splitting editor and community

This commit is contained in:
wass08
2026-03-11 12:16:26 +01:00
parent 5108636d49
commit 7359c1fcbf
586 changed files with 6477 additions and 1546 deletions
+49
View File
@@ -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
+15
View File
@@ -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 '/'
}
+11
View File
@@ -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)
+39
View File
@@ -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,
},
},
)
+123
View File
@@ -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)
}
+44
View File
@@ -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'
})()