presets community

This commit is contained in:
wass08
2026-03-04 13:38:51 +01:00
parent a86609aa5c
commit ba2dfa8caa
5 changed files with 174 additions and 56 deletions
+14 -8
View File
@@ -4,6 +4,7 @@ import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server' import { supabaseAdmin } from '@/lib/supabase/server'
// PUT /api/presets/[id] // PUT /api/presets/[id]
// Accepts any subset of: name, data, is_community
export async function PUT( export async function PUT(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
@@ -15,25 +16,30 @@ export async function PUT(
const { id } = await params const { id } = await params
const body = await req.json() const body = await req.json()
const { name } = body const { name, data, is_community } = body
if (!name) { if (name === undefined && data === undefined && is_community === undefined) {
return NextResponse.json({ error: 'Missing name' }, { status: 400 }) return NextResponse.json({ error: 'Nothing to update' }, { status: 400 })
} }
const { data: existing } = await supabaseAdmin const { data: existing } = await supabaseAdmin
.from('presets') .from('presets')
.select('user_id, is_community') .select('user_id')
.eq('id', id) .eq('id', id)
.single() .single()
if (!existing || existing.user_id !== session.user.id || existing.is_community) { if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
} }
const updates: Record<string, unknown> = {}
if (name !== undefined) updates.name = name
if (data !== undefined) updates.data = data
if (is_community !== undefined) updates.is_community = is_community
const { data: preset, error } = await supabaseAdmin const { data: preset, error } = await supabaseAdmin
.from('presets') .from('presets')
.update({ name }) .update(updates)
.eq('id', id) .eq('id', id)
.select() .select()
.single() .single()
@@ -56,11 +62,11 @@ export async function DELETE(
const { data: existing } = await supabaseAdmin const { data: existing } = await supabaseAdmin
.from('presets') .from('presets')
.select('user_id, is_community, thumbnail_url') .select('user_id, thumbnail_url')
.eq('id', id) .eq('id', id)
.single() .single()
if (!existing || existing.user_id !== session.user.id || existing.is_community) { if (!existing || existing.user_id !== session.user.id) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
} }
-1
View File
@@ -25,7 +25,6 @@ export async function GET(req: NextRequest) {
.select('*') .select('*')
.eq('type', type) .eq('type', type)
.eq('user_id', session.user.id) .eq('user_id', session.user.id)
.eq('is_community', false)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 }) if (error) return NextResponse.json({ error: error.message }, { status: 500 })
@@ -117,9 +117,9 @@ export function DoorPanel() {
handleUpdate({ segments: updated }) handleUpdate({ segments: updated })
} }
const handleSavePreset = useCallback(async (name: string) => { const getDoorPresetData = useCallback(() => {
if (!node) return if (!node) return null
const data = { return {
width: node.width, width: node.width,
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
@@ -137,12 +137,27 @@ export function DoorPanel() {
panicBarHeight: node.panicBarHeight, panicBarHeight: node.panicBarHeight,
segments: node.segments, segments: node.segments,
} }
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getDoorPresetData()
if (!data) return
await fetch('/api/presets', { await fetch('/api/presets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'door', name, data }), body: JSON.stringify({ type: 'door', name, data }),
}) })
}, [node]) }, [getDoorPresetData])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getDoorPresetData()
if (!data) return
await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
}, [getDoorPresetData])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>) handleUpdate(data as Partial<DoorNode>)
@@ -162,7 +177,7 @@ 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}> <PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors"> <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>
@@ -1,8 +1,9 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { BookMarked, Pencil, Plus, Trash2, Users, Check, X } from 'lucide-react' import { BookMarked, Check, Globe, GlobeLock, Pencil, Plus, Save, Trash2, Users, X } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/primitives/tooltip'
import { useAuth } from '@/features/community/lib/auth/hooks' import { useAuth } from '@/features/community/lib/auth/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -23,19 +24,23 @@ 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 onApply: (data: Record<string, unknown>) => void
/** Save current node state as a new preset with the given name */
onSave: (name: string) => Promise<void> 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
} }
export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopoverProps) { export function PresetsPopover({ type, onApply, onSave, onOverwrite, children }: PresetsPopoverProps) {
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('community') const [tab, setTab] = useState<Tab>('community')
const [presets, setPresets] = useState<PresetData[]>([]) const [presets, setPresets] = useState<PresetData[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
// Save dialog state // 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)
@@ -47,6 +52,9 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
// Delete confirmation // 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 fetchPresets = useCallback(async () => { const fetchPresets = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
@@ -64,12 +72,11 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
if (open) fetchPresets() if (open) fetchPresets()
}, [open, fetchPresets]) }, [open, fetchPresets])
// Switch tab to community if user signs out while on mine tab
useEffect(() => { useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community') if (!isAuthenticated && tab === 'mine') setTab('community')
}, [isAuthenticated, tab]) }, [isAuthenticated, tab])
const handleSave = async () => { const handleSaveNew = async () => {
if (!saveName.trim()) return if (!saveName.trim()) return
setSaving(true) setSaving(true)
try { try {
@@ -104,6 +111,23 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
} }
} }
const handleOverwrite = async (id: string) => {
await onOverwrite(id)
setOverwrittenId(id)
setTimeout(() => setOverwrittenId(null), 1500)
}
const handleToggleCommunity = async (id: string, current: boolean) => {
const res = await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_community: !current }),
})
if (res.ok) {
setPresets((prev) => prev.map((p) => (p.id === id ? { ...p, is_community: !current } : p)))
}
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger> <PopoverTrigger asChild>{children}</PopoverTrigger>
@@ -123,19 +147,16 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
</div> </div>
{isAuthenticated && ( {isAuthenticated && (
<button <button
onClick={() => { onClick={() => { setShowSaveInput((v) => !v); setSaveName('') }}
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"
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Save preset Save new
</button> </button>
)} )}
</div> </div>
{/* Save input */} {/* 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
@@ -143,7 +164,7 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
value={saveName} value={saveName}
onChange={(e) => setSaveName(e.target.value)} onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSave() if (e.key === 'Enter') handleSaveNew()
if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') } if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') }
}} }}
placeholder="Preset name…" placeholder="Preset name…"
@@ -151,7 +172,7 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
/> />
<button <button
disabled={!saveName.trim() || saving} disabled={!saveName.trim() || saving}
onClick={handleSave} 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"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@@ -173,10 +194,7 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
</TabButton> </TabButton>
<TabButton <TabButton
active={tab === 'mine'} active={tab === 'mine'}
onClick={() => { onClick={() => { if (isAuthenticated) setTab('mine') }}
if (!isAuthenticated) return
setTab('mine')
}}
disabled={!isAuthenticated} disabled={!isAuthenticated}
> >
<BookMarked className="h-3 w-3" /> <BookMarked className="h-3 w-3" />
@@ -202,7 +220,10 @@ export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopov
renamingId={renamingId} renamingId={renamingId}
renameValue={renameValue} renameValue={renameValue}
deletingId={deletingId} deletingId={deletingId}
overwrittenId={overwrittenId}
onApply={() => { onApply(preset.data); setOpen(false) }} onApply={() => { onApply(preset.data); setOpen(false) }}
onOverwrite={() => handleOverwrite(preset.id)}
onToggleCommunity={() => handleToggleCommunity(preset.id, preset.is_community)}
onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }} onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }}
onRenameChange={setRenameValue} onRenameChange={setRenameValue}
onRenameConfirm={() => handleRename(preset.id)} onRenameConfirm={() => handleRename(preset.id)}
@@ -256,7 +277,7 @@ function EmptyState({ tab, isAuthenticated }: { tab: Tab; isAuthenticated: boole
{tab === 'community' {tab === 'community'
? 'No community presets yet.' ? 'No community presets yet.'
: isAuthenticated : isAuthenticated
? 'No presets saved yet. Use "Save preset" to save the current configuration.' ? 'No presets saved yet. Use "Save new" to save the current configuration.'
: 'Sign in to save and view your presets.'} : 'Sign in to save and view your presets.'}
</p> </p>
</div> </div>
@@ -269,7 +290,10 @@ interface PresetRowProps {
renamingId: string | null renamingId: string | null
renameValue: string renameValue: string
deletingId: string | null deletingId: string | null
overwrittenId: string | null
onApply: () => void onApply: () => void
onOverwrite: () => void
onToggleCommunity: () => void
onStartRename: () => void onStartRename: () => void
onRenameChange: (v: string) => void onRenameChange: (v: string) => void
onRenameConfirm: () => void onRenameConfirm: () => void
@@ -285,7 +309,10 @@ function PresetRow({
renamingId, renamingId,
renameValue, renameValue,
deletingId, deletingId,
overwrittenId,
onApply, onApply,
onOverwrite,
onToggleCommunity,
onStartRename, onStartRename,
onRenameChange, onRenameChange,
onRenameConfirm, onRenameConfirm,
@@ -296,6 +323,7 @@ function PresetRow({
}: PresetRowProps) { }: PresetRowProps) {
const isRenaming = renamingId === preset.id const isRenaming = renamingId === preset.id
const isDeleting = deletingId === preset.id const isDeleting = deletingId === preset.id
const justOverwritten = overwrittenId === preset.id
if (isDeleting) { if (isDeleting) {
return ( return (
@@ -350,7 +378,7 @@ function PresetRow({
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 placeholder */} {/* Thumbnail */}
<div className="h-8 w-12 shrink-0 rounded-md border border-border/40 bg-white/5 overflow-hidden"> <div className="h-8 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
@@ -362,32 +390,87 @@ function PresetRow({
)} )}
</div> </div>
<button {/* Name + date — clicking applies */}
onClick={onApply} <button onClick={onApply} className="flex-1 min-w-0 text-left">
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">
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90"> {preset.name}
{preset.name} </span>
{preset.is_community && (
<Globe className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
)}
</span> </span>
<span className="block text-[10px] text-muted-foreground/60"> <span className="block text-[10px] text-muted-foreground/60">
{new Date(preset.created_at).toLocaleDateString()} {new Date(preset.created_at).toLocaleDateString()}
</span> </span>
</button> </button>
{/* Actions — only shown on hover for "My presets" */}
{isMine && ( {isMine && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button {/* Save into (overwrite) */}
onClick={onStartRename} <Tooltip>
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors" <TooltipTrigger asChild>
> <button
<Pencil className="h-3 w-3" /> onClick={onOverwrite}
</button> className={cn(
<button 'flex h-6 w-6 items-center justify-center rounded-md transition-colors',
onClick={onDeleteRequest} justOverwritten
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors" ? 'text-green-400 bg-green-500/10'
> : 'text-muted-foreground hover:text-foreground hover:bg-white/10',
<Trash2 className="h-3 w-3" /> )}
</button> >
{justOverwritten ? <Check className="h-3 w-3" /> : <Save className="h-3 w-3" />}
</button>
</TooltipTrigger>
<TooltipContent side="top">Save current config here</TooltipContent>
</Tooltip>
{/* Community toggle */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onToggleCommunity}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md transition-colors',
preset.is_community
? 'text-blue-400 hover:text-blue-300 hover:bg-blue-500/10'
: 'text-muted-foreground hover:text-blue-400 hover:bg-blue-500/10',
)}
>
{preset.is_community ? <Globe className="h-3 w-3" /> : <GlobeLock className="h-3 w-3" />}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{preset.is_community ? 'Remove from community' : 'Share with community'}
</TooltipContent>
</Tooltip>
{/* Rename */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onStartRename}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
>
<Pencil className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Rename</TooltipContent>
</Tooltip>
{/* Delete */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDeleteRequest}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Delete</TooltipContent>
</Tooltip>
</div> </div>
)} )}
</li> </li>
@@ -92,9 +92,9 @@ export function WindowPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
const handleSavePreset = useCallback(async (name: string) => { const getWindowPresetData = useCallback(() => {
if (!node) return if (!node) return null
const data = { return {
width: node.width, width: node.width,
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
@@ -107,12 +107,27 @@ export function WindowPanel() {
sillDepth: node.sillDepth, sillDepth: node.sillDepth,
sillThickness: node.sillThickness, sillThickness: node.sillThickness,
} }
}, [node])
const handleSavePreset = useCallback(async (name: string) => {
const data = getWindowPresetData()
if (!data) return
await fetch('/api/presets', { await fetch('/api/presets', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'window', name, data }), body: JSON.stringify({ type: 'window', name, data }),
}) })
}, [node]) }, [getWindowPresetData])
const handleOverwritePreset = useCallback(async (id: string) => {
const data = getWindowPresetData()
if (!data) return
await fetch(`/api/presets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
})
}, [getWindowPresetData])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => { const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>) handleUpdate(data as Partial<WindowNode>)
@@ -163,7 +178,7 @@ 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}> <PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset} onOverwrite={handleOverwritePreset}>
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[#3e3e3e] transition-colors"> <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>