community presets draft

This commit is contained in:
wass08
2026-03-04 13:18:18 +01:00
parent c26c7da091
commit a86609aa5c
13 changed files with 2067 additions and 2 deletions
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
// PUT /api/presets/[id]
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const { name } = body
if (!name) {
return NextResponse.json({ error: 'Missing name' }, { status: 400 })
}
const { data: existing } = await supabaseAdmin
.from('presets')
.select('user_id, is_community')
.eq('id', id)
.single()
if (!existing || existing.user_id !== session.user.id || existing.is_community) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
const { data: preset, error } = await supabaseAdmin
.from('presets')
.update({ name })
.eq('id', id)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset })
}
// DELETE /api/presets/[id]
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const { data: existing } = await supabaseAdmin
.from('presets')
.select('user_id, is_community, thumbnail_url')
.eq('id', id)
.single()
if (!existing || existing.user_id !== session.user.id || existing.is_community) {
return NextResponse.json({ error: 'Not found or forbidden' }, { status: 403 })
}
// Delete thumbnail from storage if present
if (existing.thumbnail_url) {
const url = existing.thumbnail_url as string
const match = url.match(/preset-thumbnails\/(.+)$/)
if (match?.[1]) {
await supabaseAdmin.storage.from('preset-thumbnails').remove([match[1]])
}
}
const { error } = await supabaseAdmin.from('presets').delete().eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ success: true })
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
import { supabaseAdmin } from '@/lib/supabase/server'
import { createId } from '@pascal-app/db'
// GET /api/presets?type=door|window&tab=community|mine
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl
const type = searchParams.get('type')
const tab = searchParams.get('tab') ?? 'community'
if (!type || (type !== 'door' && type !== 'window')) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
if (tab === 'mine') {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('user_id', session.user.id)
.eq('is_community', false)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// community tab
const { data, error } = await supabaseAdmin
.from('presets')
.select('*')
.eq('type', type)
.eq('is_community', true)
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ presets: data })
}
// POST /api/presets
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { type, name, data, thumbnailUrl } = body
if (!type || !name || !data) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
if (type !== 'door' && type !== 'window') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 })
}
const { data: preset, error } = await supabaseAdmin
.from('presets')
.insert({
id: createId('preset'),
type,
name,
data,
thumbnail_url: thumbnailUrl ?? null,
user_id: session.user.id,
is_community: false,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ preset }, { status: 201 })
}
@@ -2,7 +2,7 @@
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
@@ -14,6 +14,7 @@ import { MetricControl } from '../controls/metric-control'
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'
export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -116,6 +117,37 @@ export function DoorPanel() {
handleUpdate({ segments: updated })
}
const handleSavePreset = useCallback(async (name: string) => {
if (!node) return
const data = {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
threshold: node.threshold,
thresholdHeight: node.thresholdHeight,
handle: node.handle,
handleHeight: node.handleHeight,
handleSide: node.handleSide,
doorCloser: node.doorCloser,
panicBar: node.panicBar,
panicBarHeight: node.panicBarHeight,
segments: node.segments,
}
await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'door', name, data }),
})
}, [node])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<DoorNode>)
}, [handleUpdate])
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
@@ -128,6 +160,16 @@ export function DoorPanel() {
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="door" onApply={handleApplyPreset} onSave={handleSavePreset}>
<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>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">wall</sub></>}
@@ -0,0 +1,395 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { BookMarked, Pencil, Plus, Trash2, Users, Check, X } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { cn } from '@/lib/utils'
export type PresetType = 'door' | 'window'
export interface PresetData {
id: string
type: string
name: string
data: Record<string, unknown>
thumbnail_url: string | null
user_id: string | null
is_community: boolean
created_at: string
}
type Tab = 'community' | 'mine'
interface PresetsPopoverProps {
type: PresetType
onApply: (data: Record<string, unknown>) => void
onSave: (name: string) => Promise<void>
children: React.ReactNode
}
export function PresetsPopover({ type, onApply, onSave, children }: PresetsPopoverProps) {
const { isAuthenticated } = useAuth()
const [open, setOpen] = useState(false)
const [tab, setTab] = useState<Tab>('community')
const [presets, setPresets] = useState<PresetData[]>([])
const [loading, setLoading] = useState(false)
// Save dialog 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)
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 ?? [])
}
} finally {
setLoading(false)
}
}, [type, tab])
useEffect(() => {
if (open) fetchPresets()
}, [open, fetchPresets])
// Switch tab to community if user signs out while on mine tab
useEffect(() => {
if (!isAuthenticated && tab === 'mine') setTab('community')
}, [isAuthenticated, tab])
const handleSave = async () => {
if (!saveName.trim()) return
setSaving(true)
try {
await onSave(saveName.trim())
setSaveName('')
setShowSaveInput(false)
if (tab === 'mine') fetchPresets()
else setTab('mine')
} finally {
setSaving(false)
}
}
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) {
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) {
setPresets((prev) => prev.filter((p) => p.id !== id))
setDeletingId(null)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="left"
align="start"
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" />
<span className="text-xs font-semibold text-foreground tracking-tight">
{type === 'door' ? 'Door' : 'Window'} Presets
</span>
</div>
{isAuthenticated && (
<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"
>
<Plus className="h-3 w-3" />
Save preset
</button>
)}
</div>
{/* Save input */}
{showSaveInput && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
<input
autoFocus
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') { setShowSaveInput(false); setSaveName('') }
}}
placeholder="Preset name…"
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
disabled={!saveName.trim() || saving}
onClick={handleSave}
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" />
</button>
<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"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Tabs */}
<div className="flex border-b border-border/50">
<TabButton active={tab === 'community'} onClick={() => setTab('community')}>
<Users className="h-3 w-3" />
Community
</TabButton>
<TabButton
active={tab === 'mine'}
onClick={() => {
if (!isAuthenticated) return
setTab('mine')
}}
disabled={!isAuthenticated}
>
<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">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
</div>
) : presets.length === 0 ? (
<EmptyState tab={tab} isAuthenticated={isAuthenticated} />
) : (
<ul className="divide-y divide-border/30">
{presets.map((preset) => (
<PresetRow
key={preset.id}
preset={preset}
isMine={tab === 'mine'}
renamingId={renamingId}
renameValue={renameValue}
deletingId={deletingId}
onApply={() => { onApply(preset.data); setOpen(false) }}
onStartRename={() => { setRenamingId(preset.id); setRenameValue(preset.name) }}
onRenameChange={setRenameValue}
onRenameConfirm={() => handleRename(preset.id)}
onRenameCancel={() => setRenamingId(null)}
onDeleteRequest={() => setDeletingId(preset.id)}
onDeleteConfirm={() => handleDelete(preset.id)}
onDeleteCancel={() => setDeletingId(null)}
/>
))}
</ul>
)}
</div>
</PopoverContent>
</Popover>
)
}
function TabButton({
active,
onClick,
disabled,
children,
}: {
active: boolean
onClick: () => void
disabled?: boolean
children: React.ReactNode
}) {
return (
<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',
disabled && 'opacity-40 cursor-not-allowed',
)}
>
{children}
</button>
)
}
function EmptyState({ tab, isAuthenticated }: { tab: Tab; 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" />
<p className="text-xs text-muted-foreground">
{tab === 'community'
? 'No community presets yet.'
: isAuthenticated
? 'No presets saved yet. Use "Save preset" to save the current configuration.'
: 'Sign in to save and view your presets.'}
</p>
</div>
)
}
interface PresetRowProps {
preset: PresetData
isMine: boolean
renamingId: string | null
renameValue: string
deletingId: string | null
onApply: () => void
onStartRename: () => void
onRenameChange: (v: string) => void
onRenameConfirm: () => void
onRenameCancel: () => void
onDeleteRequest: () => void
onDeleteConfirm: () => void
onDeleteCancel: () => void
}
function PresetRow({
preset,
isMine,
renamingId,
renameValue,
deletingId,
onApply,
onStartRename,
onRenameChange,
onRenameConfirm,
onRenameCancel,
onDeleteRequest,
onDeleteConfirm,
onDeleteCancel,
}: PresetRowProps) {
const isRenaming = renamingId === preset.id
const isDeleting = deletingId === preset.id
if (isDeleting) {
return (
<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>
</div>
</li>
)
}
if (isRenaming) {
return (
<li className="flex items-center gap-1.5 px-3 py-2">
<input
autoFocus
value={renameValue}
onChange={(e) => onRenameChange(e.target.value)}
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>
</li>
)
}
return (
<li className="group flex items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors">
{/* Thumbnail placeholder */}
<div className="h-8 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
<img src={preset.thumbnail_url} alt={preset.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center">
<div className="h-3 w-5 rounded-sm border border-muted-foreground/30" />
</div>
)}
</div>
<button
onClick={onApply}
className="flex-1 min-w-0 text-left"
>
<span className="block truncate text-xs font-medium text-foreground group-hover:text-foreground/90">
{preset.name}
</span>
<span className="block text-[10px] text-muted-foreground/60">
{new Date(preset.created_at).toLocaleDateString()}
</span>
</button>
{isMine && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<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>
<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>
</div>
)}
</li>
)
}
@@ -2,7 +2,7 @@
import { type AnyNode, type AnyNodeId, WindowNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
@@ -13,6 +13,7 @@ import { SliderControl } from '../controls/slider-control'
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'
export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -91,6 +92,32 @@ export function WindowPanel() {
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
const handleSavePreset = useCallback(async (name: string) => {
if (!node) return
const data = {
width: node.width,
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
columnRatios: node.columnRatios,
rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness,
rowDividerThickness: node.rowDividerThickness,
sill: node.sill,
sillDepth: node.sillDepth,
sillThickness: node.sillThickness,
}
await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'window', name, data }),
})
}, [node])
const handleApplyPreset = useCallback((data: Record<string, unknown>) => {
handleUpdate(data as Partial<WindowNode>)
}, [handleUpdate])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const numCols = node.columnRatios.length
@@ -134,6 +161,16 @@ export function WindowPanel() {
onClose={handleClose}
width={320}
>
{/* Presets strip */}
<div className="px-3 pt-2.5 pb-1.5 border-b border-border/30">
<PresetsPopover type="window" onApply={handleApplyPreset} onSave={handleSavePreset}>
<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>
</button>
</PresetsPopover>
</div>
<PanelSection title="Position">
<SliderControl
label={<>X<sub className="text-[11px] ml-[1px] opacity-70">pos</sub></>}
+9
View File
@@ -37,5 +37,14 @@ export async function register() {
})
console.log('Created "project-assets" storage bucket')
}
if (!bucketNames.has('preset-thumbnails')) {
await supabase.storage.createBucket('preset-thumbnails', {
public: true,
fileSizeLimit: 5 * 1024 * 1024, // 5MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp'],
})
console.log('Created "preset-thumbnails" storage bucket')
}
}
}
+3
View File
@@ -14,3 +14,6 @@ export * from './projects/assets'
export * from './projects/likes'
export * from './projects/models'
export * from './projects/projects'
// Presets table
export * from './presets/presets'
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, index, text, boolean, jsonb } from 'drizzle-orm/pg-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { id, timestampsColumns } from '../../helpers'
import { users } from '../auth/users'
export const presets = pgTable(
'presets',
(t) => ({
id: id('preset'),
type: t.text('type').notNull(), // 'door' | 'window'
name: t.text('name').notNull(),
data: t.jsonb('data').notNull(),
thumbnailUrl: t.text('thumbnail_url'),
userId: t
.text('user_id')
.references(() => users.id, { onDelete: 'cascade' }),
isCommunity: t.boolean('is_community').notNull().default(false),
...timestampsColumns,
}),
(t) => [
index('presets_type_idx').on(t.type),
index('presets_user_id_idx').on(t.userId),
index('presets_is_community_idx').on(t.isCommunity),
],
).enableRLS()
export type Preset = typeof presets.$inferSelect
export type NewPreset = typeof presets.$inferInsert
export const insertPresetSchema = createInsertSchema(presets)
export const selectPresetSchema = createSelectSchema(presets)
+35
View File
@@ -116,6 +116,41 @@ export interface Database {
updated_at?: string
}
}
presets: {
Row: {
id: string
type: string
name: string
data: Json
thumbnail_url: string | null
user_id: string | null
is_community: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
type: string
name: string
data: Json
thumbnail_url?: string | null
user_id?: string | null
is_community?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
type?: string
name?: string
data?: Json
thumbnail_url?: string | null
user_id?: string | null
is_community?: boolean
created_at?: string
updated_at?: string
}
}
}
Views: {}
Functions: {}
@@ -0,0 +1,17 @@
CREATE TABLE "presets" (
"id" text PRIMARY KEY NOT NULL,
"type" text NOT NULL,
"name" text NOT NULL,
"data" jsonb NOT NULL,
"thumbnail_url" text,
"user_id" text,
"is_community" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "presets" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "presets" ADD CONSTRAINT "presets_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "presets_type_idx" ON "presets" USING btree ("type");--> statement-breakpoint
CREATE INDEX "presets_user_id_idx" ON "presets" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "presets_is_community_idx" ON "presets" USING btree ("is_community");
@@ -0,0 +1,58 @@
-- Presets table: stores door/window presets for community and user use
CREATE TABLE "presets" (
"id" text PRIMARY KEY NOT NULL,
"type" text NOT NULL, -- 'door' | 'window'
"name" text NOT NULL,
"data" jsonb NOT NULL,
"thumbnail_url" text,
"user_id" text REFERENCES "auth_users"("id") ON DELETE CASCADE,
"is_community" boolean NOT NULL DEFAULT false,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "presets_type_idx" ON "presets"("type");
--> statement-breakpoint
CREATE INDEX "presets_user_id_idx" ON "presets"("user_id");
--> statement-breakpoint
CREATE INDEX "presets_is_community_idx" ON "presets"("is_community");
--> statement-breakpoint
ALTER TABLE "presets" ENABLE ROW LEVEL SECURITY;
--> statement-breakpoint
-- Anyone can read community presets
CREATE POLICY "Anyone can view community presets"
ON "presets" FOR SELECT
USING ("is_community" = true);
--> statement-breakpoint
-- Users can view their own presets
CREATE POLICY "Users can view own presets"
ON "presets" FOR SELECT
USING ("user_id" = current_setting('app.user_id', true)::TEXT);
--> statement-breakpoint
-- Users can insert their own presets
CREATE POLICY "Users can insert own presets"
ON "presets" FOR INSERT
WITH CHECK ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false);
--> statement-breakpoint
-- Users can update their own presets
CREATE POLICY "Users can update own presets"
ON "presets" FOR UPDATE
USING ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false);
--> statement-breakpoint
-- Users can delete their own presets
CREATE POLICY "Users can delete own presets"
ON "presets" FOR DELETE
USING ("user_id" = current_setting('app.user_id', true)::TEXT AND "is_community" = false);
--> statement-breakpoint
CREATE TRIGGER "update_presets_updated_at"
BEFORE UPDATE ON "presets"
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
File diff suppressed because it is too large Load Diff
+7
View File
@@ -71,6 +71,13 @@
"when": 1772573729680,
"tag": "20260303213529_marvelous_mikhail_rasputin",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1772626520793,
"tag": "20260304121520_daffy_gideon",
"breakpoints": true
}
]
}