this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: reduce UI duplication and centralize theme logic

+514 -452
+18
app/globals.css
··· 12 12 --accent: #4f7a58; 13 13 --accent-soft: #dbe6dd; 14 14 --accent-ink: #2f5d35; 15 + --danger: #e11d48; 16 + --danger-hover: #be123c; 17 + --danger-contrast: #fff8fb; 15 18 --danger-soft: rgba(190, 24, 93, 0.08); 16 19 --danger-ink: #be123c; 17 20 --danger-line: rgba(190, 24, 93, 0.24); 18 21 --line: rgba(23, 23, 23, 0.08); 19 22 --line-strong: rgba(23, 23, 23, 0.14); 23 + --shadow-surface: 0 22px 60px rgba(15, 23, 42, 0.08); 24 + --shadow-elevated: 0 18px 44px rgba(15, 23, 42, 0.08); 25 + --shadow-button: 0 16px 40px rgba(15, 23, 42, 0.18); 26 + --shadow-button-hover: 0 20px 48px rgba(15, 23, 42, 0.24); 27 + --shadow-toast: 0 18px 50px rgba(15, 23, 42, 0.12); 28 + --shadow-accent: 0 16px 40px rgba(79, 122, 88, 0.1); 20 29 } 21 30 22 31 :root[data-theme="dark"] { ··· 31 40 --accent: #8db896; 32 41 --accent-soft: rgba(141, 184, 150, 0.16); 33 42 --accent-ink: #d7ecd9; 43 + --danger: #ff6b93; 44 + --danger-hover: #ff5b88; 45 + --danger-contrast: #220710; 34 46 --danger-soft: rgba(190, 24, 93, 0.16); 35 47 --danger-ink: #ffb3c7; 36 48 --danger-line: rgba(255, 179, 199, 0.28); 37 49 --line: rgba(255, 255, 255, 0.1); 38 50 --line-strong: rgba(255, 255, 255, 0.18); 51 + --shadow-surface: 0 22px 60px rgba(0, 0, 0, 0.24); 52 + --shadow-elevated: 0 18px 44px rgba(0, 0, 0, 0.22); 53 + --shadow-button: 0 16px 40px rgba(0, 0, 0, 0.32); 54 + --shadow-button-hover: 0 20px 48px rgba(0, 0, 0, 0.4); 55 + --shadow-toast: 0 18px 50px rgba(0, 0, 0, 0.28); 56 + --shadow-accent: 0 16px 40px rgba(79, 122, 88, 0.22); 39 57 color-scheme: dark; 40 58 } 41 59
+4 -8
components/dashboard-form-browser.tsx
··· 6 6 import { useEffect, useMemo, useState } from "react"; 7 7 8 8 import { createFormAction } from "@/app/(creator)/actions"; 9 - import { Badge } from "@/components/ui/badge"; 9 + import { FormStatusBadge } from "@/components/form-status-badge"; 10 10 import { Button } from "@/components/ui/button"; 11 11 import { Card } from "@/components/ui/card"; 12 - import type { FormListItem } from "@/lib/forms"; 12 + import type { FormListItem } from "@/lib/form-types"; 13 13 import { cn, formatDate } from "@/lib/utils"; 14 14 15 15 type DashboardView = "grid" | "table"; ··· 155 155 <div> 156 156 <div className="flex flex-wrap items-center gap-3"> 157 157 <h2 className="font-display text-3xl text-[var(--ink)]">{form.title}</h2> 158 - <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "rounded-full"}> 159 - {form.status === "PUBLISHED" ? "Published" : "Draft"} 160 - </Badge> 158 + <FormStatusBadge status={form.status} /> 161 159 </div> 162 160 <p className="mt-3 max-w-xl text-sm leading-6 text-[var(--muted)]">{form.description || "No description yet."}</p> 163 161 </div> ··· 228 226 </div> 229 227 </td> 230 228 <td className="px-5 py-4"> 231 - <Badge className={form.status === "PUBLISHED" ? "rounded-full bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "rounded-full"}> 232 - {form.status === "PUBLISHED" ? "Published" : "Draft"} 233 - </Badge> 229 + <FormStatusBadge status={form.status} /> 234 230 </td> 235 231 <td className="px-5 py-4 text-[var(--muted)]">{form.responseCount}</td> 236 232 <td className="px-5 py-4 text-[var(--muted)]">{formatDate(form.updatedAt)}</td>
+304
components/form-builder-panels.tsx
··· 1 + import { Copy, Link as LinkIcon, LoaderCircle, MessagesSquare, Plus, Save, Settings2, Trash2, type LucideIcon } from "lucide-react"; 2 + import Link from "next/link"; 3 + import type { Dispatch, SetStateAction } from "react"; 4 + 5 + import { FormStatusBadge } from "@/components/form-status-badge"; 6 + import { Button } from "@/components/ui/button"; 7 + import { Input } from "@/components/ui/input"; 8 + import { Textarea } from "@/components/ui/textarea"; 9 + import { 10 + blockTypeLabels, 11 + isQuestionBlock, 12 + type BlockConfig, 13 + type ChoiceBlockConfig, 14 + type LongTextBlockConfig, 15 + type ShortTextBlockConfig, 16 + type TextBlockConfig, 17 + } from "@/lib/blocks"; 18 + import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 19 + 20 + export type FormMetadataDraft = { 21 + title: string; 22 + description: string; 23 + completionTitle: string; 24 + completionMessage: string; 25 + completionLinkLabel: string; 26 + completionLinkUrl: string; 27 + slug: string; 28 + }; 29 + 30 + export function BuilderHeader({ 31 + form, 32 + settingsSelected, 33 + busy, 34 + onOpenSettings, 35 + onTogglePublished, 36 + }: { 37 + form: BuilderForm; 38 + settingsSelected: boolean; 39 + busy: string | null; 40 + onOpenSettings: () => void; 41 + onTogglePublished: () => void; 42 + }) { 43 + return ( 44 + <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 45 + <div> 46 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Builder</p> 47 + <div className="mt-3 flex flex-wrap items-center gap-3"> 48 + <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 49 + <FormStatusBadge status={form.status} /> 50 + </div> 51 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Edit blocks, update settings, and review responses.</p> 52 + </div> 53 + <div className="flex flex-wrap items-center gap-3 lg:justify-end"> 54 + <Button variant={settingsSelected ? "default" : "secondary"} onClick={onOpenSettings}> 55 + <Settings2 className="size-4" /> 56 + Settings 57 + </Button> 58 + <Link href={`/forms/${form.id}/responses`}> 59 + <Button variant="secondary"> 60 + <MessagesSquare className="size-4" /> 61 + Responses ({form.responseCount}) 62 + </Button> 63 + </Link> 64 + <Button variant={form.status === "PUBLISHED" ? "secondary" : "default"} onClick={onTogglePublished}> 65 + {busy === "publish" ? <LoaderCircle className="size-4 animate-spin" /> : <LinkIcon className="size-4" />} 66 + {form.status === "PUBLISHED" ? "Unpublish" : "Publish form"} 67 + </Button> 68 + </div> 69 + </section> 70 + ); 71 + } 72 + 73 + export function FormSettingsPanel({ 74 + metadataDraft, 75 + setMetadataDraft, 76 + shareHref, 77 + copyShareLink, 78 + deleteForm, 79 + saveMetadata, 80 + busy, 81 + }: { 82 + metadataDraft: FormMetadataDraft; 83 + setMetadataDraft: Dispatch<SetStateAction<FormMetadataDraft>>; 84 + shareHref: string; 85 + copyShareLink: () => void; 86 + deleteForm: () => void; 87 + saveMetadata: () => void; 88 + busy: string | null; 89 + }) { 90 + return ( 91 + <div className="space-y-6"> 92 + <div> 93 + <h2 className="font-display text-4xl text-[var(--ink)]">Settings</h2> 94 + </div> 95 + 96 + <div className="grid gap-5"> 97 + <label className="space-y-2 text-sm text-[var(--muted)]"> 98 + <span className="font-medium text-[var(--ink)]">Title</span> 99 + <Input value={metadataDraft.title} onChange={(event) => setMetadataDraft((current) => ({ ...current, title: event.target.value }))} /> 100 + </label> 101 + <label className="space-y-2 text-sm text-[var(--muted)]"> 102 + <span className="font-medium text-[var(--ink)]">Description</span> 103 + <Textarea value={metadataDraft.description} onChange={(event) => setMetadataDraft((current) => ({ ...current, description: event.target.value }))} /> 104 + </label> 105 + <label className="space-y-2 text-sm text-[var(--muted)]"> 106 + <span className="font-medium text-[var(--ink)]">Share URL slug</span> 107 + <Input value={metadataDraft.slug} className="font-mono text-[13px]" onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} /> 108 + </label> 109 + </div> 110 + 111 + <div className="space-y-5 border-t border-[color:var(--line)] pt-6"> 112 + <div> 113 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">After submission</p> 114 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 115 + Customize what respondents see after they finish the form, including an optional next-step link. 116 + </p> 117 + </div> 118 + 119 + <div className="grid gap-5"> 120 + <label className="space-y-2 text-sm text-[var(--muted)]"> 121 + <span className="font-medium text-[var(--ink)]">Completion title</span> 122 + <Input value={metadataDraft.completionTitle} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionTitle: event.target.value }))} /> 123 + </label> 124 + <label className="space-y-2 text-sm text-[var(--muted)]"> 125 + <span className="font-medium text-[var(--ink)]">Completion message</span> 126 + <Textarea value={metadataDraft.completionMessage} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionMessage: event.target.value }))} /> 127 + </label> 128 + <div className="grid gap-5 lg:grid-cols-2"> 129 + <label className="space-y-2 text-sm text-[var(--muted)]"> 130 + <span className="font-medium text-[var(--ink)]">Follow-up link label</span> 131 + <Input value={metadataDraft.completionLinkLabel} placeholder="Read next steps" onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkLabel: event.target.value }))} /> 132 + </label> 133 + <label className="space-y-2 text-sm text-[var(--muted)]"> 134 + <span className="font-medium text-[var(--ink)]">Follow-up link URL</span> 135 + <Input value={metadataDraft.completionLinkUrl} placeholder="https://example.com/next" onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkUrl: event.target.value }))} /> 136 + </label> 137 + </div> 138 + </div> 139 + </div> 140 + 141 + <div className="grid gap-4 rounded-[20px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-5 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 142 + <div> 143 + <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Public route</p> 144 + <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> 145 + <p className="mt-1 text-xs text-[var(--muted)]"> 146 + Published forms accept anonymous submissions. Changes to a published form go live immediately. 147 + </p> 148 + </div> 149 + <Button variant="secondary" onClick={copyShareLink}> 150 + <Copy className="size-4" /> 151 + Copy link 152 + </Button> 153 + <Link href={shareHref} target="_blank"> 154 + <Button variant="secondary"> 155 + <LinkIcon className="size-4" /> 156 + Open runner 157 + </Button> 158 + </Link> 159 + </div> 160 + 161 + <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 162 + <Button variant="danger" onClick={deleteForm}> 163 + {busy === "delete-form" ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 164 + Delete form 165 + </Button> 166 + <Button onClick={saveMetadata}> 167 + {busy === "metadata" ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 168 + Save form settings 169 + </Button> 170 + </div> 171 + </div> 172 + ); 173 + } 174 + 175 + export function BlockEditorPanel({ 176 + blockDraft, 177 + selectedBlockIcon: SelectedBlockIcon, 178 + choiceOptionsDraft, 179 + setChoiceOptionsDraft, 180 + setBlockDraft, 181 + deleteBlock, 182 + saveBlock, 183 + busy, 184 + }: { 185 + blockDraft: BuilderBlock; 186 + selectedBlockIcon: LucideIcon | null; 187 + choiceOptionsDraft: string; 188 + setChoiceOptionsDraft: (value: string) => void; 189 + setBlockDraft: Dispatch<SetStateAction<BuilderBlock | null>>; 190 + deleteBlock: (blockId: string) => void; 191 + saveBlock: () => void; 192 + busy: string | null; 193 + }) { 194 + return ( 195 + <div className="space-y-6"> 196 + <div className="flex items-start justify-between gap-4"> 197 + <div className="flex items-center gap-3"> 198 + {SelectedBlockIcon ? <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> : null} 199 + <h2 className="font-display text-4xl text-[var(--ink)]">{blockTypeLabels[blockDraft.type]}</h2> 200 + </div> 201 + <span className="inline-flex size-8 items-center justify-center rounded-full border border-[color:var(--line-strong)] bg-[var(--accent-soft)] text-sm font-semibold text-[var(--accent-ink)]"> 202 + {blockDraft.position + 1} 203 + </span> 204 + </div> 205 + 206 + <div className="grid gap-5"> 207 + <label className="space-y-2 text-sm text-[var(--muted)]"> 208 + <span className="font-medium text-[var(--ink)]">Prompt</span> 209 + <Input value={blockDraft.title} onChange={(event) => setBlockDraft((current) => (current ? { ...current, title: event.target.value } : current))} /> 210 + </label> 211 + <label className="space-y-2 text-sm text-[var(--muted)]"> 212 + <span className="font-medium text-[var(--ink)]">Support text</span> 213 + <Textarea value={blockDraft.description} onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> 214 + </label> 215 + 216 + {blockDraft.type === "TEXT" ? ( 217 + <label className="space-y-2 text-sm text-[var(--muted)]"> 218 + <span className="font-medium text-[var(--ink)]">Body copy</span> 219 + <Textarea 220 + value={(blockDraft.config as TextBlockConfig).body} 221 + onChange={(event) => setBlockDraft((current) => (current ? { ...current, config: { body: event.target.value } as BlockConfig } : current))} 222 + /> 223 + </label> 224 + ) : null} 225 + 226 + {(blockDraft.type === "SHORT_TEXT" || blockDraft.type === "LONG_TEXT") && ( 227 + <label className="space-y-2 text-sm text-[var(--muted)]"> 228 + <span className="font-medium text-[var(--ink)]">Placeholder</span> 229 + <Input 230 + value={blockDraft.type === "SHORT_TEXT" ? (blockDraft.config as ShortTextBlockConfig).placeholder : (blockDraft.config as LongTextBlockConfig).placeholder} 231 + onChange={(event) => 232 + setBlockDraft((current) => 233 + current ? { ...current, config: { placeholder: event.target.value } as BlockConfig } : current, 234 + ) 235 + } 236 + /> 237 + </label> 238 + )} 239 + 240 + {(blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE") && ( 241 + <label className="space-y-2 text-sm text-[var(--muted)]"> 242 + <span className="font-medium text-[var(--ink)]">Choice options</span> 243 + <Textarea 244 + value={choiceOptionsDraft} 245 + onChange={(event) => { 246 + setChoiceOptionsDraft(event.target.value); 247 + setBlockDraft((current) => 248 + current 249 + ? { 250 + ...current, 251 + config: { options: event.target.value.split("\n") } as BlockConfig, 252 + } 253 + : current, 254 + ); 255 + }} 256 + /> 257 + <p className="text-xs text-[var(--muted)]">Use one option per line. We’ll keep the first 10 non-empty values.</p> 258 + </label> 259 + )} 260 + 261 + {isQuestionBlock(blockDraft.type) ? ( 262 + <label className="flex items-center gap-3 rounded-xl border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 263 + <input 264 + checked={blockDraft.required} 265 + className="size-4 rounded border-[color:var(--line-strong)]" 266 + type="checkbox" 267 + onChange={(event) => setBlockDraft((current) => (current ? { ...current, required: event.target.checked } : current))} 268 + /> 269 + Mark this question as required 270 + </label> 271 + ) : null} 272 + </div> 273 + 274 + <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 275 + <Button variant="danger" onClick={() => deleteBlock(blockDraft.id)}> 276 + {busy === `delete-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 277 + Delete block 278 + </Button> 279 + <Button onClick={saveBlock}> 280 + {busy === `block-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 281 + Save block 282 + </Button> 283 + </div> 284 + </div> 285 + ); 286 + } 287 + 288 + export function EmptyEditorState({ onAddShortText }: { onAddShortText: () => void }) { 289 + return ( 290 + <div className="flex min-h-[420px] items-center justify-center rounded-[20px] border border-dashed border-[color:var(--line)] bg-[var(--bg-strong)] p-10 text-center"> 291 + <div> 292 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Nothing selected</p> 293 + <h2 className="mt-4 font-display text-4xl text-[var(--ink)]">Select a block or open form settings.</h2> 294 + <p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-[var(--muted)]">Use the left panel to choose what you want to edit.</p> 295 + <div className="mt-6 flex justify-center"> 296 + <Button variant="secondary" onClick={onAddShortText}> 297 + <Plus className="size-4" /> 298 + Add a short text question 299 + </Button> 300 + </div> 301 + </div> 302 + </div> 303 + ); 304 + }
+45 -329
components/form-builder.tsx
··· 17 17 } from "@dnd-kit/sortable"; 18 18 import { CSS } from "@dnd-kit/utilities"; 19 19 import { 20 - Copy, 21 20 GripVertical, 22 - Link as LinkIcon, 23 21 LoaderCircle, 24 - Plus, 25 - MessagesSquare, 26 22 Radio, 27 23 Rows3, 28 - Save, 29 - Settings2, 30 24 SquareCheck, 31 25 TextCursorInput, 32 - Trash2, 33 26 Type, 34 27 } from "lucide-react"; 35 28 import Link from "next/link"; 36 29 import { useRouter } from "next/navigation"; 37 30 31 + import { 32 + BlockEditorPanel, 33 + BuilderHeader, 34 + EmptyEditorState, 35 + FormSettingsPanel, 36 + type FormMetadataDraft, 37 + } from "@/components/form-builder-panels"; 38 38 import { Badge } from "@/components/ui/badge"; 39 39 import { Button } from "@/components/ui/button"; 40 40 import { Card } from "@/components/ui/card"; 41 - import { Input } from "@/components/ui/input"; 42 41 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 43 - import { Textarea } from "@/components/ui/textarea"; 42 + import { blockTypeLabels, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks"; 43 + import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 44 44 import { cn } from "@/lib/utils"; 45 45 46 - type BlockType = "TEXT" | "SHORT_TEXT" | "LONG_TEXT" | "SINGLE_CHOICE" | "MULTIPLE_CHOICE"; 47 - 48 - type TextConfig = { body: string }; 49 - type PlaceholderConfig = { placeholder: string }; 50 - type ChoiceConfig = { options: string[] }; 51 - 52 - type BuilderBlock = { 53 - id: string; 54 - type: BlockType; 55 - title: string; 56 - description: string; 57 - required: boolean; 58 - position: number; 59 - config: TextConfig | PlaceholderConfig | ChoiceConfig; 60 - }; 61 - 62 - type BuilderForm = { 63 - id: string; 64 - title: string; 65 - description: string; 66 - completionTitle: string; 67 - completionMessage: string; 68 - completionLinkLabel: string | null; 69 - completionLinkUrl: string | null; 70 - slug: string; 71 - status: "DRAFT" | "PUBLISHED"; 72 - updatedAt: string; 73 - responseCount: number; 74 - blocks: BuilderBlock[]; 75 - }; 46 + type BlockType = BuilderBlock["type"]; 76 47 77 48 type Selection = { kind: "form" } | { kind: "block"; blockId: string }; 78 49 ··· 82 53 LONG_TEXT: Rows3, 83 54 SINGLE_CHOICE: Radio, 84 55 MULTIPLE_CHOICE: SquareCheck, 85 - }; 86 - 87 - const blockLabels: Record<BlockType, string> = { 88 - TEXT: "Text block", 89 - SHORT_TEXT: "Short text", 90 - LONG_TEXT: "Long text", 91 - SINGLE_CHOICE: "Single choice", 92 - MULTIPLE_CHOICE: "Multiple choice", 93 56 }; 94 57 95 58 const blockCreationOrder: BlockType[] = ["TEXT", "SHORT_TEXT", "LONG_TEXT", "SINGLE_CHOICE", "MULTIPLE_CHOICE"]; ··· 112 75 return payload; 113 76 } 114 77 115 - function getBlockPreview(block: BuilderBlock) { 116 - if (block.title.trim()) { 117 - return block.title; 118 - } 119 - 120 - if (block.type === "TEXT") { 121 - return (block.config as TextConfig).body; 122 - } 123 - 124 - if ("placeholder" in block.config) { 125 - return block.config.placeholder; 126 - } 127 - 128 - if ("options" in block.config) { 129 - return block.config.options.join(" • "); 130 - } 131 - 132 - return blockLabels[block.type]; 133 - } 134 - 135 - function isQuestion(type: BlockType) { 136 - return type !== "TEXT"; 137 - } 138 - 139 78 function BlockRowInner({ 140 79 block, 141 80 onSelect, ··· 154 93 <div className="flex items-center justify-between gap-2 text-[var(--muted)]"> 155 94 <div className="flex min-w-0 items-center gap-1.5"> 156 95 <Icon className="size-3.5 shrink-0" /> 157 - <span className="truncate text-[10px] font-semibold uppercase tracking-[0.18em]">{blockLabels[block.type]}</span> 96 + <span className="truncate text-[10px] font-semibold uppercase tracking-[0.18em]">{blockTypeLabels[block.type]}</span> 158 97 </div> 159 98 <span className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-[color:var(--line-strong)] bg-[var(--accent-soft)] text-[10px] font-semibold text-[var(--accent-ink)]"> 160 99 {block.position + 1} ··· 187 126 className={cn( 188 127 "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 189 128 selected 190 - ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]" 129 + ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 191 130 : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 192 131 )} 193 132 > ··· 223 162 className={cn( 224 163 "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 225 164 selected 226 - ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]" 165 + ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 227 166 : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 228 167 )} 229 168 > ··· 250 189 const [busy, setBusy] = useState<string | null>(null); 251 190 const [isDndReady, setIsDndReady] = useState(false); 252 191 253 - const [metadataDraft, setMetadataDraft] = useState({ 192 + const [metadataDraft, setMetadataDraft] = useState<FormMetadataDraft>({ 254 193 title: initialForm.title, 255 194 description: initialForm.description, 256 195 completionTitle: initialForm.completionTitle, ··· 268 207 const [blockDraft, setBlockDraft] = useState<BuilderBlock | null>(selectedBlock); 269 208 const [choiceOptionsDraft, setChoiceOptionsDraft] = useState( 270 209 selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 271 - ? (selectedBlock.config as ChoiceConfig).options.join("\n") 210 + ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n") 272 211 : "", 273 212 ); 274 213 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; ··· 291 230 setBlockDraft(selectedBlock); 292 231 setChoiceOptionsDraft( 293 232 selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 294 - ? (selectedBlock.config as ChoiceConfig).options.join("\n") 233 + ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n") 295 234 : "", 296 235 ); 297 236 }, [selectedBlock]); ··· 346 285 blocks: [...current.blocks, payload.block], 347 286 })); 348 287 setSelection({ kind: "block", blockId: payload.block.id }); 349 - showToast(`Added ${blockLabels[type].toLowerCase()}`); 288 + showToast(`Added ${blockTypeLabels[type].toLowerCase()}`); 350 289 }); 351 290 } 352 291 ··· 475 414 <> 476 415 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 477 416 <div className="space-y-6"> 478 - <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 479 - <div> 480 - <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Builder</p> 481 - <div className="mt-3 flex flex-wrap items-center gap-3"> 482 - <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 483 - <Badge className={cn("rounded-full", form.status === "PUBLISHED" && "bg-[var(--accent-soft)] text-[var(--accent-ink)]")}>{form.status}</Badge> 484 - </div> 485 - <p className="mt-2 text-sm leading-6 text-[var(--muted)]">Edit blocks, update settings, and review responses.</p> 486 - </div> 487 - <div className="flex flex-wrap items-center gap-3 lg:justify-end"> 488 - <Button 489 - variant={selection.kind === "form" ? "default" : "secondary"} 490 - onClick={() => setSelection({ kind: "form" })} 491 - > 492 - <Settings2 className="size-4" /> 493 - Settings 494 - </Button> 495 - <Link href={`/forms/${form.id}/responses`}> 496 - <Button variant="secondary"> 497 - <MessagesSquare className="size-4" /> 498 - Responses ({form.responseCount}) 499 - </Button> 500 - </Link> 501 - <Button variant={form.status === "PUBLISHED" ? "secondary" : "default"} onClick={togglePublished}> 502 - {busy === "publish" ? <LoaderCircle className="size-4 animate-spin" /> : <LinkIcon className="size-4" />} 503 - {form.status === "PUBLISHED" ? "Unpublish" : "Publish form"} 504 - </Button> 505 - </div> 506 - </section> 417 + <BuilderHeader 418 + form={form} 419 + settingsSelected={selection.kind === "form"} 420 + busy={busy} 421 + onOpenSettings={() => setSelection({ kind: "form" })} 422 + onTogglePublished={togglePublished} 423 + /> 507 424 508 425 <div className="grid gap-6 lg:grid-cols-[340px_minmax(0,1fr)]"> 509 426 <Card className="p-5"> ··· 553 470 onClick={() => addBlock(type)} 554 471 > 555 472 {busy === `add-${type}` ? <LoaderCircle className="size-4 animate-spin" /> : <Icon className="size-4" />} 556 - {blockLabels[type]} 473 + {blockTypeLabels[type]} 557 474 </Button> 558 475 ); 559 476 })} ··· 563 480 564 481 <Card className="p-6 lg:p-8"> 565 482 {selection.kind === "form" ? ( 566 - <div className="space-y-6"> 567 - <div> 568 - <h2 className="font-display text-4xl text-[var(--ink)]">Settings</h2> 569 - </div> 570 - 571 - <div className="grid gap-5"> 572 - <label className="space-y-2 text-sm text-[var(--muted)]"> 573 - <span className="font-medium text-[var(--ink)]">Title</span> 574 - <Input value={metadataDraft.title} onChange={(event) => setMetadataDraft((current) => ({ ...current, title: event.target.value }))} /> 575 - </label> 576 - <label className="space-y-2 text-sm text-[var(--muted)]"> 577 - <span className="font-medium text-[var(--ink)]">Description</span> 578 - <Textarea 579 - value={metadataDraft.description} 580 - onChange={(event) => setMetadataDraft((current) => ({ ...current, description: event.target.value }))} 581 - /> 582 - </label> 583 - <label className="space-y-2 text-sm text-[var(--muted)]"> 584 - <span className="font-medium text-[var(--ink)]">Share URL slug</span> 585 - <Input 586 - value={metadataDraft.slug} 587 - className="font-mono text-[13px]" 588 - onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} 589 - /> 590 - </label> 591 - </div> 592 - 593 - <div className="space-y-5 border-t border-[color:var(--line)] pt-6"> 594 - <div> 595 - <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">After submission</p> 596 - <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 597 - Customize what respondents see after they finish the form, including an optional next-step link. 598 - </p> 599 - </div> 600 - 601 - <div className="grid gap-5"> 602 - <label className="space-y-2 text-sm text-[var(--muted)]"> 603 - <span className="font-medium text-[var(--ink)]">Completion title</span> 604 - <Input 605 - value={metadataDraft.completionTitle} 606 - onChange={(event) => setMetadataDraft((current) => ({ ...current, completionTitle: event.target.value }))} 607 - /> 608 - </label> 609 - <label className="space-y-2 text-sm text-[var(--muted)]"> 610 - <span className="font-medium text-[var(--ink)]">Completion message</span> 611 - <Textarea 612 - value={metadataDraft.completionMessage} 613 - onChange={(event) => setMetadataDraft((current) => ({ ...current, completionMessage: event.target.value }))} 614 - /> 615 - </label> 616 - <div className="grid gap-5 lg:grid-cols-2"> 617 - <label className="space-y-2 text-sm text-[var(--muted)]"> 618 - <span className="font-medium text-[var(--ink)]">Follow-up link label</span> 619 - <Input 620 - value={metadataDraft.completionLinkLabel} 621 - placeholder="Read next steps" 622 - onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkLabel: event.target.value }))} 623 - /> 624 - </label> 625 - <label className="space-y-2 text-sm text-[var(--muted)]"> 626 - <span className="font-medium text-[var(--ink)]">Follow-up link URL</span> 627 - <Input 628 - value={metadataDraft.completionLinkUrl} 629 - placeholder="https://example.com/next" 630 - onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkUrl: event.target.value }))} 631 - /> 632 - </label> 633 - </div> 634 - </div> 635 - </div> 636 - 637 - <div className="grid gap-4 rounded-[20px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-5 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 638 - <div> 639 - <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Public route</p> 640 - <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> 641 - <p className="mt-1 text-xs text-[var(--muted)]"> 642 - Published forms accept anonymous submissions. Changes to a published form go live immediately. 643 - </p> 644 - </div> 645 - <Button variant="secondary" onClick={copyShareLink}> 646 - <Copy className="size-4" /> 647 - Copy link 648 - </Button> 649 - <Link href={shareHref} target="_blank"> 650 - <Button variant="secondary"> 651 - <LinkIcon className="size-4" /> 652 - Open runner 653 - </Button> 654 - </Link> 655 - </div> 656 - 657 - <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 658 - <Button variant="danger" onClick={deleteForm}> 659 - {busy === "delete-form" ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 660 - Delete form 661 - </Button> 662 - <Button onClick={saveMetadata}> 663 - {busy === "metadata" ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 664 - Save form settings 665 - </Button> 666 - </div> 667 - </div> 483 + <FormSettingsPanel 484 + metadataDraft={metadataDraft} 485 + setMetadataDraft={setMetadataDraft} 486 + shareHref={shareHref} 487 + copyShareLink={copyShareLink} 488 + deleteForm={deleteForm} 489 + saveMetadata={saveMetadata} 490 + busy={busy} 491 + /> 668 492 ) : blockDraft ? ( 669 - <div className="space-y-6"> 670 - <div className="flex items-start justify-between gap-4"> 671 - <div className="flex items-center gap-3"> 672 - {SelectedBlockIcon ? <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> : null} 673 - <h2 className="font-display text-4xl text-[var(--ink)]">{blockLabels[blockDraft.type]}</h2> 674 - </div> 675 - <span className="inline-flex size-8 items-center justify-center rounded-full border border-[color:var(--line-strong)] bg-[var(--accent-soft)] text-sm font-semibold text-[var(--accent-ink)]"> 676 - {blockDraft.position + 1} 677 - </span> 678 - </div> 679 - 680 - <div className="grid gap-5"> 681 - <label className="space-y-2 text-sm text-[var(--muted)]"> 682 - <span className="font-medium text-[var(--ink)]">Prompt</span> 683 - <Input 684 - value={blockDraft.title} 685 - onChange={(event) => setBlockDraft((current) => (current ? { ...current, title: event.target.value } : current))} 686 - /> 687 - </label> 688 - <label className="space-y-2 text-sm text-[var(--muted)]"> 689 - <span className="font-medium text-[var(--ink)]">Support text</span> 690 - <Textarea 691 - value={blockDraft.description} 692 - onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} 693 - /> 694 - </label> 695 - 696 - {blockDraft.type === "TEXT" ? ( 697 - <label className="space-y-2 text-sm text-[var(--muted)]"> 698 - <span className="font-medium text-[var(--ink)]">Body copy</span> 699 - <Textarea 700 - value={(blockDraft.config as TextConfig).body} 701 - onChange={(event) => 702 - setBlockDraft((current) => 703 - current ? { ...current, config: { body: event.target.value } } : current, 704 - ) 705 - } 706 - /> 707 - </label> 708 - ) : null} 709 - 710 - {(blockDraft.type === "SHORT_TEXT" || blockDraft.type === "LONG_TEXT") && ( 711 - <label className="space-y-2 text-sm text-[var(--muted)]"> 712 - <span className="font-medium text-[var(--ink)]">Placeholder</span> 713 - <Input 714 - value={(blockDraft.config as PlaceholderConfig).placeholder} 715 - onChange={(event) => 716 - setBlockDraft((current) => 717 - current ? { ...current, config: { placeholder: event.target.value } } : current, 718 - ) 719 - } 720 - /> 721 - </label> 722 - )} 723 - 724 - {(blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE") && ( 725 - <label className="space-y-2 text-sm text-[var(--muted)]"> 726 - <span className="font-medium text-[var(--ink)]">Choice options</span> 727 - <Textarea 728 - value={choiceOptionsDraft} 729 - onChange={(event) => { 730 - setChoiceOptionsDraft(event.target.value); 731 - setBlockDraft((current) => 732 - current 733 - ? { 734 - ...current, 735 - config: { 736 - options: event.target.value.split("\n"), 737 - }, 738 - } 739 - : current, 740 - ); 741 - }} 742 - /> 743 - <p className="text-xs text-[var(--muted)]">Use one option per line. We’ll keep the first 10 non-empty values.</p> 744 - </label> 745 - )} 746 - 747 - {isQuestion(blockDraft.type) ? ( 748 - <label className="flex items-center gap-3 rounded-xl border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 749 - <input 750 - checked={blockDraft.required} 751 - className="size-4 rounded border-[color:var(--line-strong)]" 752 - type="checkbox" 753 - onChange={(event) => 754 - setBlockDraft((current) => (current ? { ...current, required: event.target.checked } : current)) 755 - } 756 - /> 757 - Mark this question as required 758 - </label> 759 - ) : null} 760 - </div> 761 - 762 - <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 763 - <Button variant="danger" onClick={() => deleteBlock(blockDraft.id)}> 764 - {busy === `delete-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 765 - Delete block 766 - </Button> 767 - <Button onClick={saveBlock}> 768 - {busy === `block-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 769 - Save block 770 - </Button> 771 - </div> 772 - </div> 493 + <BlockEditorPanel 494 + blockDraft={blockDraft} 495 + selectedBlockIcon={SelectedBlockIcon} 496 + choiceOptionsDraft={choiceOptionsDraft} 497 + setChoiceOptionsDraft={setChoiceOptionsDraft} 498 + setBlockDraft={setBlockDraft} 499 + deleteBlock={deleteBlock} 500 + saveBlock={saveBlock} 501 + busy={busy} 502 + /> 773 503 ) : ( 774 - <div className="flex min-h-[420px] items-center justify-center rounded-[20px] border border-dashed border-[color:var(--line)] bg-[var(--bg-strong)] p-10 text-center"> 775 - <div> 776 - <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Nothing selected</p> 777 - <h2 className="mt-4 font-display text-4xl text-[var(--ink)]">Select a block or open form settings.</h2> 778 - <p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-[var(--muted)]"> 779 - Use the left panel to choose what you want to edit. 780 - </p> 781 - <div className="mt-6 flex justify-center"> 782 - <Button variant="secondary" onClick={() => addBlock("SHORT_TEXT")}> 783 - <Plus className="size-4" /> 784 - Add a short text question 785 - </Button> 786 - </div> 787 - </div> 788 - </div> 504 + <EmptyEditorState onAddShortText={() => addBlock("SHORT_TEXT")} /> 789 505 )} 790 506 </Card> 791 507 </div>
+18
components/form-status-badge.tsx
··· 1 + import type { FormStatus } from "@prisma/client"; 2 + 3 + import { Badge } from "@/components/ui/badge"; 4 + import { cn, sentenceCase } from "@/lib/utils"; 5 + 6 + export function FormStatusBadge({ status, className }: { status: FormStatus; className?: string }) { 7 + return ( 8 + <Badge 9 + className={cn( 10 + "rounded-full", 11 + status === "PUBLISHED" && "bg-[var(--accent-soft)] text-[var(--accent-ink)]", 12 + className, 13 + )} 14 + > 15 + {sentenceCase(status)} 16 + </Badge> 17 + ); 18 + }
+2 -2
components/loading-shell.tsx
··· 4 4 <div className="h-4 w-24 animate-pulse rounded-full bg-[var(--line)]" /> 5 5 <div className="h-12 w-64 animate-pulse rounded-full bg-[var(--line)]" /> 6 6 <div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]"> 7 - <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 8 - <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)]" /> 7 + <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[var(--shadow-surface)]" /> 8 + <div className="h-[420px] animate-pulse rounded-[20px] bg-[var(--surface)] shadow-[var(--shadow-surface)]" /> 9 9 </div> 10 10 <p className="text-sm text-[var(--muted)]">Loading {title}…</p> 11 11 </div>
+9 -34
components/public-form-runner.tsx
··· 11 11 import { Input } from "@/components/ui/input"; 12 12 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 13 13 import { Textarea } from "@/components/ui/textarea"; 14 + import type { ChoiceBlockConfig, LongTextBlockConfig, ShortTextBlockConfig, TextBlockConfig } from "@/lib/blocks"; 15 + import type { PublicForm } from "@/lib/form-types"; 14 16 import { cn } from "@/lib/utils"; 15 - 16 - type BlockType = "TEXT" | "SHORT_TEXT" | "LONG_TEXT" | "SINGLE_CHOICE" | "MULTIPLE_CHOICE"; 17 - 18 - type PublicBlock = { 19 - id: string; 20 - type: BlockType; 21 - title: string; 22 - description: string; 23 - required: boolean; 24 - position: number; 25 - config: 26 - | { body: string } 27 - | { placeholder: string } 28 - | { options: string[] }; 29 - }; 30 - 31 - type PublicForm = { 32 - id: string; 33 - title: string; 34 - description: string; 35 - completionTitle: string; 36 - completionMessage: string; 37 - completionLinkLabel: string | null; 38 - completionLinkUrl: string | null; 39 - slug: string; 40 - blocks: PublicBlock[]; 41 - }; 42 17 43 18 async function submitResponse(slug: string, answers: Record<string, string | string[]>) { 44 19 const response = await fetch(`/api/public/forms/${slug}/responses`, { ··· 241 216 {currentBlock.title || "Take a breath before the next step."} 242 217 </h2> 243 218 {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{currentBlock.description}</p> : null} 244 - <p className="mt-6 max-w-3xl text-lg leading-9 text-[var(--ink)]/82">{(currentBlock.config as { body: string }).body}</p> 219 + <p className="mt-6 max-w-3xl text-lg leading-9 text-[var(--ink)]/82">{(currentBlock.config as TextBlockConfig).body}</p> 245 220 </div> 246 221 ) : ( 247 222 <div className="flex min-h-[320px] flex-col justify-center"> ··· 270 245 {currentBlock.type === "SHORT_TEXT" ? ( 271 246 <Input 272 247 className="h-14 text-base placeholder:text-[var(--muted)]/65" 273 - placeholder={(currentBlock.config as { placeholder: string }).placeholder} 248 + placeholder={(currentBlock.config as ShortTextBlockConfig).placeholder} 274 249 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 275 250 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 276 251 /> ··· 279 254 {currentBlock.type === "LONG_TEXT" ? ( 280 255 <Textarea 281 256 className="min-h-[180px] text-base placeholder:text-[var(--muted)]/65" 282 - placeholder={(currentBlock.config as { placeholder: string }).placeholder} 257 + placeholder={(currentBlock.config as LongTextBlockConfig).placeholder} 283 258 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 284 259 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 285 260 /> ··· 287 262 288 263 {currentBlock.type === "SINGLE_CHOICE" ? ( 289 264 <div className="grid gap-3"> 290 - {(currentBlock.config as { options: string[] }).options.map((option) => { 265 + {(currentBlock.config as ChoiceBlockConfig).options.map((option) => { 291 266 const selected = answers[currentBlock.id] === option; 292 267 293 268 return ( ··· 297 272 className={cn( 298 273 "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 299 274 selected 300 - ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 275 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 301 276 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 302 277 )} 303 278 onClick={() => setAnswer(currentBlock.id, option)} ··· 316 291 317 292 {currentBlock.type === "MULTIPLE_CHOICE" ? ( 318 293 <div className="grid gap-3"> 319 - {(currentBlock.config as { options: string[] }).options.map((option) => { 294 + {(currentBlock.config as ChoiceBlockConfig).options.map((option) => { 320 295 const rawValues = answers[currentBlock.id]; 321 296 const currentValues: string[] = Array.isArray(rawValues) ? rawValues : []; 322 297 const selected = currentValues.includes(option); ··· 328 303 className={cn( 329 304 "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 330 305 selected 331 - ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 306 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 332 307 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 333 308 )} 334 309 onClick={() => {
+2 -2
components/theme-provider.tsx
··· 4 4 5 5 import { 6 6 THEME_STORAGE_KEY, 7 + applyResolvedTheme, 7 8 applyThemePreference, 8 9 persistThemePreference, 9 10 readStoredThemePreference, ··· 39 40 return; 40 41 } 41 42 42 - const nextResolved = event.matches ? "dark" : "light"; 43 - document.documentElement.dataset.theme = nextResolved; 43 + const nextResolved = applyResolvedTheme(document.documentElement, currentPreference, event.matches); 44 44 setResolvedTheme(nextResolved); 45 45 }; 46 46
+1 -1
components/theme-settings-panel.tsx
··· 59 59 className={cn( 60 60 "rounded-[18px] border p-5 text-left transition", 61 61 selected 62 - ? "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[0_16px_40px_rgba(79,122,88,0.10)]" 62 + ? "border-[var(--accent)] bg-[var(--accent-soft)] shadow-[var(--shadow-accent)]" 63 63 : "border-[color:var(--line)] bg-[var(--surface-strong)] hover:bg-[var(--surface)]", 64 64 )} 65 65 aria-pressed={selected}
+2 -2
components/ui/button.tsx
··· 9 9 variants: { 10 10 variant: { 11 11 default: 12 - "bg-[var(--ink)] px-5 py-3 text-[var(--bg)] shadow-[0_16px_40px_rgba(15,23,42,0.18)] hover:-translate-y-0.5 hover:shadow-[0_20px_48px_rgba(15,23,42,0.24)] focus-visible:ring-[var(--ink)]", 12 + "bg-[var(--ink)] px-5 py-3 text-[var(--bg)] shadow-[var(--shadow-button)] hover:-translate-y-0.5 hover:shadow-[var(--shadow-button-hover)] focus-visible:ring-[var(--ink)]", 13 13 secondary: 14 14 "bg-[var(--surface-strong)] px-5 py-3 text-[var(--ink)] ring-1 ring-[color:var(--line)] hover:bg-[var(--surface)] focus-visible:ring-[var(--ink)]", 15 15 ghost: 16 16 "px-3 py-2 text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)] focus-visible:ring-[var(--ink)]", 17 17 danger: 18 - "bg-[var(--surface-strong)] px-5 py-3 text-[var(--muted)] ring-1 ring-[color:var(--line)] hover:bg-[#ef4444] hover:text-white hover:ring-[#ef4444] focus-visible:ring-[#ef4444]", 18 + "bg-[var(--surface-strong)] px-5 py-3 text-[var(--muted)] ring-1 ring-[color:var(--line)] hover:bg-[var(--danger-hover)] hover:text-[var(--danger-contrast)] hover:ring-[var(--danger-hover)] focus-visible:ring-[var(--danger-hover)]", 19 19 }, 20 20 size: { 21 21 default: "h-11",
+1 -1
components/ui/card.tsx
··· 6 6 return ( 7 7 <div 8 8 className={cn( 9 - "rounded-[20px] border border-[color:var(--line)] bg-[var(--surface)] shadow-[0_22px_60px_rgba(15,23,42,0.08)] backdrop-blur-sm", 9 + "rounded-[20px] border border-[color:var(--line)] bg-[var(--surface)] shadow-[var(--shadow-surface)] backdrop-blur-sm", 10 10 className, 11 11 )} 12 12 {...props}
+1 -1
components/ui/toast.tsx
··· 38 38 exit={{ opacity: 0, x: 24, y: -8, scale: 0.98 }} 39 39 transition={{ duration: 0.18, ease: "easeOut" }} 40 40 className={cn( 41 - "pointer-events-auto flex items-start gap-3 rounded-xl border bg-[var(--bg-strong)] px-4 py-3 shadow-[0_18px_50px_rgba(15,23,42,0.12)]", 41 + "pointer-events-auto flex items-start gap-3 rounded-xl border bg-[var(--bg-strong)] px-4 py-3 shadow-[var(--shadow-toast)]", 42 42 toast.variant === "error" 43 43 ? "border-[color:var(--danger-line)] text-[var(--danger-ink)]" 44 44 : "border-[color:var(--accent)] text-[var(--accent-ink)]",
+59
lib/form-types.ts
··· 1 + import type { FormStatus } from "@prisma/client"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + 5 + export type BuilderBlock = SerializedBlock; 6 + export type PublicBlock = SerializedBlock; 7 + export type AnswerValue = string | string[]; 8 + 9 + export type BuilderForm = { 10 + id: string; 11 + title: string; 12 + description: string; 13 + completionTitle: string; 14 + completionMessage: string; 15 + completionLinkLabel: string | null; 16 + completionLinkUrl: string | null; 17 + slug: string; 18 + status: FormStatus; 19 + updatedAt: string; 20 + responseCount: number; 21 + blocks: BuilderBlock[]; 22 + }; 23 + 24 + export type PublicForm = { 25 + id: string; 26 + title: string; 27 + description: string; 28 + completionTitle: string; 29 + completionMessage: string; 30 + completionLinkLabel: string | null; 31 + completionLinkUrl: string | null; 32 + slug: string; 33 + blocks: PublicBlock[]; 34 + }; 35 + 36 + export type FormListItem = { 37 + id: string; 38 + title: string; 39 + description: string; 40 + slug: string; 41 + status: FormStatus; 42 + updatedAt: string; 43 + responseCount: number; 44 + }; 45 + 46 + export type ResponseListItem = { 47 + id: string; 48 + submittedAt: string; 49 + answerCount: number; 50 + submissionNumber: number; 51 + }; 52 + 53 + export type ResponseDetail = { 54 + id: string; 55 + submittedAt: string; 56 + submissionNumber: number; 57 + answers: Record<string, AnswerValue>; 58 + blocks: SerializedBlock[]; 59 + };
+14 -51
lib/forms.ts
··· 17 17 } from "@/lib/blocks"; 18 18 import { db } from "@/lib/db"; 19 19 import { AppError } from "@/lib/errors"; 20 + import type { 21 + BuilderForm, 22 + FormListItem, 23 + PublicForm, 24 + ResponseDetail, 25 + ResponseListItem, 26 + } from "@/lib/form-types"; 20 27 import { 21 28 blockUpdateSchema, 22 29 createBlockSchema, ··· 67 74 config: z.unknown(), 68 75 }); 69 76 70 - export type BuilderForm = { 71 - id: string; 72 - title: string; 73 - description: string; 74 - completionTitle: string; 75 - completionMessage: string; 76 - completionLinkLabel: string | null; 77 - completionLinkUrl: string | null; 78 - slug: string; 79 - status: FormStatus; 80 - updatedAt: string; 81 - responseCount: number; 82 - blocks: SerializedBlock[]; 83 - }; 84 - 85 - export type PublicForm = { 86 - id: string; 87 - title: string; 88 - description: string; 89 - completionTitle: string; 90 - completionMessage: string; 91 - completionLinkLabel: string | null; 92 - completionLinkUrl: string | null; 93 - slug: string; 94 - blocks: SerializedBlock[]; 95 - }; 96 - 97 - export type FormListItem = { 98 - id: string; 99 - title: string; 100 - description: string; 101 - slug: string; 102 - status: FormStatus; 103 - updatedAt: string; 104 - responseCount: number; 105 - }; 106 - 107 - export type ResponseListItem = { 108 - id: string; 109 - submittedAt: string; 110 - answerCount: number; 111 - submissionNumber: number; 112 - }; 113 - 114 - export type ResponseDetail = { 115 - id: string; 116 - submittedAt: string; 117 - submissionNumber: number; 118 - answers: Record<string, string | string[]>; 119 - blocks: SerializedBlock[]; 120 - }; 77 + export type { 78 + BuilderForm, 79 + FormListItem, 80 + PublicForm, 81 + ResponseDetail, 82 + ResponseListItem, 83 + } from "@/lib/form-types"; 121 84 122 85 function toBuilderForm(form: BuilderFormRecord): BuilderForm { 123 86 return {
+34 -21
lib/theme.ts
··· 9 9 return typeof value === "string" && THEME_PREFERENCES.includes(value as ThemePreference); 10 10 } 11 11 12 + export function normalizeThemePreference(value: unknown): ThemePreference { 13 + return isThemePreference(value) ? value : "system"; 14 + } 15 + 12 16 export function resolveTheme(preference: ThemePreference, systemPrefersDark: boolean): ResolvedTheme { 13 17 if (preference === "system") { 14 18 return systemPrefersDark ? "dark" : "light"; ··· 17 21 return preference; 18 22 } 19 23 20 - export function applyThemePreference(preference: ThemePreference) { 21 - const resolved = resolveTheme(preference, window.matchMedia("(prefers-color-scheme: dark)").matches); 22 - const root = document.documentElement; 24 + export function applyResolvedTheme(root: HTMLElement, preference: ThemePreference, systemPrefersDark: boolean) { 25 + const resolved = resolveTheme(preference, systemPrefersDark); 23 26 24 27 root.dataset.themePreference = preference; 25 28 root.dataset.theme = resolved; 26 29 root.style.colorScheme = resolved; 30 + 31 + return resolved; 32 + } 33 + 34 + export function applyThemePreference(preference: ThemePreference) { 35 + return applyResolvedTheme(document.documentElement, preference, window.matchMedia("(prefers-color-scheme: dark)").matches); 27 36 } 28 37 29 38 export function readStoredThemePreference(): ThemePreference { 30 39 try { 31 - const stored = window.localStorage.getItem(THEME_STORAGE_KEY); 32 - return isThemePreference(stored) ? stored : "system"; 40 + return normalizeThemePreference(window.localStorage.getItem(THEME_STORAGE_KEY)); 33 41 } catch { 34 42 return "system"; 35 43 } ··· 43 51 } 44 52 } 45 53 46 - export const themeInitScript = `(() => { 47 - const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)}; 48 - const root = document.documentElement; 49 - const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 54 + export function createThemeInitScript() { 55 + return `(() => { 56 + const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)}; 57 + const validPreferences = ${JSON.stringify(THEME_PREFERENCES)}; 58 + const root = document.documentElement; 59 + const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 60 + 61 + let preference = "system"; 50 62 51 - let preference = "system"; 63 + try { 64 + const stored = window.localStorage.getItem(storageKey); 65 + if (validPreferences.includes(stored)) { 66 + preference = stored; 67 + } 68 + } catch {} 52 69 53 - try { 54 - const stored = window.localStorage.getItem(storageKey); 55 - if (stored === "light" || stored === "dark" || stored === "system") { 56 - preference = stored; 57 - } 58 - } catch {} 70 + const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference; 71 + root.dataset.themePreference = preference; 72 + root.dataset.theme = resolved; 73 + root.style.colorScheme = resolved; 74 + })();`; 75 + } 59 76 60 - const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference; 61 - root.dataset.themePreference = preference; 62 - root.dataset.theme = resolved; 63 - root.style.colorScheme = resolved; 64 - })();`; 77 + export const themeInitScript = createThemeInitScript();