presets community / non community
This commit is contained in:
@@ -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',
|
|
||||||
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 })
|
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||||
}
|
}, [getDoorPresetData, selectedId, adapter])
|
||||||
}, [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,24 +107,16 @@ 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',
|
|
||||||
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)))
|
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, name: renameValue.trim() } : p)))
|
||||||
setRenamingId(null)
|
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) => {
|
||||||
await onOverwrite(id)
|
await onOverwrite(id)
|
||||||
@@ -128,15 +125,12 @@ 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' },
|
|
||||||
body: JSON.stringify({ is_community: !current }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
|
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}>
|
||||||
@@ -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,12 +191,15 @@ 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">
|
||||||
|
{tabs.includes('community') && (
|
||||||
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Community
|
Community
|
||||||
</TabButton>
|
</TabButton>
|
||||||
|
)}
|
||||||
|
{tabs.includes('mine') && (
|
||||||
<TabButton
|
<TabButton
|
||||||
active={tab === 'mine'}
|
active={tab === 'mine'}
|
||||||
onClick={() => { if (isAuthenticated) setTab('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" />
|
<BookMarked className="h-3 w-3" />
|
||||||
My presets
|
My presets
|
||||||
</TabButton>
|
</TabButton>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
<DropdownMenuItem onClick={onToggleCommunity}>
|
<DropdownMenuItem onClick={onToggleCommunity}>
|
||||||
{preset.is_community
|
{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</>}
|
||||||
? <><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>
|
</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',
|
|
||||||
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 })
|
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||||
}
|
}, [getWindowPresetData, selectedId, adapter])
|
||||||
}, [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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user