diff --git a/apps/community/app/editor/[projectId]/page.tsx b/apps/community/app/editor/[projectId]/page.tsx index 89c32a01..6fb47e15 100644 --- a/apps/community/app/editor/[projectId]/page.tsx +++ b/apps/community/app/editor/[projectId]/page.tsx @@ -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 ? { diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 96f35124..5b949e6d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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 ( +
{showLoader && } @@ -229,5 +235,6 @@ export default function Editor({ {!isPreviewMode && }
+
) } diff --git a/packages/editor/src/components/editor/preset-thumbnail-generator.tsx b/packages/editor/src/components/editor/preset-thumbnail-generator.tsx index 49ee33c5..2a7686cf 100644 --- a/packages/editor/src/components/editor/preset-thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/preset-thumbnail-generator.tsx @@ -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(() => { diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 5cac3802..8e8a8bf7 100644 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -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 - if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId }) - } - }, [getDoorPresetData, selectedId]) + const presetId = await adapter.savePreset('door', name, data) + if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: 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) => { handleUpdate(data as Partial) @@ -183,7 +174,18 @@ export function DoorPanel() { > {/* Presets strip */}
- + 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} + >
- {/* New preset name input */} {showSaveInput && (
)} - {/* Tabs */} -
- setTab('community')}> - - Community - - { if (isAuthenticated) setTab('mine') }} - disabled={!isAuthenticated} - > - - My presets - -
+ {showTabs && ( +
+ {tabs.includes('community') && ( + setTab('community')}> + + Community + + )} + {tabs.includes('mine') && ( + { if (isAuthenticated) setTab('mine') }} + disabled={!isAuthenticated} + > + + My presets + + )} +
+ )} - {/* Content */}
{loading ? (
@@ -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 ( - + +
) @@ -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" /> - - + + ) } return (
  • - {/* Thumbnail */}
    {preset.thumbnail_url ? ( // eslint-disable-next-line @next/next/no-img-element @@ -399,56 +356,35 @@ function PresetRow({
    )}
  • - - {/* Name + date — clicking applies */} - - - {/* Actions — 3-dot dropdown, only in "My presets" */} {isMine && ( - - - Update with current - - - {preset.is_community - ? <>Remove from community - : <>Share with community} - - - - Rename - - - - Delete - + Update with current + {showCommunityToggle && ( + + {preset.is_community ? <>Remove from community : <>Share with community} + + )} + Rename + Delete )} diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index d53b699e..57270752 100644 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -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 - if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId }) - } - }, [getWindowPresetData, selectedId]) + const presetId = await adapter.savePreset('window', name, data) + if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: 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) => { handleUpdate(data as Partial) @@ -184,7 +175,18 @@ export function WindowPanel() { > {/* Presets strip */}
    - + 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} + >