diff --git a/apps/editor/app/api/presets/[id]/route.ts b/apps/editor/app/api/presets/[id]/route.ts new file mode 100644 index 00000000..450ed884 --- /dev/null +++ b/apps/editor/app/api/presets/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import { supabaseAdmin } from '@/lib/supabase/server' + +// PUT /api/presets/[id] +// Accepts any subset of: name, data, is_community +export async function PUT( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const body = await req.json() + const { name, data, is_community } = body + + if (name === undefined && data === undefined && is_community === undefined) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }) + } + + const existingResult = await supabaseAdmin + .from('presets') + .select('user_id') + .eq('id', id) + .single() + const existing = existingResult.data as { user_id: string | null } | null + + if (!existing || existing.user_id !== session.user.id) { + return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 }) + } + + const updates: Record = {} + if (name !== undefined) updates.name = name + if (data !== undefined) updates.data = data + if (is_community !== undefined) updates.is_community = is_community + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: preset, error } = await (supabaseAdmin as any) + .from('presets') + .update(updates) + .eq('id', id) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ preset }) +} + +// DELETE /api/presets/[id] +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const existingResult = await supabaseAdmin + .from('presets') + .select('user_id, thumbnail_url') + .eq('id', id) + .single() + const existing = existingResult.data as { user_id: string | null; thumbnail_url: string | null } | null + + if (!existing || existing.user_id !== session.user.id) { + return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 }) + } + + // Delete thumbnail from storage if present + if (existing.thumbnail_url) { + const url = existing.thumbnail_url as string + const match = url.match(/preset-thumbnails\/(.+)$/) + if (match?.[1]) { + await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]]) + } + } + + const { error } = await supabaseAdmin.from('presets').delete().eq('id', id) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ success: true }) +} diff --git a/apps/editor/app/api/presets/[id]/thumbnail/route.ts b/apps/editor/app/api/presets/[id]/thumbnail/route.ts new file mode 100644 index 00000000..4f3a69f0 --- /dev/null +++ b/apps/editor/app/api/presets/[id]/thumbnail/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import { supabaseAdmin } from '@/lib/supabase/server' + +// POST /api/presets/[id]/thumbnail +// Accepts a raw PNG blob, uploads to preset-thumbnails bucket, updates thumbnail_url +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const existingResult = await supabaseAdmin + .from('presets') + .select('user_id') + .eq('id', id) + .single() + const existing = existingResult.data as { user_id: string | null } | null + + if (!existing || existing.user_id !== session.user.id) { + return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 }) + } + + const blob = await req.blob() + + const filename = `${id}/thumbnail.png` + const { data: uploadData, error: uploadError } = await supabaseAdmin.storage + .from('preset-thumbnails') + .upload(filename, blob, { + contentType: 'image/png', + upsert: true, + }) + + if (uploadError) { + return NextResponse.json({ error: uploadError.message }, { status: 500 }) + } + + const { data: urlData } = supabaseAdmin.storage + .from('preset-thumbnails') + .getPublicUrl(uploadData.path) + + const thumbnailUrl = `${urlData.publicUrl}?t=${Date.now()}` + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: updateError } = await (supabaseAdmin as any) + .from('presets') + .update({ thumbnail_url: thumbnailUrl }) + .eq('id', id) + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + return NextResponse.json({ thumbnail_url: thumbnailUrl }) +} diff --git a/apps/editor/app/api/presets/route.ts b/apps/editor/app/api/presets/route.ts new file mode 100644 index 00000000..f8bb6a54 --- /dev/null +++ b/apps/editor/app/api/presets/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { auth } from '@/lib/auth' +import { supabaseAdmin } from '@/lib/supabase/server' +import { createId } from '@pascal-app/db' + +// GET /api/presets?type=door|window&tab=community|mine +export async function GET(req: NextRequest) { + const { searchParams } = req.nextUrl + const type = searchParams.get('type') + const tab = searchParams.get('tab') ?? 'community' + + if (!type || (type !== 'door' && type !== 'window')) { + return NextResponse.json({ error: 'Invalid type' }, { status: 400 }) + } + + if (tab === 'mine') { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data, error } = await supabaseAdmin + .from('presets') + .select('*') + .eq('type', type) + .eq('user_id', session.user.id) + .order('created_at', { ascending: false }) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ presets: data }) + } + + // community tab + const { data, error } = await supabaseAdmin + .from('presets') + .select('*') + .eq('type', type) + .eq('is_community', true) + .order('created_at', { ascending: false }) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ presets: data }) +} + +// POST /api/presets +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await req.json() + const { type, name, data, thumbnailUrl } = body + + if (!type || !name || !data) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) + } + + if (type !== 'door' && type !== 'window') { + return NextResponse.json({ error: 'Invalid type' }, { status: 400 }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: preset, error } = await (supabaseAdmin as any) + .from('presets') + .insert({ + id: createId('preset'), + type, + name, + data, + thumbnail_url: thumbnailUrl ?? null, + user_id: session.user.id, + is_community: false, + }) + .select() + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ preset }, { status: 201 }) +} diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index ec095031..0fe78eee 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -25,6 +25,7 @@ import { FloatingActionMenu } from './floating-action-menu' import { Grid } from './grid' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' +import { PresetThumbnailGenerator } from './preset-thumbnail-generator' import { ThumbnailGenerator } from './thumbnail-generator' @@ -120,6 +121,7 @@ export default function Editor({ projectId }: EditorProps) { + diff --git a/apps/editor/components/editor/preset-thumbnail-generator.tsx b/apps/editor/components/editor/preset-thumbnail-generator.tsx new file mode 100644 index 00000000..49ee33c5 --- /dev/null +++ b/apps/editor/components/editor/preset-thumbnail-generator.tsx @@ -0,0 +1,123 @@ +'use client' + +import { emitter, sceneRegistry } from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect } from 'react' +import * as THREE from 'three' + +const THUMBNAIL_SIZE = 1080 +const CAMERA_FOV = 45 + +export const PresetThumbnailGenerator = () => { + const gl = useThree((state) => state.gl) + const scene = useThree((state) => state.scene) + + const generate = useCallback( + async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => { + const target = sceneRegistry.nodes.get(nodeId) + if (!target) { + console.error('❌ PresetThumbnail: node not found', nodeId) + return + } + + // Compute each mesh's transform relative to the target node (cancels world + // position/rotation), so the item is always rendered at origin with a known + // neutral orientation regardless of where it's placed in the scene. + target.updateWorldMatrix(true, true) + const targetInverse = new THREE.Matrix4().copy(target.matrixWorld).invert() + const relMatrix = new THREE.Matrix4() + + const clones: THREE.Object3D[] = [] + target.traverse((obj) => { + if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return + const c = obj.clone(false) // shallow clone: copies geometry, material, visible — no children + relMatrix.multiplyMatrices(targetInverse, obj.matrixWorld) + relMatrix.decompose(c.position, c.quaternion, c.scale) + scene.add(c) + clones.push(c) + }) + + if (clones.length === 0) { + console.error('❌ PresetThumbnail: no renderable objects found', nodeId) + return + } + + // Combined bounding box across all clones + const box = new THREE.Box3() + for (const c of clones) box.expandByObject(c) + + if (box.isEmpty()) { + for (const c of clones) scene.remove(c) + console.error('❌ PresetThumbnail: empty bounding box', nodeId) + return + } + + const sphere = new THREE.Sphere() + box.getBoundingSphere(sphere) + + // Camera: aspect matches canvas (center-cropped to square after render) + const { width, height } = gl.domElement + const camera = new THREE.PerspectiveCamera(CAMERA_FOV, width / height, 0.01, 1000) + const dir = new THREE.Vector3(-0.5, 0.5, 0.5).normalize() + const fovRad = (CAMERA_FOV * Math.PI) / 180 + const dist = (sphere.radius / Math.tan(fovRad / 2)) * 1.3 + camera.position.copy(sphere.center).addScaledVector(dir, dist) + camera.lookAt(sphere.center) + camera.updateProjectionMatrix() + + // Hide all scene geometry except the clones — leave lights, cameras, etc. intact + const cloneSet = new Set(clones) + const snapshot = new Map() + scene.traverse((obj) => { + if (cloneSet.has(obj)) return + if (!(obj instanceof THREE.Mesh || obj instanceof THREE.Line || obj instanceof THREE.Points)) return + snapshot.set(obj, obj.visible) + obj.visible = false + }) + + gl.render(scene, camera) + + // Restore visibility and remove clones + snapshot.forEach((wasVisible, obj) => { + obj.visible = wasVisible + }) + for (const c of clones) scene.remove(c) + + // Center-crop to square and scale to THUMBNAIL_SIZE + const minDim = Math.min(width, height) + const sx = Math.round((width - minDim) / 2) + const sy = Math.round((height - minDim) / 2) + const offscreen = document.createElement('canvas') + offscreen.width = THUMBNAIL_SIZE + offscreen.height = THUMBNAIL_SIZE + const ctx = offscreen.getContext('2d')! + ctx.drawImage(gl.domElement, sx, sy, minDim, minDim, 0, 0, THUMBNAIL_SIZE, THUMBNAIL_SIZE) + + offscreen.toBlob(async (blob) => { + if (!blob) { + console.error('❌ PresetThumbnail: failed to create blob') + return + } + const res = await fetch(`/api/presets/${presetId}/thumbnail`, { + method: 'POST', + body: blob, + headers: { 'Content-Type': 'image/png' }, + }) + if (res.ok) { + const json = await res.json() + emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl: json.thumbnail_url }) + } else { + console.error('❌ PresetThumbnail: upload failed', await res.text()) + } + }, 'image/png') + }, + [gl, scene], + ) + + useEffect(() => { + emitter.on('preset:generate-thumbnail', generate) + return () => emitter.off('preset:generate-thumbnail', generate) + }, [generate]) + + return null +} diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx index 2c6d8457..45ef66ce 100644 --- a/apps/editor/components/ui/panels/door-panel.tsx +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -1,8 +1,8 @@ 'use client' -import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, DoorNode, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' +import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' @@ -14,6 +14,7 @@ import { MetricControl } from '../controls/metric-control' import { ToggleControl } from '../controls/toggle-control' import { SegmentedControl } from '../controls/segmented-control' import { ActionButton, ActionGroup } from '../controls/action-button' +import { PresetsPopover } from './presets/presets-popover' export function DoorPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -116,6 +117,58 @@ export function DoorPanel() { handleUpdate({ segments: updated }) } + const getDoorPresetData = useCallback(() => { + if (!node) return null + return { + width: node.width, + height: node.height, + frameThickness: node.frameThickness, + frameDepth: node.frameDepth, + contentPadding: node.contentPadding, + hingesSide: node.hingesSide, + swingDirection: node.swingDirection, + threshold: node.threshold, + thresholdHeight: node.thresholdHeight, + handle: node.handle, + handleHeight: node.handleHeight, + handleSide: node.handleSide, + doorCloser: node.doorCloser, + panicBar: node.panicBar, + panicBarHeight: node.panicBarHeight, + segments: node.segments, + } + }, [node]) + + const handleSavePreset = useCallback(async (name: string) => { + const data = getDoorPresetData() + if (!data || !selectedId) return + const res = await fetch('/api/presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'door', name, data }), + }) + if (res.ok) { + const json = await res.json() + const presetId = json.preset?.id + if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId }) + } + }, [getDoorPresetData, selectedId]) + + const handleOverwritePreset = useCallback(async (id: string) => { + const data = getDoorPresetData() + if (!data || !selectedId) return + const res = await fetch(`/api/presets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }) + if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId }) + }, [getDoorPresetData, selectedId]) + + const handleApplyPreset = useCallback((data: Record) => { + handleUpdate(data as Partial) + }, [handleUpdate]) + if (!node || node.type !== 'door' || selectedIds.length !== 1) return null const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0) @@ -128,6 +181,16 @@ export function DoorPanel() { onClose={handleClose} width={320} > + {/* Presets strip */} +
+ + + +
+ Xwall} diff --git a/apps/editor/components/ui/panels/presets/presets-popover.tsx b/apps/editor/components/ui/panels/presets/presets-popover.tsx new file mode 100644 index 00000000..10c6e4ed --- /dev/null +++ b/apps/editor/components/ui/panels/presets/presets-popover.tsx @@ -0,0 +1,458 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { BookMarked, Check, Globe, GlobeLock, MoreHorizontal, Pencil, Plus, Save, Trash2, Users, X } from 'lucide-react' +import { emitter } from '@pascal-app/core' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover' +import { useAuth } from '@/features/community/lib/auth/hooks' +import { cn } from '@/lib/utils' + +export type PresetType = 'door' | 'window' + +export interface PresetData { + id: string + type: string + name: string + data: Record + thumbnail_url: string | null + user_id: string | null + is_community: boolean + created_at: string +} + +type Tab = 'community' | 'mine' + +interface PresetsPopoverProps { + type: PresetType + /** Apply preset data to the current node */ + onApply: (data: Record) => void + /** Save current node state as a new preset with the given name */ + onSave: (name: string) => Promise + /** Overwrite an existing preset's data with the current node state */ + onOverwrite: (id: string) => Promise + children: React.ReactNode +} + +export function PresetsPopover({ type, onApply, onSave, onOverwrite, children }: PresetsPopoverProps) { + const { isAuthenticated } = useAuth() + const [open, setOpen] = useState(false) + const [tab, setTab] = useState('community') + const [presets, setPresets] = useState([]) + const [loading, setLoading] = useState(false) + + // New preset save state + const [showSaveInput, setShowSaveInput] = useState(false) + const [saveName, setSaveName] = useState('') + const [saving, setSaving] = useState(false) + + // Rename state + const [renamingId, setRenamingId] = useState(null) + const [renameValue, setRenameValue] = useState('') + + // Delete confirmation + const [deletingId, setDeletingId] = useState(null) + + // Overwrite feedback (shows check icon briefly after overwrite) + const [overwrittenId, setOverwrittenId] = useState(null) + + const fetchPresets = useCallback(async () => { + setLoading(true) + try { + const res = await fetch(`/api/presets?type=${type}&tab=${tab}`) + if (res.ok) { + const json = await res.json() + setPresets(json.presets ?? []) + } + } finally { + setLoading(false) + } + }, [type, tab]) + + useEffect(() => { + if (open) fetchPresets() + }, [open, fetchPresets]) + + useEffect(() => { + if (!isAuthenticated && tab === 'mine') setTab('community') + }, [isAuthenticated, tab]) + + useEffect(() => { + const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => { + setPresets((prev) => + prev.map((p) => (p.id === presetId ? { ...p, thumbnail_url: thumbnailUrl } : p)), + ) + } + emitter.on('preset:thumbnail-updated', handler) + return () => emitter.off('preset:thumbnail-updated', handler) + }, []) + + const handleSaveNew = async () => { + if (!saveName.trim()) return + setSaving(true) + try { + await onSave(saveName.trim()) + setSaveName('') + setShowSaveInput(false) + if (tab === 'mine') fetchPresets() + else setTab('mine') + } finally { + setSaving(false) + } + } + + const handleRename = async (id: string) => { + if (!renameValue.trim()) return + const res = await fetch(`/api/presets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: renameValue.trim() }), + }) + if (res.ok) { + setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p))) + setRenamingId(null) + } + } + + const handleDelete = async (id: string) => { + const res = await fetch(`/api/presets/${id}`, { method: 'DELETE' }) + if (res.ok) { + setPresets((prev) => prev.filter((p) => p.id !== id)) + setDeletingId(null) + } + } + + const handleOverwrite = async (id: string) => { + await onOverwrite(id) + setOverwrittenId(id) + setTimeout(() => setOverwrittenId(null), 1500) + } + + const handleToggleCommunity = async (id: string, current: boolean) => { + const res = await fetch(`/api/presets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_community: !current }), + }) + if (res.ok) { + setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p))) + } + } + + return ( + + {children} + + {/* Header */} +
+
+ + + {type === 'door' ? 'Door' : 'Window'} Presets + +
+ {isAuthenticated && ( + + )} +
+ + {/* New preset name input */} + {showSaveInput && ( +
+ setSaveName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveNew() + if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') } + }} + placeholder="Preset name…" + className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30" + /> + + +
+ )} + + {/* Tabs */} +
+ setTab('community')}> + + Community + + { if (isAuthenticated) setTab('mine') }} + disabled={!isAuthenticated} + > + + My presets + +
+ + {/* Content */} +
+ {loading ? ( +
+
+
+ ) : presets.length === 0 ? ( + + ) : ( +
    + {presets.map((preset) => ( + { onApply(preset.data); setOpen(false) }} + onOverwrite={() => handleOverwrite(preset.id)} + onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)} + onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }} + onRenameChange={setRenameValue} + onRenameConfirm={() => handleRename(preset.id)} + onRenameCancel={() => setRenamingId(null)} + onDeleteRequest={() => setDeletingId(preset.id)} + onDeleteConfirm={() => handleDelete(preset.id)} + onDeleteCancel={() => setDeletingId(null)} + /> + ))} +
+ )} +
+ + + ) +} + +function TabButton({ + active, + onClick, + disabled, + children, +}: { + active: boolean + onClick: () => void + disabled?: boolean + children: React.ReactNode +}) { + return ( + + ) +} + +function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boolean }) { + return ( +
+ +

+ {tab === 'community' + ? 'No community presets yet.' + : isAuthenticated + ? 'No presets saved yet. Use "Save new" to save the current configuration.' + : 'Sign in to save and view your presets.'} +

+
+ ) +} + +interface PresetRowProps { + preset: PresetData + isMine: boolean + renamingId: string | null + renameValue: string + deletingId: string | null + overwrittenId: string | null + onApply: () => void + onOverwrite: () => void + onToggleCommunity: () => void + onStartRename: () => void + onRenameChange: (v: string) => void + onRenameConfirm: () => void + onRenameCancel: () => void + onDeleteRequest: () => void + onDeleteConfirm: () => void + onDeleteCancel: () => void +} + +function PresetRow({ + preset, + isMine, + renamingId, + renameValue, + deletingId, + overwrittenId, + onApply, + onOverwrite, + onToggleCommunity, + onStartRename, + onRenameChange, + onRenameConfirm, + onRenameCancel, + onDeleteRequest, + onDeleteConfirm, + onDeleteCancel, +}: PresetRowProps) { + const isRenaming = renamingId === preset.id + const isDeleting = deletingId === preset.id + const justOverwritten = overwrittenId === preset.id + + if (isDeleting) { + return ( +
  • + Delete "{preset.name}"? +
    + + +
    +
  • + ) + } + + if (isRenaming) { + return ( +
  • + onRenameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onRenameConfirm() + if (e.key === 'Escape') onRenameCancel() + }} + className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30" + /> + + +
  • + ) + } + + return ( +
  • + {/* Thumbnail */} +
    + {preset.thumbnail_url ? ( + // eslint-disable-next-line @next/next/no-img-element + {preset.name} + ) : ( +
    +
    +
    + )} +
    + + {/* Name + date — clicking applies */} + + + {/* Actions — 3-dot dropdown, only in "My presets" */} + {isMine && ( + + + + + + + + Update with current + + + {preset.is_community + ? <>Remove from community + : <>Share with community} + + + + Rename + + + + Delete + + + + )} +
  • + ) +} diff --git a/apps/editor/components/ui/panels/window-panel.tsx b/apps/editor/components/ui/panels/window-panel.tsx index a721b8a4..ed1b55b1 100644 --- a/apps/editor/components/ui/panels/window-panel.tsx +++ b/apps/editor/components/ui/panels/window-panel.tsx @@ -1,8 +1,8 @@ 'use client' -import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, WindowNode, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' +import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' @@ -13,6 +13,7 @@ import { SliderControl } from '../controls/slider-control' import { MetricControl } from '../controls/metric-control' import { ToggleControl } from '../controls/toggle-control' import { ActionButton, ActionGroup } from '../controls/action-button' +import { PresetsPopover } from './presets/presets-popover' export function WindowPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -91,6 +92,53 @@ export function WindowPanel() { setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) + const getWindowPresetData = useCallback(() => { + if (!node) return null + return { + width: node.width, + height: node.height, + frameThickness: node.frameThickness, + frameDepth: node.frameDepth, + columnRatios: node.columnRatios, + rowRatios: node.rowRatios, + columnDividerThickness: node.columnDividerThickness, + rowDividerThickness: node.rowDividerThickness, + sill: node.sill, + sillDepth: node.sillDepth, + sillThickness: node.sillThickness, + } + }, [node]) + + const handleSavePreset = useCallback(async (name: string) => { + const data = getWindowPresetData() + if (!data || !selectedId) return + const res = await fetch('/api/presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'window', name, data }), + }) + if (res.ok) { + const json = await res.json() + const presetId = json.preset?.id + if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId }) + } + }, [getWindowPresetData, selectedId]) + + const handleOverwritePreset = useCallback(async (id: string) => { + const data = getWindowPresetData() + if (!data || !selectedId) return + const res = await fetch(`/api/presets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }), + }) + if (res.ok) emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId }) + }, [getWindowPresetData, selectedId]) + + const handleApplyPreset = useCallback((data: Record) => { + handleUpdate(data as Partial) + }, [handleUpdate]) + if (!node || node.type !== 'window' || selectedIds.length !== 1) return null const numCols = node.columnRatios.length @@ -134,6 +182,16 @@ export function WindowPanel() { onClose={handleClose} width={320} > + {/* Presets strip */} +
    + + + +
    + Xpos} diff --git a/apps/editor/features/community/lib/assets/actions.ts b/apps/editor/features/community/lib/assets/actions.ts index 3414e8a0..66821a5b 100644 --- a/apps/editor/features/community/lib/assets/actions.ts +++ b/apps/editor/features/community/lib/assets/actions.ts @@ -1,6 +1,5 @@ 'use server' -import type { createClient } from '@supabase/supabase-js' import { createServerSupabaseClient } from '../database/server' import { getSession } from '../auth/server' import { createId } from '../utils/id-generator' @@ -83,7 +82,7 @@ export async function uploadProjectAsset( const url = urlData.publicUrl // Record in project_assets table - const { error: insertError } = await (supabase.from('project_assets') as any).insert({ + const { error: insertError } = await (supabase as any).from('project_assets').insert({ id: assetId, project_id: projectId, storage_key: storageKey, @@ -144,9 +143,7 @@ export async function createAssetUploadUrl( const assetId = createId('asset') const storageKey = ext ? `${projectId}/${assetId}.${ext}` : `${projectId}/${assetId}` - const { data, error } = await ( - supabase as ReturnType - ).storage + const { data, error } = await supabase.storage .from(BUCKET) .createSignedUploadUrl(storageKey) @@ -203,7 +200,7 @@ export async function confirmAssetUpload( const url = urlData.publicUrl - const { error: insertError } = await (supabase.from('project_assets') as any).insert({ + const { error: insertError } = await (supabase as any).from('project_assets').insert({ id: assetId, project_id: projectId, storage_key: storageKey, @@ -273,7 +270,7 @@ export async function deleteProjectAssetByUrl( } // Delete DB row by storage_key scoped to this project - const { error: dbError } = await (supabase.from('project_assets') as any) + const { error: dbError } = await (supabase as any).from('project_assets') .delete() .eq('project_id', projectId) .eq('storage_key', storageKeyFromUrl) diff --git a/apps/editor/hooks/use-keyboard.ts b/apps/editor/hooks/use-keyboard.ts index c0985007..f81d94cb 100644 --- a/apps/editor/hooks/use-keyboard.ts +++ b/apps/editor/hooks/use-keyboard.ts @@ -19,12 +19,6 @@ export const useKeyboard = () => { // Clear selections to close UI panels, but KEEP the active building and level context useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) useEditor.getState().setSelectedReferenceId(null) - - // If in build mode, switch back to select mode - const { mode } = useEditor.getState() - if (mode === 'build') { - useEditor.getState().setMode('select') - } } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') diff --git a/apps/editor/instrumentation.ts b/apps/editor/instrumentation.ts index 81648c17..ffe74034 100644 --- a/apps/editor/instrumentation.ts +++ b/apps/editor/instrumentation.ts @@ -37,5 +37,14 @@ export async function register() { }) console.log('Created "project-assets" storage bucket') } + + if (!bucketNames.has('preset-thumbnails')) { + await supabase.storage.createBucket('preset-thumbnails', { + public: true, + fileSizeLimit: 5 * 1024 * 1024, // 5MB + allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'], + }) + console.log('Created "preset-thumbnails" storage bucket') + } } } diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 61250974..63c87dac 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -73,6 +73,11 @@ type ToolEvents = { 'tool:cancel': undefined } +type PresetEvents = { + 'preset:generate-thumbnail': { presetId: string; nodeId: string } + 'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string } +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'item', ItemEvent> & @@ -86,6 +91,7 @@ type EditorEvents = GridEvents & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & CameraControlEvents & - ToolEvents + ToolEvents & + PresetEvents export const emitter = mitt() diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 65773f88..b64110cc 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -14,3 +14,6 @@ export * from './projects/assets' export * from './projects/likes' export * from './projects/models' export * from './projects/projects' + +// Presets table +export * from './presets/presets' diff --git a/packages/db/src/schema/presets/presets.ts b/packages/db/src/schema/presets/presets.ts new file mode 100644 index 00000000..57c4ec62 --- /dev/null +++ b/packages/db/src/schema/presets/presets.ts @@ -0,0 +1,30 @@ +import { pgTable, index, text, boolean, jsonb } from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { id, timestampsColumns } from '../../helpers' +import { users } from '../auth/users' + +export const presets = pgTable( + 'presets', + (t) => ({ + id: id('preset'), + type: t.text('type').notNull(), // 'door' | 'window' + name: t.text('name').notNull(), + data: t.jsonb('data').notNull(), + thumbnailUrl: t.text('thumbnail_url'), + userId: t + .text('user_id') + .references(() => users.id, { onDelete: 'cascade' }), + isCommunity: t.boolean('is_community').notNull().default(false), + ...timestampsColumns, + }), + (t) => [ + index('presets_type_idx').on(t.type), + index('presets_user_id_idx').on(t.userId), + index('presets_is_community_idx').on(t.isCommunity), + ], +).enableRLS() + +export type Preset = typeof presets.$inferSelect +export type NewPreset = typeof presets.$inferInsert +export const insertPresetSchema = createInsertSchema(presets) +export const selectPresetSchema = createSelectSchema(presets) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index bcab2216..09f67e3a 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -116,6 +116,42 @@ export interface Database { updated_at?: string } } + presets: { + Row: { + id: string + type: string + name: string + data: Json + thumbnail_url: string | null + user_id: string | null + is_community: boolean + created_at: string + updated_at: string + } + Insert: { + id?: string + type: string + name: string + data: Json + thumbnail_url?: string | null + user_id?: string | null + is_community?: boolean + created_at?: string + updated_at?: string + } + Update: { + id?: string + type?: string + name?: string + data?: Json + thumbnail_url?: string | null + user_id?: string | null + is_community?: boolean + created_at?: string + updated_at?: string + } + Relationships: [] + } } Views: {} Functions: {} diff --git a/packages/viewer/src/systems/zone/zone-system.tsx b/packages/viewer/src/systems/zone/zone-system.tsx index ba7c122c..f845b379 100644 --- a/packages/viewer/src/systems/zone/zone-system.tsx +++ b/packages/viewer/src/systems/zone/zone-system.tsx @@ -46,14 +46,12 @@ export const ZoneSystem = () => { const prevLabel = document.getElementById(`${lastHighlightedZoneRef.current}-label`) const pin = prevLabel?.querySelector('.label-pin') as HTMLElement | null if (pin) pin.style.opacity = '0' - console.log('setting opacity to 0 for', lastHighlightedZoneRef.current) } // Fade in new zone label-pin if (highlightedZone) { const label = document.getElementById(`${highlightedZone}-label`) const pin = label?.querySelector('.label-pin') as HTMLElement | null if (pin) pin.style.opacity = '1' - console.log('setting opacity to 1 for', highlightedZone) } lastHighlightedZoneRef.current = highlightedZone diff --git a/supabase/migrations/20260304130720_create_presets.sql b/supabase/migrations/20260304130720_create_presets.sql new file mode 100644 index 00000000..0fb841ae --- /dev/null +++ b/supabase/migrations/20260304130720_create_presets.sql @@ -0,0 +1,58 @@ +-- Presets table: stores door/window presets for community and user use +CREATE TABLE "presets" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, -- 'door' | 'window' + "name" text NOT NULL, + "data" jsonb NOT NULL, + "thumbnail_url" text, + "user_id" text REFERENCES "auth_users"("id") ON DELETE CASCADE, + "is_community" boolean NOT NULL DEFAULT false, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +CREATE INDEX "presets_type_idx" ON "presets"("type"); +--> statement-breakpoint +CREATE INDEX "presets_user_id_idx" ON "presets"("user_id"); +--> statement-breakpoint +CREATE INDEX "presets_is_community_idx" ON "presets"("is_community"); + +--> statement-breakpoint +ALTER TABLE "presets" ENABLE ROW LEVEL SECURITY; + +--> statement-breakpoint +-- Anyone can read community presets +CREATE POLICY "Anyone can view community presets" + ON "presets" FOR SELECT + USING ("is_community" = true); + +--> statement-breakpoint +-- Users can view their own presets +CREATE POLICY "Users can view own presets" + ON "presets" FOR SELECT + USING ("user_id" = current_setting('app.user_id', true)::TEXT); + +--> statement-breakpoint +-- Users can insert their own presets +CREATE POLICY "Users can insert own presets" + ON "presets" FOR INSERT + WITH CHECK ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false); + +--> statement-breakpoint +-- Users can update their own presets +CREATE POLICY "Users can update own presets" + ON "presets" FOR UPDATE + USING ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false); + +--> statement-breakpoint +-- Users can delete their own presets +CREATE POLICY "Users can delete own presets" + ON "presets" FOR DELETE + USING ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false); + +--> statement-breakpoint +CREATE TRIGGER "update_presets_updated_at" + BEFORE UPDATE ON "presets" + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/supabase/migrations/meta/20260304121520_snapshot.json b/supabase/migrations/meta/20260304121520_snapshot.json new file mode 100644 index 00000000..773c1d38 --- /dev/null +++ b/supabase/migrations/meta/20260304121520_snapshot.json @@ -0,0 +1,1271 @@ +{ + "id": "75cf6a24-dec8-44c8-97a6-5dd4e8ad7fc5", + "prevId": "022423df-5615-44b1-a725-a9c1f4baa074", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_jwks": { + "name": "auth_jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_project_id": { + "name": "active_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auth_sessions_impersonated_by_auth_users_id_fk": { + "name": "auth_sessions_impersonated_by_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_url": { + "name": "x_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "youtube_url": { + "name": "youtube_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "role": { + "name": "role", + "type": "auth_user_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_unique_index": { + "name": "email_unique_index", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "username_unique_index": { + "name": "username_unique_index", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_index": { + "name": "verification_identifier_index", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.feedback": { + "name": "feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "images": { + "name": "images", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_addresses": { + "name": "projects_addresses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "street_number": { + "name": "street_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_short": { + "name": "route_short", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_long": { + "name": "state_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_suffix": { + "name": "postal_code_suffix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_long": { + "name": "country_long", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "raw_json": { + "name": "raw_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "address_components_unique": { + "name": "address_components_unique", + "nullsNotDistinct": false, + "columns": [ + "street_number", + "route", + "city", + "state", + "postal_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.project_assets": { + "name": "project_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_assets_project_id_projects_id_fk": { + "name": "project_assets_project_id_projects_id_fk", + "tableFrom": "project_assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_likes": { + "name": "projects_likes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_likes_project_id_projects_id_fk": { + "name": "projects_likes_project_id_projects_id_fk", + "tableFrom": "projects_likes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_likes_project_user_unique": { + "name": "projects_likes_project_user_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects_models": { + "name": "projects_models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft": { + "name": "draft", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scene_graph": { + "name": "scene_graph", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_models_project_id_projects_id_fk": { + "name": "projects_models_project_id_projects_id_fk", + "tableFrom": "projects_models", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_id": { + "name": "address_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_model_version": { + "name": "published_model_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_empty": { + "name": "is_empty", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_scans_public": { + "name": "show_scans_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_guides_public": { + "name": "show_guides_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "likes": { + "name": "likes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_address_idx": { + "name": "project_address_idx", + "columns": [ + { + "expression": "address_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_idx": { + "name": "project_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_private_idx": { + "name": "project_is_private_idx", + "columns": [ + { + "expression": "is_private", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_likes_idx": { + "name": "project_likes_idx", + "columns": [ + { + "expression": "likes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_address_id_projects_addresses_id_fk": { + "name": "projects_address_id_projects_addresses_id_fk", + "tableFrom": "projects", + "tableTo": "projects_addresses", + "columnsFrom": [ + "address_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "projects_owner_id_auth_users_id_fk": { + "name": "projects_owner_id_auth_users_id_fk", + "tableFrom": "projects", + "tableTo": "auth_users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.presets": { + "name": "presets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_community": { + "name": "is_community", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "presets_type_idx": { + "name": "presets_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presets_user_id_idx": { + "name": "presets_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "presets_is_community_idx": { + "name": "presets_is_community_idx", + "columns": [ + { + "expression": "is_community", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "presets_user_id_auth_users_id_fk": { + "name": "presets_user_id_auth_users_id_fk", + "tableFrom": "presets", + "tableTo": "auth_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.auth_user_roles": { + "name": "auth_user_roles", + "schema": "public", + "values": [ + "user", + "admin" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file