splitting editor and community
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
/** Three.js layer used for editor-only objects (helpers, grid, polygon editors).
|
||||
* The thumbnail camera renders only layer 0, so these are excluded from thumbnails. */
|
||||
export const EDITOR_LAYER = 1
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* 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 '/'
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import mitt from 'mitt'
|
||||
import { playSFX } from './sfx-player'
|
||||
|
||||
/**
|
||||
* SFX-specific events that tools can trigger
|
||||
*/
|
||||
type SFXEvents = {
|
||||
'sfx:grid-snap': undefined
|
||||
'sfx:item-delete': undefined
|
||||
'sfx:item-pick': undefined
|
||||
'sfx:item-place': undefined
|
||||
'sfx:item-rotate': undefined
|
||||
'sfx:structure-build': undefined
|
||||
'sfx:structure-delete': undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated event emitter for SFX
|
||||
* Tools should use this to trigger sound effects
|
||||
*/
|
||||
export const sfxEmitter = mitt<SFXEvents>()
|
||||
|
||||
/**
|
||||
* Initialize SFX Bus - connects SFX events to actual sound playback
|
||||
* Call once in your app initialization
|
||||
*/
|
||||
export function initSFXBus() {
|
||||
// Map SFX events to sound playback
|
||||
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
|
||||
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to trigger SFX events from tools
|
||||
* @example
|
||||
* triggerSFX('sfx:item-place')
|
||||
*/
|
||||
export function triggerSFX(event: keyof SFXEvents) {
|
||||
sfxEmitter.emit(event)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Howl } from 'howler'
|
||||
import useAudio from '@/store/use-audio'
|
||||
|
||||
// SFX sound definitions
|
||||
export const SFX = {
|
||||
gridSnap: '/audios/sfx/grid_snap.mp3',
|
||||
itemDelete: '/audios/sfx/item_delete.mp3',
|
||||
itemPick: '/audios/sfx/item_pick.mp3',
|
||||
itemPlace: '/audios/sfx/item_place.mp3',
|
||||
itemRotate: '/audios/sfx/item_rotate.mp3',
|
||||
structureBuild: '/audios/sfx/structure_build.mp3',
|
||||
structureDelete: '/audios/sfx/structure_delete.mp3',
|
||||
} as const
|
||||
|
||||
export type SFXName = keyof typeof SFX
|
||||
|
||||
// Preload all SFX sounds
|
||||
const sfxCache = new Map<SFXName, Howl>()
|
||||
|
||||
// Initialize all sounds
|
||||
Object.entries(SFX).forEach(([name, path]) => {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
preload: true,
|
||||
volume: 0.5, // Will be adjusted by the bus
|
||||
})
|
||||
sfxCache.set(name as SFXName, sound)
|
||||
})
|
||||
|
||||
/**
|
||||
* Play a sound effect with volume based on audio settings
|
||||
*/
|
||||
export function playSFX(name: SFXName) {
|
||||
const sound = sfxCache.get(name)
|
||||
if (!sound) {
|
||||
console.warn(`SFX not found: ${name}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||
|
||||
if (muted) return
|
||||
|
||||
// Calculate final volume (masterVolume and sfxVolume are 0-100)
|
||||
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
sound.volume(finalVolume)
|
||||
sound.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all cached SFX volumes (useful when settings change)
|
||||
*/
|
||||
export function updateSFXVolumes() {
|
||||
const { masterVolume, sfxVolume } = useAudio.getState()
|
||||
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
|
||||
sfxCache.forEach((sound) => {
|
||||
sound.volume(finalVolume)
|
||||
})
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { playSFX, updateSFXVolumes, SFX, type SFXName } from '../sfx-player'
|
||||
export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
@@ -1,39 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1,123 +0,0 @@
|
||||
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 '@/store/use-upload'
|
||||
import useEditor from '@/store/use-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)
|
||||
}
|
||||
Reference in New Issue
Block a user