splitting editor and community
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { createAuth } from '@pascal-app/auth/server'
|
||||
import { db } from '@pascal-app/db'
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/env.mjs'
|
||||
import { BASE_URL } from './utils'
|
||||
|
||||
// Initialize Resend only if API key is available
|
||||
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
|
||||
|
||||
export const auth = createAuth({
|
||||
db,
|
||||
appName: 'Pascal Editor',
|
||||
baseURL: BASE_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
googleClientId: env.GOOGLE_CLIENT_ID,
|
||||
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: 'Pascal <noreply@pascal.app>',
|
||||
to: email,
|
||||
subject: 'Sign in to Pascal Editor',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>Sign in to Pascal Editor</h2>
|
||||
<p>Click the button below to sign in to your account:</p>
|
||||
<a href="${url}" style="display: inline-block; background-color: #000; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0;">
|
||||
Sign In
|
||||
</a>
|
||||
<p style="color: #666; font-size: 14px;">This link will expire in 5 minutes.</p>
|
||||
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
console.log(`✓ Magic link email sent to ${email}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to send magic link email:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type Session = typeof auth.$Infer.Session
|
||||
export type User = typeof auth.$Infer.Session.user
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Navigation helpers for project-based routing
|
||||
*/
|
||||
|
||||
export function getEditorUrl(projectId: string): string {
|
||||
return `/editor/${projectId}`
|
||||
}
|
||||
|
||||
export function getViewerUrl(projectId: string): string {
|
||||
return `/viewer/${projectId}`
|
||||
}
|
||||
|
||||
export function getHomeUrl(): string {
|
||||
return '/'
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
|
||||
/**
|
||||
* Supabase client for client-side use with anon key
|
||||
* Uses Row Level Security (RLS) policies
|
||||
*/
|
||||
export const supabase = createClient<SupabaseDatabase>(supabaseUrl, supabaseAnonKey)
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseDatabase } from '@pascal-app/db'
|
||||
import { env } from '@/env.mjs'
|
||||
|
||||
/**
|
||||
* Safety check: warn loudly if a Vercel preview deployment is using
|
||||
* the production Supabase instance. This catches misconfigured branching.
|
||||
*/
|
||||
if (
|
||||
process.env.VERCEL_ENV === 'preview' &&
|
||||
process.env.SUPABASE_URL &&
|
||||
env.NEXT_PUBLIC_SUPABASE_URL === process.env.SUPABASE_URL
|
||||
) {
|
||||
// If the Supabase integration set a branch-specific SUPABASE_URL,
|
||||
// it should differ from NEXT_PUBLIC_SUPABASE_URL (which comes from the
|
||||
// generic env vars pointing at production). When they match, the
|
||||
// integration likely skipped branch creation for this PR.
|
||||
console.warn(
|
||||
'⚠️ [supabase] Preview deployment appears to be using the PRODUCTION ' +
|
||||
'Supabase instance. Supabase branching may not be configured for this PR. ' +
|
||||
'See: https://supabase.com/docs/guides/deployment/branching',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supabase client for server-side use with service role key
|
||||
* Bypasses Row Level Security (RLS) - use with caution
|
||||
* Always filter by user_id to enforce permissions
|
||||
*/
|
||||
export const supabaseAdmin = createClient<SupabaseDatabase>(
|
||||
env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
{
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ScanNode as ScanNodeSchema,
|
||||
GuideNode as GuideNodeSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
createAssetUploadUrl,
|
||||
confirmAssetUpload,
|
||||
type AssetType,
|
||||
} from '@/features/community/lib/assets/actions'
|
||||
import { useUploadStore } from '@pascal-app/editor'
|
||||
import { useEditor } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Upload a file directly to Supabase Storage via signed URL with progress tracking.
|
||||
* Runs entirely outside React — survives component unmounts.
|
||||
*/
|
||||
export function uploadAssetWithProgress(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = useUploadStore.getState()
|
||||
store.startUpload(levelId, assetType, file.name)
|
||||
|
||||
// Run async work without blocking the caller
|
||||
doUpload(projectId, levelId, file, assetType).catch(() => {
|
||||
// errors are already recorded in the store by doUpload
|
||||
})
|
||||
}
|
||||
|
||||
async function doUpload(
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
assetType: AssetType,
|
||||
) {
|
||||
const store = () => useUploadStore.getState()
|
||||
|
||||
// Phase 1: Get signed URL
|
||||
const urlResult = await createAssetUploadUrl(
|
||||
projectId,
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!urlResult.success) {
|
||||
store().setError(levelId, urlResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Upload directly to Supabase via XHR (for progress)
|
||||
store().setStatus(levelId, 'uploading')
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
useUploadStore.getState().setProgress(levelId, pct)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Upload failed: HTTP ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
|
||||
|
||||
xhr.open('PUT', urlResult.signedUrl)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed'
|
||||
store().setError(levelId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 3: Confirm upload and record in DB
|
||||
store().setStatus(levelId, 'confirming')
|
||||
|
||||
const confirmResult = await confirmAssetUpload(
|
||||
projectId,
|
||||
urlResult.assetId,
|
||||
urlResult.storageKey,
|
||||
file.name,
|
||||
file.type || null,
|
||||
assetType,
|
||||
)
|
||||
|
||||
if (!confirmResult.success) {
|
||||
store().setError(levelId, confirmResult.error)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Create scene node (works even if component is unmounted)
|
||||
const Schema = assetType === 'scan' ? ScanNodeSchema : GuideNodeSchema
|
||||
const node = Schema.parse({
|
||||
url: confirmResult.url,
|
||||
name: file.name,
|
||||
parentId: levelId,
|
||||
})
|
||||
useScene.getState().createNode(node, levelId as AnyNodeId)
|
||||
useEditor.getState().setSelectedReferenceId(node.id)
|
||||
|
||||
store().setResult(levelId, confirmResult.url)
|
||||
|
||||
// Auto-clear after a short delay so the UI shows "done" briefly
|
||||
setTimeout(() => {
|
||||
useUploadStore.getState().clearUpload(levelId)
|
||||
}, 1500)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.NEXT_PUBLIC_VERCEL_ENV === 'development'
|
||||
|
||||
export const isProduction =
|
||||
process.env.NODE_ENV === 'production' || process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
|
||||
export const isPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
|
||||
/**
|
||||
* Base URL for the application
|
||||
* Uses NEXT_PUBLIC_* variables which are available at build time
|
||||
*/
|
||||
export const BASE_URL = (() => {
|
||||
// Development: localhost
|
||||
if (isDevelopment) {
|
||||
return process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`
|
||||
}
|
||||
|
||||
// Preview deployments: use Vercel branch URL
|
||||
if (isPreview && process.env.NEXT_PUBLIC_VERCEL_URL) {
|
||||
return `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
}
|
||||
|
||||
// Production: use custom domain or Vercel production URL
|
||||
if (isProduction) {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
(process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
|
||||
: 'https://editor.pascal.app')
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback (should never reach here in normal operation)
|
||||
return process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
})()
|
||||
Reference in New Issue
Block a user