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 { useCallback, useEffect, useState } from 'react'
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 { ProjectHeader } from '@/features/community/components/project-header'
import { useAuth } from '@/features/community/lib/auth/hooks'
@@ -49,6 +49,61 @@ export default function EditorPage() {
await saveProjectModel(projectId, scene)
}, [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 result = await uploadProjectThumbnail(projectId, blob)
if (result.success) {
@@ -76,6 +131,7 @@ export default function EditorPage() {
isVersionPreviewMode={isVersionPreviewMode}
isLoading={isProjectLoading}
onThumbnailCapture={onThumbnailCapture}
presetsAdapter={apiPresetsAdapter}
settingsPanelProps={{
projectId,
projectVisibility: activeProject ? {
@@ -23,6 +23,7 @@ import { SceneLoader } from '../ui/scene-loader'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import type { SettingsPanelProps } from '../ui/sidebar/panels/settings-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 { ExportManager } from './export-manager'
import { FloatingActionMenu } from './floating-action-menu'
@@ -87,6 +88,9 @@ export interface EditorProps {
// Panel config (passed through to sidebar panels)
settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps
// Presets storage backend (defaults to localStorage)
presetsAdapter?: PresetsAdapter
}
function EditorSceneCrashFallback() {
@@ -130,6 +134,7 @@ export default function Editor({
onThumbnailCapture,
settingsPanelProps,
sitePanelProps,
presetsAdapter,
}: EditorProps) {
useKeyboard()
@@ -190,6 +195,7 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading
return (
<PresetsProvider adapter={presetsAdapter}>
<div className="w-full h-full dark text-foreground">
{showLoader && <SceneLoader />}
@@ -229,5 +235,6 @@ export default function Editor({
{!isPreviewMode && <ZoneLabelEditorSystem />}
</ErrorBoundary>
</div>
</PresetsProvider>
)
}
@@ -4,6 +4,7 @@ import { emitter, sceneRegistry } from '@pascal-app/core'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect } from 'react'
import * as THREE from 'three'
import { usePresetsAdapter } from '../../contexts/presets-context'
const THUMBNAIL_SIZE = 1080
const CAMERA_FOV = 45
@@ -11,6 +12,7 @@ const CAMERA_FOV = 45
export const PresetThumbnailGenerator = () => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const adapter = usePresetsAdapter()
const generate = useCallback(
async ({ presetId, nodeId }: { presetId: string; nodeId: string }) => {
@@ -98,20 +100,14 @@ export const PresetThumbnailGenerator = () => {
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())
if (!adapter.uploadPresetThumbnail) return
const thumbnailUrl = await adapter.uploadPresetThumbnail(presetId, blob)
if (thumbnailUrl) {
emitter.emit('preset:thumbnail-updated', { presetId, thumbnailUrl })
}
}, 'image/png')
},
[gl, scene],
[gl, scene, adapter],
)
useEffect(() => {
@@ -15,6 +15,7 @@ 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'
import { usePresetsAdapter } from '../../../contexts/presets-context'
export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -24,6 +25,8 @@ export function DoorPanel() {
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined)
@@ -142,28 +145,16 @@ export function DoorPanel() {
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
const presetId = await adapter.savePreset('door', name, data)
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getDoorPresetData, selectedId])
}, [getDoorPresetData, selectedId, adapter])
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])
await adapter.overwritePreset('door', id, data)
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getDoorPresetData, selectedId, adapter])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>)
@@ -183,7 +174,18 @@ export function DoorPanel() {
>
{/* 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}>
<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">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<span>Presets</span>
@@ -6,6 +6,7 @@ import { emitter } from '@pascal-app/core'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../primitives/dropdown-menu'
import { Popover, PopoverContent, PopoverTrigger } from '../../primitives/popover'
import { cn } from '../../../../lib/utils'
import type { PresetsTab } from '../../../../contexts/presets-context'
export type PresetType = 'door' | 'window'
@@ -20,61 +21,65 @@ export interface PresetData {
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
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 [tab, setTab] = useState<Tab>('community')
const [tab, setTab] = useState<PresetsTab>(defaultTab)
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 ?? [])
}
const data = await onFetchPresets(tab)
setPresets(data)
} finally {
setLoading(false)
}
}, [type, tab])
}, [onFetchPresets, tab])
useEffect(() => {
if (open) fetchPresets()
}, [open, fetchPresets])
useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community')
}, [isAuthenticated, tab])
if (!isAuthenticated && tab === 'mine') setTab(defaultTab)
}, [isAuthenticated, tab, defaultTab])
useEffect(() => {
const handler = ({ presetId, thumbnailUrl }: { presetId: string; thumbnailUrl: string }) => {
@@ -102,24 +107,16 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
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) {
await onRename(id, renameValue.trim())
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) {
await onDelete(id)
setPresets((prev) => prev.filter((p) => p.id !== id))
setDeletingId(null)
}
}
const handleOverwrite = async (id: string) => {
await onOverwrite(id)
@@ -128,15 +125,12 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
}
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) {
if (!onToggleCommunity) return
await onToggleCommunity(id, current)
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
}
}
const showTabs = tabs.length > 1
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -147,7 +141,6 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
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" />
@@ -157,6 +150,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
</div>
{isAuthenticated && (
<button
type="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"
>
@@ -166,7 +160,6 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
)}
</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
@@ -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"
/>
<button
type="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"
@@ -188,6 +182,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
<Check className="h-3.5 w-3.5" />
</button>
<button
type="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"
>
@@ -196,12 +191,15 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
</div>
)}
{/* Tabs */}
{showTabs && (
<div className="flex border-b border-border/50">
{tabs.includes('community') && (
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
<Users className="h-3 w-3" />
Community
</TabButton>
)}
{tabs.includes('mine') && (
<TabButton
active={tab === 'mine'}
onClick={() => { if (isAuthenticated) setTab('mine') }}
@@ -210,9 +208,10 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
<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">
@@ -227,6 +226,7 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
key={preset.id}
preset={preset}
isMine={tab === 'mine'}
showCommunityToggle={!!onToggleCommunity}
renamingId={renamingId}
renameValue={renameValue}
deletingId={deletingId}
@@ -252,25 +252,18 @@ export function PresetsPopover({ type, onApply, onSave, onOverwrite, children, i
}
function TabButton({
active,
onClick,
disabled,
children,
active, onClick, disabled, children,
}: {
active: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
active: boolean; onClick: () => void; disabled?: boolean; children: React.ReactNode
}) {
return (
<button
type="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',
active ? 'text-foreground border-b-2 border-primary -mb-px' : 'text-muted-foreground hover:text-foreground',
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 (
<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" />
@@ -297,6 +290,7 @@ function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boole
interface PresetRowProps {
preset: PresetData
isMine: boolean
showCommunityToggle: boolean
renamingId: string | null
renameValue: string
deletingId: string | null
@@ -314,22 +308,9 @@ interface PresetRowProps {
}
function PresetRow({
preset,
isMine,
renamingId,
renameValue,
deletingId,
overwrittenId,
onApply,
onOverwrite,
onToggleCommunity,
onStartRename,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
preset, isMine, showCommunityToggle, 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
@@ -340,18 +321,8 @@ function PresetRow({
<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>
<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>
<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>
</div>
</li>
)
@@ -364,31 +335,17 @@ function PresetRow({
autoFocus
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRenameConfirm()
if (e.key === 'Escape') onRenameCancel()
}}
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>
<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>
<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>
</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
@@ -399,56 +356,35 @@ function PresetRow({
</div>
)}
</div>
{/* Name + date — clicking applies */}
<button onClick={onApply} className="flex-1 min-w-0 text-left">
<button type="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 className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">{preset.name}</span>
{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
type="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 ? '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={onOverwrite}><Save className="h-3.5 w-3.5" />Update with current</DropdownMenuItem>
{showCommunityToggle && (
<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
{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>
)}
@@ -14,6 +14,7 @@ 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'
import { usePresetsAdapter } from '../../../contexts/presets-context'
export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -23,6 +24,8 @@ export function WindowPanel() {
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
@@ -112,28 +115,16 @@ export function WindowPanel() {
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
const presetId = await adapter.savePreset('window', name, data)
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
}
}, [getWindowPresetData, selectedId])
}, [getWindowPresetData, selectedId, adapter])
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])
await adapter.overwritePreset('window', id, data)
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
}, [getWindowPresetData, selectedId, adapter])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>)
@@ -184,7 +175,18 @@ export function WindowPanel() {
>
{/* 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}>
<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">
<BookMarked className="h-3.5 w-3.5 shrink-0" />
<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 { useUploadStore } from './store/use-upload'
export { default as useEditor } from './store/use-editor'
export { PresetsProvider } from './contexts/presets-context'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'