Merge pull request #132 from pascalorg/feat/items-community
Feat/items community
This commit is contained in:
@@ -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<string, unknown> = {}
|
||||
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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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) {
|
||||
<ToolManager />
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator projectId={projectId} />
|
||||
<PresetThumbnailGenerator />
|
||||
<SiteEdgeLabels />
|
||||
</Viewer>
|
||||
</div>
|
||||
|
||||
@@ -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<THREE.Object3D>(clones)
|
||||
const snapshot = new Map<THREE.Object3D, boolean>()
|
||||
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
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
handleUpdate(data as Partial<DoorNode>)
|
||||
}, [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 */}
|
||||
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
|
||||
<PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
</PresetsPopover>
|
||||
</div>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>) => void
|
||||
/** Save current node state as a new preset with the given name */
|
||||
onSave: (name: string) => Promise<void>
|
||||
/** Overwrite an existing preset's data with the current node state */
|
||||
onOverwrite: (id: string) => Promise<void>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function PresetsPopover({ type, onApply, onSave, onOverwrite, children }: PresetsPopoverProps) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [tab, setTab] = useState<Tab>('community')
|
||||
const [presets, setPresets] = useState<PresetData[]>([])
|
||||
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<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
|
||||
// Delete confirmation
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
// Overwrite feedback (shows check icon briefly after overwrite)
|
||||
const [overwrittenId, setOverwrittenId] = useState<string | null>(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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight">
|
||||
{type === 'door' ? 'Door' : 'Window'} Presets
|
||||
</span>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Save new
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New preset name input */}
|
||||
{showSaveInput && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
|
||||
<input
|
||||
autoFocus
|
||||
value={saveName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
disabled={!saveName.trim() || saving}
|
||||
onClick={handleSaveNew}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSaveInput(false); setSaveName('') }}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border/50">
|
||||
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
||||
<Users className="h-3 w-3" />
|
||||
Community
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={tab === 'mine'}
|
||||
onClick={() => { if (isAuthenticated) setTab('mine') }}
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<BookMarked className="h-3 w-3" />
|
||||
My presets
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
||||
</div>
|
||||
) : presets.length === 0 ? (
|
||||
<EmptyState tab={tab} isAuthenticated={isAuthenticated} />
|
||||
) : (
|
||||
<ul className="divide-y divide-border/30">
|
||||
{presets.map((preset) => (
|
||||
<PresetRow
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isMine={tab === 'mine'}
|
||||
renamingId={renamingId}
|
||||
renameValue={renameValue}
|
||||
deletingId={deletingId}
|
||||
overwrittenId={overwrittenId}
|
||||
onApply={() => { 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)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors',
|
||||
active
|
||||
? 'text-foreground border-b-2 border-primary -mb-px'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
disabled && 'opacity-40 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
|
||||
<BookMarked className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
|
||||
<span className="text-xs text-foreground/80 truncate">Delete "{preset.name}"?</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onDeleteConfirm}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeleteCancel}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<li className="flex items-center gap-1.5 px-3 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={onRenameConfirm}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRenameCancel}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors">
|
||||
{/* Thumbnail */}
|
||||
<div className="h-12 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden">
|
||||
{preset.thumbnail_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preset.thumbnail_url} alt={preset.name} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + date — clicking applies */}
|
||||
<button onClick={onApply} className="flex-1 min-w-0 text-left">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">
|
||||
{preset.name}
|
||||
</span>
|
||||
{/* Only show globe in "My presets" — in community tab it's redundant */}
|
||||
{isMine && preset.is_community && (
|
||||
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground/60">
|
||||
{new Date(preset.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Actions — 3-dot dropdown, only in "My presets" */}
|
||||
{isMine && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100',
|
||||
justOverwritten
|
||||
? 'text-green-400 bg-green-500/10 opacity-100'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
{justOverwritten ? <Check className="h-3 w-3" /> : <MoreHorizontal className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" align="start" className="min-w-44">
|
||||
<DropdownMenuItem onClick={onOverwrite}>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Update with current
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onToggleCommunity}>
|
||||
{preset.is_community
|
||||
? <><GlobeLock className="h-3.5 w-3.5" />Remove from community</>
|
||||
: <><Globe className="h-3.5 w-3.5" />Share with community</>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onStartRename}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDeleteRequest}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
}, [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 */}
|
||||
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
|
||||
<PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
</PresetsPopover>
|
||||
</div>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
|
||||
|
||||
@@ -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<typeof createClient>
|
||||
).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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EditorEvents>()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -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: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user