presets community / non community

This commit is contained in:
wass08
2026-03-11 13:02:17 +01:00
parent 7359c1fcbf
commit a97ed42578
8 changed files with 318 additions and 206 deletions
+57 -1
View File
@@ -3,7 +3,7 @@
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Editor, SceneLoader } from '@pascal-app/editor' import { Editor, SceneLoader } from '@pascal-app/editor'
import type { SceneGraph } from '@pascal-app/editor' import type { SceneGraph, PresetsAdapter } from '@pascal-app/editor'
import { CommunityAppMenu } from '@/features/community/components/community-app-menu' import { CommunityAppMenu } from '@/features/community/components/community-app-menu'
import { ProjectHeader } from '@/features/community/components/project-header' import { ProjectHeader } from '@/features/community/components/project-header'
import { useAuth } from '@/features/community/lib/auth/hooks' import { useAuth } from '@/features/community/lib/auth/hooks'
@@ -49,6 +49,61 @@ export default function EditorPage() {
await saveProjectModel(projectId, scene) await saveProjectModel(projectId, scene)
}, [projectId]) }, [projectId])
const apiPresetsAdapter: PresetsAdapter = {
tabs: ['community', 'mine'],
isAuthenticated,
fetchPresets: async (type, tab) => {
const res = await fetch(`/api/presets?type=${type}&tab=${tab}`)
if (!res.ok) return []
const json = await res.json()
return json.presets ?? []
},
savePreset: async (type, name, data) => {
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, name, data }),
})
if (!res.ok) return null
const json = await res.json()
return json.preset?.id ?? null
},
overwritePreset: async (_type, id, data) => {
await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
},
renamePreset: async (id, name) => {
await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
},
deletePreset: async (id) => {
await fetch(`/api/presets/${id}`, { method: 'DELETE' })
},
togglePresetCommunity: async (id, current) => {
await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_community: !current }),
})
},
uploadPresetThumbnail: async (presetId, blob) => {
const res = await fetch(`/api/presets/${presetId}/thumbnail`, {
method: 'POST',
body: blob,
headers: { 'Content-Type': 'image/png' },
})
if (!res.ok) return null
const json = await res.json()
return json.thumbnail_url ?? null
},
}
const onThumbnailCapture = useCallback(async (blob: Blob) => { const onThumbnailCapture = useCallback(async (blob: Blob) => {
const result = await uploadProjectThumbnail(projectId, blob) const result = await uploadProjectThumbnail(projectId, blob)
if (result.success) { if (result.success) {
@@ -76,6 +131,7 @@ export default function EditorPage() {
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
isLoading={isProjectLoading} isLoading={isProjectLoading}
onThumbnailCapture={onThumbnailCapture} onThumbnailCapture={onThumbnailCapture}
presetsAdapter={apiPresetsAdapter}
settingsPanelProps={{ settingsPanelProps={{
projectId, projectId,
projectVisibility: activeProject ? { projectVisibility: activeProject ? {
@@ -23,6 +23,7 @@ import { SceneLoader } from '../ui/scene-loader'
import { AppSidebar } from '../ui/sidebar/app-sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar'
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
import type { SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SitePanelProps } from '../ui/sidebar/panels/site-panel'
import { PresetsProvider, type PresetsAdapter } from '../../contexts/presets-context'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu' import { FloatingActionMenu } from './floating-action-menu'
@@ -87,6 +88,9 @@ export interface EditorProps {
// Panel config (passed through to sidebar panels) // Panel config (passed through to sidebar panels)
settingsPanelProps?: SettingsPanelProps settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps sitePanelProps?: SitePanelProps
// Presets storage backend (defaults to localStorage)
presetsAdapter?: PresetsAdapter
} }
function EditorSceneCrashFallback() { function EditorSceneCrashFallback() {
@@ -130,6 +134,7 @@ export default function Editor({
onThumbnailCapture, onThumbnailCapture,
settingsPanelProps, settingsPanelProps,
sitePanelProps, sitePanelProps,
presetsAdapter,
}: EditorProps) { }: EditorProps) {
useKeyboard() useKeyboard()
@@ -190,6 +195,7 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading const showLoader = isLoading || isSceneLoading
return ( return (
<PresetsProvider adapter={presetsAdapter}>
<div className="w-full h-full dark text-foreground"> <div className="w-full h-full dark text-foreground">
{showLoader && <SceneLoader />} {showLoader && <SceneLoader />}
@@ -229,5 +235,6 @@ export default function Editor({
{!isPreviewMode && <ZoneLabelEditorSystem />} {!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</PresetsProvider>
) )
} }
@@ -4,6 +4,7 @@ import { emitter, sceneRegistry } from '@pascal-app/core'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { usePresetsAdapter } from '../../contexts/presets-context'
const THUMBNAIL_SIZE = 1080 const THUMBNAIL_SIZE = 1080
const CAMERA_FOV = 45 const CAMERA_FOV = 45
@@ -11,6 +12,7 @@ const CAMERA_FOV = 45
export const PresetThumbnailGenerator = () => { export const PresetThumbnailGenerator = () => {
const gl = useThree((state) => state.gl) const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene) const scene = useThree((state) => state.scene)
const adapter = usePresetsAdapter()
const generate = useCallback( const generate = useCallback(
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => { async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
@@ -98,20 +100,14 @@ export const PresetThumbnailGenerator = () => {
console.error('❌ PresetThumbnail: failed to create blob') console.error('❌ PresetThumbnail: failed to create blob')
return return
} }
const res = await fetch(`/api/presets/${presetId}/thumbnail`, { if (!adapter.uploadPresetThumbnail) return
method: 'POST', const thumbnailUrl = await adapter.uploadPresetThumbnail(presetId, blob)
body: blob, if (thumbnailUrl) {
headers: { 'Content-Type': 'image/png' }, emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl })
})
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') }, 'image/png')
}, },
[gl, scene], [gl, scene, adapter],
) )
useEffect(() => { useEffect(() => {
@@ -15,6 +15,7 @@ import { ToggleControl } from '../controls/toggle-control'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover' import { PresetsPopover } from './presets/presets-popover'
import { usePresetsAdapter } from '../../../contexts/presets-context'
export function DoorPanel() { export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -24,6 +25,8 @@ export function DoorPanel() {
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
@@ -142,28 +145,16 @@ export function DoorPanel() {
const handleSavePreset = useCallback(async (name: string) => { const handleSavePreset = useCallback(async (name: string) => {
const data = getDoorPresetData() const data = getDoorPresetData()
if (!data || !selectedId) return if (!data || !selectedId) return
const res = await fetch('/api/presets', { const presetId = await adapter.savePreset('door', name, data)
method: 'POST', if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
headers: { 'Content-Type': 'application/json' }, }, [getDoorPresetData, selectedId, adapter])
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 handleOverwritePreset = useCallback(async (id: string) => {
const data = getDoorPresetData() const data = getDoorPresetData()
if (!data || !selectedId) return if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, { await adapter.overwritePreset('door', id, data)
method: 'PUT', emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
headers: { 'Content-Type': 'application/json' }, }, [getDoorPresetData, selectedId, adapter])
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>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>) handleUpdate(data as Partial<DoorNode>)
@@ -183,7 +174,18 @@ export function DoorPanel() {
> >
{/* Presets strip */} {/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30"> <div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}> <PresetsPopover
type="door"
onApply={handleApplyPreset}
onSave={handleSavePreset}
onOverwrite={handleOverwritePreset}
onFetchPresets={(tab) => adapter.fetchPresets('door', tab)}
onRename={(id, name) => adapter.renamePreset(id, name)}
onDelete={(id) => adapter.deletePreset(id)}
onToggleCommunity={adapter.togglePresetCommunity}
isAuthenticated={adapter.isAuthenticated}
tabs={adapter.tabs}
>
<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"> <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" /> <BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span> <span>Presets</span>
@@ -6,6 +6,7 @@ import { emitter } from '@pascal-app/core'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../primitives/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
import { cn } from '../../../../lib/utils' import { cn } from '../../../../lib/utils'
import type { PresetsTab } from '../../../../contexts/presets-context'
export type PresetType = 'door' | 'window' export type PresetType = 'door' | 'window'
@@ -20,61 +21,65 @@ export interface PresetData {
created_at: string created_at: string
} }
type Tab = 'community' | 'mine'
interface PresetsPopoverProps { interface PresetsPopoverProps {
type: PresetType 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 children: React.ReactNode
isAuthenticated?: boolean isAuthenticated?: boolean
tabs?: PresetsTab[]
onFetchPresets: (tab: PresetsTab) => Promise<PresetData[]>
onApply: (data: Record<string, unknown>) => void
onSave: (name: string) => Promise<void>
onOverwrite: (id: string) => Promise<void>
onRename: (id: string, name: string) => Promise<void>
onDelete: (id: string) => Promise<void>
onToggleCommunity?: (id: string, current: boolean) => Promise<void>
} }
export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, isAuthenticated = false }: PresetsPopoverProps) { export function PresetsPopover({
type,
onApply,
onSave,
onOverwrite,
onFetchPresets,
onRename,
onDelete,
onToggleCommunity,
children,
isAuthenticated = false,
tabs = ['community', 'mine'],
}: PresetsPopoverProps) {
const defaultTab = tabs[0] ?? 'mine'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('community') const [tab, setTab] = useState<PresetsTab>(defaultTab)
const [presets, setPresets] = useState<PresetData[]>([]) const [presets, setPresets] = useState<PresetData[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// New preset save state
const [showSaveInput, setShowSaveInput] = useState(false) const [showSaveInput, setShowSaveInput] = useState(false)
const [saveName, setSaveName] = useState('') const [saveName, setSaveName] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Rename state
const [renamingId, setRenamingId] = useState<string | null>(null) const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
// Delete confirmation
const [deletingId, setDeletingId] = useState<string | null>(null) const [deletingId, setDeletingId] = useState<string | null>(null)
// Overwrite feedback (shows check icon briefly after overwrite)
const [overwrittenId, setOverwrittenId] = useState<string | null>(null) const [overwrittenId, setOverwrittenId] = useState<string | null>(null)
const fetchPresets = useCallback(async () => { const fetchPresets = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const res = await fetch(`/api/presets?type=${type}&tab=${tab}`) const data = await onFetchPresets(tab)
if (res.ok) { setPresets(data)
const json = await res.json()
setPresets(json.presets ?? [])
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [type, tab]) }, [onFetchPresets, tab])
useEffect(() => { useEffect(() => {
if (open) fetchPresets() if (open) fetchPresets()
}, [open, fetchPresets]) }, [open, fetchPresets])
useEffect(() => { useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community') if (!isAuthenticated && tab === 'mine') setTab(defaultTab)
}, [isAuthenticated, tab]) }, [isAuthenticated, tab, defaultTab])
useEffect(() => { useEffect(() => {
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => { const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
@@ -102,23 +107,15 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
const handleRename = async (id: string) => { const handleRename = async (id: string) => {
if (!renameValue.trim()) return if (!renameValue.trim()) return
const res = await fetch(`/api/presets/${id}`, { await onRename(id, renameValue.trim())
method: 'PUT', setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
headers: { 'Content-Type': 'application/json' }, setRenamingId(null)
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 handleDelete = async (id: string) => {
const res = await fetch(`/api/presets/${id}`, { method: 'DELETE' }) await onDelete(id)
if (res.ok) { setPresets((prev) => prev.filter((p) => p.id !== id))
setPresets((prev) => prev.filter((p) => p.id !== id)) setDeletingId(null)
setDeletingId(null)
}
} }
const handleOverwrite = async (id: string) => { const handleOverwrite = async (id: string) => {
@@ -128,16 +125,13 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
} }
const handleToggleCommunity = async (id: string, current: boolean) => { const handleToggleCommunity = async (id: string, current: boolean) => {
const res = await fetch(`/api/presets/${id}`, { if (!onToggleCommunity) return
method: 'PUT', await onToggleCommunity(id, current)
headers: { 'Content-Type': 'application/json' }, setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
body: JSON.stringify({ is_community: !current }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
}
} }
const showTabs = tabs.length > 1
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger> <PopoverTrigger asChild>{children}</PopoverTrigger>
@@ -147,7 +141,6 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
sideOffset={8} sideOffset={8}
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden" 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 justify-between px-3 py-2.5 border-b border-border/50">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<BookMarked className="h-3.5 w-3.5 text-muted-foreground" /> <BookMarked className="h-3.5 w-3.5 text-muted-foreground" />
@@ -157,6 +150,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
</div> </div>
{isAuthenticated && ( {isAuthenticated && (
<button <button
type="button"
onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }} 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" 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"
> >
@@ -166,7 +160,6 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
)} )}
</div> </div>
{/* New preset name input */}
{showSaveInput && ( {showSaveInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5"> <div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input <input
@@ -181,6 +174,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
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" 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 <button
type="button"
disabled={!saveName.trim() || saving} disabled={!saveName.trim() || saving}
onClick={handleSaveNew} 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" 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"
@@ -188,6 +182,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
<button <button
type="button"
onClick={() => { setShowSaveInput(false); setSaveName('') }} 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" className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
> >
@@ -196,23 +191,27 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
</div> </div>
)} )}
{/* Tabs */} {showTabs && (
<div className="flex border-b border-border/50"> <div className="flex border-b border-border/50">
<TabButton active={tab === 'community'} onClick={() => setTab('community')}> {tabs.includes('community') && (
<Users className="h-3 w-3" /> <TabButton active={tab === 'community'} onClick={() => setTab('community')}>
Community <Users className="h-3 w-3" />
</TabButton> Community
<TabButton </TabButton>
active={tab === 'mine'} )}
onClick={() => { if (isAuthenticated) setTab('mine') }} {tabs.includes('mine') && (
disabled={!isAuthenticated} <TabButton
> active={tab === 'mine'}
<BookMarked className="h-3 w-3" /> onClick={() => { if (isAuthenticated) setTab('mine') }}
My presets disabled={!isAuthenticated}
</TabButton> >
</div> <BookMarked className="h-3 w-3" />
My presets
</TabButton>
)}
</div>
)}
{/* Content */}
<div className="max-h-72 overflow-y-auto no-scrollbar"> <div className="max-h-72 overflow-y-auto no-scrollbar">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
@@ -227,6 +226,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
key={preset.id} key={preset.id}
preset={preset} preset={preset}
isMine={tab === 'mine'} isMine={tab === 'mine'}
showCommunityToggle={!!onToggleCommunity}
renamingId={renamingId} renamingId={renamingId}
renameValue={renameValue} renameValue={renameValue}
deletingId={deletingId} deletingId={deletingId}
@@ -252,25 +252,18 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
} }
function TabButton({ function TabButton({
active, active, onClick, disabled, children,
onClick,
disabled,
children,
}: { }: {
active: boolean active: boolean; onClick: () => void; disabled?: boolean; children: React.ReactNode
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) { }) {
return ( return (
<button <button
type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors', 'flex flex-1 items-center justify-center gap-1.5 py-2 text-[11px] font-medium transition-colors',
active active ? 'text-foreground border-b-2 border-primary -mb-px' : 'text-muted-foreground hover:text-foreground',
? 'text-foreground border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground',
disabled && 'opacity-40 cursor-not-allowed', disabled && 'opacity-40 cursor-not-allowed',
)} )}
> >
@@ -279,7 +272,7 @@ function TabButton({
) )
} }
function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boolean }) { function EmptyState({ tab, isAuthenticated }: { tab: PresetsTab; isAuthenticated: boolean }) {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4"> <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" /> <BookMarked className="h-6 w-6 text-muted-foreground/40" />
@@ -297,6 +290,7 @@ function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boole
interface PresetRowProps { interface PresetRowProps {
preset: PresetData preset: PresetData
isMine: boolean isMine: boolean
showCommunityToggle: boolean
renamingId: string | null renamingId: string | null
renameValue: string renameValue: string
deletingId: string | null deletingId: string | null
@@ -314,22 +308,9 @@ interface PresetRowProps {
} }
function PresetRow({ function PresetRow({
preset, preset, isMine, showCommunityToggle, renamingId, renameValue, deletingId, overwrittenId,
isMine, onApply, onOverwrite, onToggleCommunity, onStartRename, onRenameChange, onRenameConfirm,
renamingId, onRenameCancel, onDeleteRequest, onDeleteConfirm, onDeleteCancel,
renameValue,
deletingId,
overwrittenId,
onApply,
onOverwrite,
onToggleCommunity,
onStartRename,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
}: PresetRowProps) { }: PresetRowProps) {
const isRenaming = renamingId === preset.id const isRenaming = renamingId === preset.id
const isDeleting = deletingId === preset.id const isDeleting = deletingId === preset.id
@@ -340,18 +321,8 @@ function PresetRow({
<li className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10"> <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> <span className="text-xs text-foreground/80 truncate">Delete "{preset.name}"?</span>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <button type="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>
onClick={onDeleteConfirm} <button type="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>
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> </div>
</li> </li>
) )
@@ -364,31 +335,17 @@ function PresetRow({
autoFocus autoFocus
value={renameValue} value={renameValue}
onChange={(e) => onRenameChange(e.target.value)} onChange={(e) => onRenameChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => { if (e.key === 'Enter') onRenameConfirm(); if (e.key === 'Escape') onRenameCancel() }}
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" 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 <button type="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>
onClick={onRenameConfirm} <button type="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>
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> </li>
) )
} }
return ( return (
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"> <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"> <div className="h-12 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden">
{preset.thumbnail_url ? ( {preset.thumbnail_url ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -399,56 +356,35 @@ function PresetRow({
</div> </div>
)} )}
</div> </div>
<button type="button" onClick={onApply} className="flex-1 min-w-0 text-left">
{/* 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="flex items-center gap-1.5">
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90"> <span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">{preset.name}</span>
{preset.name} {isMine && preset.is_community && <Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
</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> </span>
<span className="block text-[10px] text-muted-foreground/60">{new Date(preset.created_at).toLocaleDateString()}</span>
</button> </button>
{/* Actions — 3-dot dropdown, only in "My presets" */}
{isMine && ( {isMine && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button"
className={cn( className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100', 'flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors opacity-0 group-hover:opacity-100',
justOverwritten justOverwritten ? 'text-green-400 bg-green-500/10 opacity-100' : 'text-muted-foreground hover:text-foreground hover:bg-white/10',
? '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" />} {justOverwritten ? <Check className="h-3 w-3" /> : <MoreHorizontal className="h-3.5 w-3.5" />}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="left" align="start" className="min-w-44"> <DropdownMenuContent side="left" align="start" className="min-w-44">
<DropdownMenuItem onClick={onOverwrite}> <DropdownMenuItem onClick={onOverwrite}><Save className="h-3.5 w-3.5" />Update with current</DropdownMenuItem>
<Save className="h-3.5 w-3.5" /> {showCommunityToggle && (
Update with current <DropdownMenuItem onClick={onToggleCommunity}>
</DropdownMenuItem> {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 onClick={onToggleCommunity}> </DropdownMenuItem>
{preset.is_community )}
? <><GlobeLock className="h-3.5 w-3.5" />Remove from community</> <DropdownMenuItem onClick={onStartRename}><Pencil className="h-3.5 w-3.5" />Rename</DropdownMenuItem>
: <><Globe className="h-3.5 w-3.5" />Share with community</>} <DropdownMenuItem variant="destructive" onClick={onDeleteRequest}><Trash2 className="h-3.5 w-3.5" />Delete</DropdownMenuItem>
</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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -14,6 +14,7 @@ import { MetricControl } from '../controls/metric-control'
import { ToggleControl } from '../controls/toggle-control' import { ToggleControl } from '../controls/toggle-control'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { PresetsPopover } from './presets/presets-popover' import { PresetsPopover } from './presets/presets-popover'
import { usePresetsAdapter } from '../../../contexts/presets-context'
export function WindowPanel() { export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -23,6 +24,8 @@ export function WindowPanel() {
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
const node = selectedId const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined) ? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
@@ -112,28 +115,16 @@ export function WindowPanel() {
const handleSavePreset = useCallback(async (name: string) => { const handleSavePreset = useCallback(async (name: string) => {
const data = getWindowPresetData() const data = getWindowPresetData()
if (!data || !selectedId) return if (!data || !selectedId) return
const res = await fetch('/api/presets', { const presetId = await adapter.savePreset('window', name, data)
method: 'POST', if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
headers: { 'Content-Type': 'application/json' }, }, [getWindowPresetData, selectedId, adapter])
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 handleOverwritePreset = useCallback(async (id: string) => {
const data = getWindowPresetData() const data = getWindowPresetData()
if (!data || !selectedId) return if (!data || !selectedId) return
const res = await fetch(`/api/presets/${id}`, { await adapter.overwritePreset('window', id, data)
method: 'PUT', emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
headers: { 'Content-Type': 'application/json' }, }, [getWindowPresetData, selectedId, adapter])
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>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>) handleUpdate(data as Partial<WindowNode>)
@@ -184,7 +175,18 @@ export function WindowPanel() {
> >
{/* Presets strip */} {/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30"> <div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}> <PresetsPopover
type="window"
onApply={handleApplyPreset}
onSave={handleSavePreset}
onOverwrite={handleOverwritePreset}
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
onRename={(id, name) => adapter.renamePreset(id, name)}
onDelete={(id) => adapter.deletePreset(id)}
onToggleCommunity={adapter.togglePresetCommunity}
isAuthenticated={adapter.isAuthenticated}
tabs={adapter.tabs}
>
<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"> <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" /> <BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span> <span>Presets</span>
@@ -0,0 +1,111 @@
'use client'
import { createContext, useContext } from 'react'
import type { PresetData, PresetType } from '../components/ui/panels/presets/presets-popover'
export type { PresetData, PresetType }
export type PresetsTab = 'community' | 'mine'
export interface PresetsAdapter {
/** Tabs to show. Default: both. Standalone passes ['mine']. */
tabs?: PresetsTab[]
isAuthenticated?: boolean
fetchPresets: (type: PresetType, tab: PresetsTab) => Promise<PresetData[]>
savePreset: (type: PresetType, name: string, data: Record<string, unknown>) => Promise<string | null>
overwritePreset: (type: PresetType, id: string, data: Record<string, unknown>) => Promise<void>
renamePreset: (id: string, name: string) => Promise<void>
deletePreset: (id: string) => Promise<void>
togglePresetCommunity?: (id: string, current: boolean) => Promise<void>
uploadPresetThumbnail?: (presetId: string, blob: Blob) => Promise<string | null>
}
const PRESETS_KEY = (type: string) => `pascal-presets-${type}`
export const localStoragePresetsAdapter: PresetsAdapter = {
tabs: ['mine'],
isAuthenticated: true,
fetchPresets: async (type, tab) => {
if (tab === 'community') return []
try {
const raw = localStorage.getItem(PRESETS_KEY(type))
return raw ? (JSON.parse(raw) as PresetData[]) : []
} catch {
return []
}
},
savePreset: async (type, name, data) => {
try {
const id = Math.random().toString(36).slice(2, 10)
const raw = localStorage.getItem(PRESETS_KEY(type))
const presets: PresetData[] = raw ? JSON.parse(raw) : []
presets.push({
id,
type,
name,
data,
thumbnail_url: null,
user_id: null,
is_community: false,
created_at: new Date().toISOString(),
})
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets))
return id
} catch {
return null
}
},
overwritePreset: async (type, id, data) => {
try {
const raw = localStorage.getItem(PRESETS_KEY(type))
if (!raw) return
const presets: PresetData[] = JSON.parse(raw)
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.map(p => p.id === id ? { ...p, data } : p)))
} catch {}
},
renamePreset: async (id, name) => {
for (const type of ['door', 'window']) {
try {
const raw = localStorage.getItem(PRESETS_KEY(type))
if (!raw) continue
const presets: PresetData[] = JSON.parse(raw)
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.map(p => p.id === id ? { ...p, name } : p)))
} catch {}
}
},
deletePreset: async (id) => {
for (const type of ['door', 'window']) {
try {
const raw = localStorage.getItem(PRESETS_KEY(type))
if (!raw) continue
const presets: PresetData[] = JSON.parse(raw)
localStorage.setItem(PRESETS_KEY(type), JSON.stringify(presets.filter(p => p.id !== id)))
} catch {}
}
},
}
const PresetsContext = createContext<PresetsAdapter>(localStoragePresetsAdapter)
export function PresetsProvider({
adapter,
children,
}: {
adapter?: PresetsAdapter
children: React.ReactNode
}) {
return (
<PresetsContext.Provider value={adapter ?? localStoragePresetsAdapter}>
{children}
</PresetsContext.Provider>
)
}
export function usePresetsAdapter(): PresetsAdapter {
return useContext(PresetsContext)
}
+2
View File
@@ -9,3 +9,5 @@ export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export { useCommandPalette } from './components/ui/command-palette' export { useCommandPalette } from './components/ui/command-palette'
export { useUploadStore } from './store/use-upload' export { useUploadStore } from './store/use-upload'
export { default as useEditor } from './store/use-editor' export { default as useEditor } from './store/use-editor'
export { PresetsProvider } from './contexts/presets-context'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'