this repo has no description
0
fork

Configure Feed

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

feat: refine builder experience and archive layout change

+506 -148
+7 -1
app/globals.css
··· 72 72 box-sizing: border-box; 73 73 } 74 74 75 + html { 76 + min-height: 100%; 77 + background-color: var(--bg-end); 78 + } 79 + 75 80 body { 76 81 min-height: 100vh; 77 - background: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%); 82 + background-color: var(--bg-end); 83 + background-image: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%); 78 84 color: var(--ink); 79 85 font-family: var(--font-manrope), sans-serif; 80 86 }
+161 -42
components/form-builder-panels.tsx
··· 1 - import { Copy, Link as LinkIcon, LoaderCircle, MessagesSquare, Plus, Save, Settings2, Trash2, type LucideIcon } from "lucide-react"; 1 + "use client"; 2 + 3 + import { 4 + closestCenter, 5 + DndContext, 6 + type DragEndEvent, 7 + PointerSensor, 8 + useSensor, 9 + useSensors, 10 + } from "@dnd-kit/core"; 11 + import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; 12 + import { CSS } from "@dnd-kit/utilities"; 13 + import { Copy, GripVertical, Link as LinkIcon, LoaderCircle, MessagesSquare, Plus, Save, Settings2, Trash2, type LucideIcon } from "lucide-react"; 2 14 import Link from "next/link"; 3 15 import type { Dispatch, SetStateAction } from "react"; 4 16 ··· 10 22 blockTypeLabels, 11 23 isQuestionBlock, 12 24 type BlockConfig, 13 - type ChoiceBlockConfig, 14 25 type LongTextBlockConfig, 15 26 type ShortTextBlockConfig, 16 27 type TextBlockConfig, ··· 25 36 completionLinkLabel: string; 26 37 completionLinkUrl: string; 27 38 slug: string; 39 + }; 40 + 41 + export type ChoiceOptionDraft = { 42 + id: string; 43 + value: string; 28 44 }; 29 45 30 46 export function BuilderHeader({ ··· 89 105 busy: string | null; 90 106 }) { 91 107 return ( 92 - <div className="space-y-6"> 108 + <div className="space-y-8"> 93 109 <div> 94 110 <h2 className="font-display text-4xl text-[var(--ink)]">Settings</h2> 95 111 </div> 96 112 97 113 <div className="grid gap-5"> 98 - <label className="space-y-2 text-sm text-[var(--muted)]"> 114 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 99 115 <span className="font-medium text-[var(--ink)]">Title</span> 100 116 <Input value={metadataDraft.title} onChange={(event) => setMetadataDraft((current) => ({ ...current, title: event.target.value }))} /> 101 117 </label> 102 - <label className="space-y-2 text-sm text-[var(--muted)]"> 118 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 103 119 <span className="font-medium text-[var(--ink)]">Description</span> 104 120 <Textarea value={metadataDraft.description} onChange={(event) => setMetadataDraft((current) => ({ ...current, description: event.target.value }))} /> 105 121 </label> 106 - <label className="space-y-2 text-sm text-[var(--muted)]"> 122 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 107 123 <span className="font-medium text-[var(--ink)]">Share URL slug</span> 108 124 <Input value={metadataDraft.slug} className="font-mono text-[13px]" onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} /> 109 125 </label> ··· 118 134 </div> 119 135 120 136 <div className="grid gap-5"> 121 - <label className="space-y-2 text-sm text-[var(--muted)]"> 137 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 122 138 <span className="font-medium text-[var(--ink)]">Completion title</span> 123 139 <Input value={metadataDraft.completionTitle} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionTitle: event.target.value }))} /> 124 140 </label> 125 - <label className="space-y-2 text-sm text-[var(--muted)]"> 141 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 126 142 <span className="font-medium text-[var(--ink)]">Completion message</span> 127 143 <Textarea value={metadataDraft.completionMessage} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionMessage: event.target.value }))} /> 128 144 </label> 129 145 <div className="grid gap-5 lg:grid-cols-2"> 130 - <label className="space-y-2 text-sm text-[var(--muted)]"> 146 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 131 147 <span className="font-medium text-[var(--ink)]">Follow-up link label</span> 132 148 <Input value={metadataDraft.completionLinkLabel} placeholder="Read next steps" onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkLabel: event.target.value }))} /> 133 149 </label> 134 - <label className="space-y-2 text-sm text-[var(--muted)]"> 150 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 135 151 <span className="font-medium text-[var(--ink)]">Follow-up link URL</span> 136 152 <Input value={metadataDraft.completionLinkUrl} placeholder="https://example.com/next" onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkUrl: event.target.value }))} /> 137 153 </label> ··· 139 155 </div> 140 156 </div> 141 157 142 - <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"> 158 + <div className="grid gap-4 border-t border-[color:var(--line)] pt-6 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 143 159 <div> 144 160 <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Public route</p> 145 161 <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> ··· 173 189 ); 174 190 } 175 191 192 + function SortableChoiceOptionRow({ 193 + option, 194 + index, 195 + optionCount, 196 + onChange, 197 + onRemove, 198 + }: { 199 + option: ChoiceOptionDraft; 200 + index: number; 201 + optionCount: number; 202 + onChange: (optionId: string, value: string) => void; 203 + onRemove: (optionId: string) => void; 204 + }) { 205 + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: option.id }); 206 + 207 + return ( 208 + <div 209 + ref={setNodeRef} 210 + style={{ 211 + transform: CSS.Transform.toString(transform), 212 + transition, 213 + }} 214 + className="flex items-center gap-2 rounded-[18px] border border-[color:var(--line)] bg-[var(--surface)] p-3" 215 + > 216 + <button 217 + type="button" 218 + className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] text-[var(--muted)] transition hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]" 219 + aria-label={`Drag option ${index + 1}`} 220 + {...attributes} 221 + {...listeners} 222 + > 223 + <GripVertical className="size-4" /> 224 + </button> 225 + <Input value={option.value} placeholder={`Option ${index + 1}`} onChange={(event) => onChange(option.id, event.target.value)} /> 226 + <button 227 + type="button" 228 + className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--muted)] transition hover:bg-[var(--danger-hover)] hover:text-[var(--danger-contrast)] hover:border-[var(--danger-hover)] disabled:opacity-100 disabled:text-[var(--line-strong)]" 229 + aria-label={`Remove option ${index + 1}`} 230 + disabled={optionCount <= 2} 231 + onClick={() => onRemove(option.id)} 232 + > 233 + <Trash2 className="size-4" /> 234 + </button> 235 + </div> 236 + ); 237 + } 238 + 176 239 export function BlockEditorPanel({ 177 240 blockDraft, 178 241 selectedBlockIcon: SelectedBlockIcon, ··· 185 248 }: { 186 249 blockDraft: BuilderBlock; 187 250 selectedBlockIcon: LucideIcon | null; 188 - choiceOptionsDraft: string; 189 - setChoiceOptionsDraft: (value: string) => void; 251 + choiceOptionsDraft: ChoiceOptionDraft[]; 252 + setChoiceOptionsDraft: Dispatch<SetStateAction<ChoiceOptionDraft[]>>; 190 253 setBlockDraft: Dispatch<SetStateAction<BuilderBlock | null>>; 191 254 deleteBlock: (blockId: string) => void; 192 255 saveBlock: () => void; 193 256 busy: string | null; 194 257 }) { 258 + const optionSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 259 + 260 + function syncChoiceOptions(nextOptions: ChoiceOptionDraft[]) { 261 + setChoiceOptionsDraft(nextOptions); 262 + setBlockDraft((current) => 263 + current 264 + ? { 265 + ...current, 266 + config: { options: nextOptions.map((option) => option.value) } as BlockConfig, 267 + } 268 + : current, 269 + ); 270 + } 271 + 272 + function updateChoiceOption(optionId: string, value: string) { 273 + syncChoiceOptions(choiceOptionsDraft.map((option) => (option.id === optionId ? { ...option, value } : option))); 274 + } 275 + 276 + function addChoiceOption() { 277 + if (choiceOptionsDraft.length >= 10) { 278 + return; 279 + } 280 + 281 + syncChoiceOptions([...choiceOptionsDraft, { id: crypto.randomUUID(), value: `Option ${choiceOptionsDraft.length + 1}` }]); 282 + } 283 + 284 + function removeChoiceOption(optionId: string) { 285 + if (choiceOptionsDraft.length <= 2) { 286 + return; 287 + } 288 + 289 + syncChoiceOptions(choiceOptionsDraft.filter((option) => option.id !== optionId)); 290 + } 291 + 292 + function handleChoiceOptionDragEnd(event: DragEndEvent) { 293 + const { active, over } = event; 294 + 295 + if (!over || active.id === over.id) { 296 + return; 297 + } 298 + 299 + const oldIndex = choiceOptionsDraft.findIndex((option) => option.id === active.id); 300 + const newIndex = choiceOptionsDraft.findIndex((option) => option.id === over.id); 301 + 302 + if (oldIndex < 0 || newIndex < 0) { 303 + return; 304 + } 305 + 306 + syncChoiceOptions(arrayMove(choiceOptionsDraft, oldIndex, newIndex)); 307 + } 308 + 195 309 return ( 196 - <div className="space-y-6"> 197 - <div className="flex items-start justify-between gap-4"> 310 + <div className="space-y-8"> 311 + <div className="flex items-start gap-4"> 198 312 <div className="flex items-center gap-3"> 199 313 {SelectedBlockIcon ? <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> : null} 200 314 <h2 className="font-display text-4xl text-[var(--ink)]">{blockTypeLabels[blockDraft.type]}</h2> 201 315 </div> 202 - <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)]"> 203 - {blockDraft.position + 1} 204 - </span> 205 316 </div> 206 317 207 318 <div className="grid gap-5"> 208 - <label className="space-y-2 text-sm text-[var(--muted)]"> 319 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 209 320 <span className="font-medium text-[var(--ink)]">Prompt</span> 210 321 <Input value={blockDraft.title} onChange={(event) => setBlockDraft((current) => (current ? { ...current, title: event.target.value } : current))} /> 211 322 </label> 212 - <label className="space-y-2 text-sm text-[var(--muted)]"> 323 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 213 324 <span className="font-medium text-[var(--ink)]">Support text</span> 214 325 <Textarea value={blockDraft.description} onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> 215 326 </label> 216 327 217 328 {blockDraft.type === "TEXT" ? ( 218 - <label className="space-y-2 text-sm text-[var(--muted)]"> 329 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 219 330 <span className="font-medium text-[var(--ink)]">Body copy</span> 220 331 <Textarea 221 332 value={(blockDraft.config as TextBlockConfig).body} ··· 225 336 ) : null} 226 337 227 338 {(blockDraft.type === "SHORT_TEXT" || blockDraft.type === "LONG_TEXT") && ( 228 - <label className="space-y-2 text-sm text-[var(--muted)]"> 339 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 229 340 <span className="font-medium text-[var(--ink)]">Placeholder</span> 230 341 <Input 231 342 value={blockDraft.type === "SHORT_TEXT" ? (blockDraft.config as ShortTextBlockConfig).placeholder : (blockDraft.config as LongTextBlockConfig).placeholder} ··· 239 350 )} 240 351 241 352 {(blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE") && ( 242 - <label className="space-y-2 text-sm text-[var(--muted)]"> 243 - <span className="font-medium text-[var(--ink)]">Choice options</span> 244 - <Textarea 245 - value={choiceOptionsDraft} 246 - onChange={(event) => { 247 - setChoiceOptionsDraft(event.target.value); 248 - setBlockDraft((current) => 249 - current 250 - ? { 251 - ...current, 252 - config: { options: event.target.value.split("\n") } as BlockConfig, 253 - } 254 - : current, 255 - ); 256 - }} 257 - /> 258 - <p className="text-xs text-[var(--muted)]">Use one option per line. We’ll keep the first 10 non-empty values.</p> 259 - </label> 353 + <div className="grid gap-3 text-sm text-[var(--muted)]"> 354 + <div className="flex items-center justify-between gap-3"> 355 + <span className="font-medium text-[var(--ink)]">Choice options</span> 356 + <Button variant="secondary" size="sm" onClick={addChoiceOption} disabled={choiceOptionsDraft.length >= 10}> 357 + <Plus className="size-4" /> 358 + Add option 359 + </Button> 360 + </div> 361 + <DndContext sensors={optionSensors} collisionDetection={closestCenter} onDragEnd={handleChoiceOptionDragEnd}> 362 + <SortableContext items={choiceOptionsDraft.map((option) => option.id)} strategy={verticalListSortingStrategy}> 363 + <div className="grid gap-3"> 364 + {choiceOptionsDraft.map((option, index) => ( 365 + <SortableChoiceOptionRow 366 + key={option.id} 367 + option={option} 368 + index={index} 369 + optionCount={choiceOptionsDraft.length} 370 + onChange={updateChoiceOption} 371 + onRemove={removeChoiceOption} 372 + /> 373 + ))} 374 + </div> 375 + </SortableContext> 376 + </DndContext> 377 + <p className="text-xs text-[var(--muted)]">Drag to reorder, rename in place, and keep between 2 and 10 options.</p> 378 + </div> 260 379 )} 261 380 262 381 {isQuestionBlock(blockDraft.type) ? ( 263 - <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)]"> 382 + <label className="flex items-center gap-3 rounded-xl bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 264 383 <input 265 384 checked={blockDraft.required} 266 385 className="size-4 rounded border-[color:var(--line-strong)]" ··· 288 407 289 408 export function EmptyEditorState({ onAddShortText }: { onAddShortText: () => void }) { 290 409 return ( 291 - <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"> 410 + <div className="flex min-h-[420px] items-center justify-center border-t border-dashed border-[color:var(--line)] p-10 text-center"> 292 411 <div> 293 412 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Nothing selected</p> 294 413 <h2 className="mt-4 font-display text-4xl text-[var(--ink)]">Select a block or open form settings.</h2>
+136 -103
components/form-builder.tsx
··· 1 1 "use client"; 2 2 3 - import { useEffect, useMemo, useState } from "react"; 3 + import { useEffect, useMemo, useRef, useState } from "react"; 4 4 import { 5 5 closestCenter, 6 6 DndContext, ··· 19 19 import { 20 20 GripVertical, 21 21 LoaderCircle, 22 + Plus, 22 23 Radio, 23 24 Rows3, 24 25 SquareCheck, ··· 33 34 BuilderHeader, 34 35 EmptyEditorState, 35 36 FormSettingsPanel, 37 + type ChoiceOptionDraft, 36 38 type FormMetadataDraft, 37 39 } from "@/components/form-builder-panels"; 38 40 import { Badge } from "@/components/ui/badge"; 39 41 import { Button } from "@/components/ui/button"; 40 - import { Card } from "@/components/ui/card"; 41 42 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 42 43 import { blockTypeLabels, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks"; 43 44 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; ··· 57 58 58 59 const blockCreationOrder: BlockType[] = ["TEXT", "SHORT_TEXT", "LONG_TEXT", "SINGLE_CHOICE", "MULTIPLE_CHOICE"]; 59 60 61 + function createChoiceOptionDrafts(options: string[]): ChoiceOptionDraft[] { 62 + return options.map((value) => ({ id: crypto.randomUUID(), value })); 63 + } 64 + 60 65 async function fetchJson<T>(url: string, init?: RequestInit) { 61 66 const response = await fetch(url, { 62 67 ...init, ··· 84 89 onSelect: (blockId: string) => void; 85 90 dragHandle: React.ReactNode; 86 91 }) { 87 - const Icon = blockIcons[block.type]; 88 - 89 92 return ( 90 93 <> 91 94 {dragHandle} 92 95 <button className="min-w-0 flex-1 text-left" type="button" onClick={() => onSelect(block.id)}> 93 - <div className="flex items-center justify-between gap-2 text-[var(--muted)]"> 94 - <div className="flex min-w-0 items-center gap-1.5"> 95 - <Icon className="size-3.5 shrink-0" /> 96 - <span className="truncate text-[10px] font-semibold uppercase tracking-[0.18em]">{blockTypeLabels[block.type]}</span> 97 - </div> 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)]"> 99 - {block.position + 1} 100 - </span> 96 + <div className="min-w-0 flex-1 text-sm font-medium leading-5 text-[var(--ink)]"> 97 + <span className="block truncate">{getBlockPreview(block)}</span> 101 98 </div> 102 - <p className="mt-1 truncate text-sm font-medium text-[var(--ink)]">{getBlockPreview(block)}</p> 103 99 </button> 104 100 </> 105 101 ); ··· 115 111 onSelect: (blockId: string) => void; 116 112 }) { 117 113 const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: block.id }); 114 + const Icon = blockIcons[block.type]; 118 115 119 116 return ( 120 117 <div ··· 124 121 transition, 125 122 }} 126 123 className={cn( 127 - "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 124 + "group flex items-center gap-2 rounded-[14px] px-2.5 py-2 transition", 128 125 selected 129 - ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 130 - : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 126 + ? "bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 127 + : "hover:bg-[var(--surface)]", 131 128 )} 132 129 > 133 130 <BlockRowInner ··· 135 132 onSelect={onSelect} 136 133 dragHandle={ 137 134 <button 138 - className="rounded-full border border-[color:var(--line)] p-1.5 text-[var(--muted)] transition hover:bg-[var(--accent-soft)]" 135 + className="relative inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-[color:var(--line)] text-[var(--muted)] transition hover:bg-[var(--accent-soft)]" 139 136 type="button" 137 + aria-label={`Drag ${getBlockPreview(block)}`} 140 138 {...attributes} 141 139 {...listeners} 142 140 > 143 - <GripVertical className="size-3.5" /> 141 + <Icon className="size-3 transition group-hover:opacity-0" /> 142 + <GripVertical className="absolute size-3.5 opacity-0 transition group-hover:opacity-100" /> 144 143 </button> 145 144 } 146 145 /> ··· 157 156 selected: boolean; 158 157 onSelect: (blockId: string) => void; 159 158 }) { 159 + const Icon = blockIcons[block.type]; 160 + 160 161 return ( 161 162 <div 162 163 className={cn( 163 - "group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition", 164 + "group flex items-center gap-2 rounded-[14px] px-2.5 py-2 transition", 164 165 selected 165 - ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 166 - : "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]", 166 + ? "bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]" 167 + : "hover:bg-[var(--surface)]", 167 168 )} 168 169 > 169 170 <BlockRowInner 170 171 block={block} 171 172 onSelect={onSelect} 172 173 dragHandle={ 173 - <div className="rounded-full border border-[color:var(--line)] p-1.5 text-[var(--muted)]"> 174 - <GripVertical className="size-3.5" /> 174 + <div className="relative inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-[color:var(--line)] text-[var(--muted)]"> 175 + <Icon className="size-3 transition group-hover:opacity-0" /> 176 + <GripVertical className="absolute size-3.5 opacity-0 transition group-hover:opacity-100" /> 175 177 </div> 176 178 } 177 179 /> ··· 199 201 slug: initialForm.slug, 200 202 }); 201 203 204 + const [isBlockMenuOpen, setIsBlockMenuOpen] = useState(false); 205 + const blockMenuRef = useRef<HTMLDivElement | null>(null); 206 + 202 207 const selectedBlock = useMemo( 203 208 () => (selection.kind === "block" ? form.blocks.find((block) => block.id === selection.blockId) ?? null : null), 204 209 [form.blocks, selection], 205 210 ); 206 211 207 212 const [blockDraft, setBlockDraft] = useState<BuilderBlock | null>(selectedBlock); 208 - const [choiceOptionsDraft, setChoiceOptionsDraft] = useState( 213 + const [choiceOptionsDraft, setChoiceOptionsDraft] = useState<ChoiceOptionDraft[]>( 209 214 selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 210 - ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n") 211 - : "", 215 + ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 216 + : [], 212 217 ); 213 218 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 214 219 ··· 230 235 setBlockDraft(selectedBlock); 231 236 setChoiceOptionsDraft( 232 237 selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 233 - ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n") 234 - : "", 238 + ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 239 + : [], 235 240 ); 236 241 }, [selectedBlock]); 237 242 238 243 useEffect(() => { 239 244 setIsDndReady(true); 240 245 }, []); 246 + 247 + useEffect(() => { 248 + function handlePointerDown(event: PointerEvent) { 249 + if (!blockMenuRef.current?.contains(event.target as Node)) { 250 + setIsBlockMenuOpen(false); 251 + } 252 + } 253 + 254 + if (!isBlockMenuOpen) { 255 + return; 256 + } 257 + 258 + window.addEventListener("pointerdown", handlePointerDown); 259 + return () => window.removeEventListener("pointerdown", handlePointerDown); 260 + }, [isBlockMenuOpen]); 241 261 242 262 function showToast(message: string, variant: ToastData["variant"] = "success") { 243 263 const id = crypto.randomUUID(); ··· 285 305 blocks: [...current.blocks, payload.block], 286 306 })); 287 307 setSelection({ kind: "block", blockId: payload.block.id }); 308 + setIsBlockMenuOpen(false); 288 309 showToast(`Added ${blockTypeLabels[type].toLowerCase()}`); 289 310 }); 290 311 } ··· 303 324 required: blockDraft.required, 304 325 config: 305 326 blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE" 306 - ? { options: choiceOptionsDraft.split("\n") } 327 + ? { options: choiceOptionsDraft.map((option) => option.value) } 307 328 : blockDraft.config, 308 329 }), 309 330 }); ··· 422 443 onTogglePublished={togglePublished} 423 444 /> 424 445 425 - <div className="grid gap-6 lg:grid-cols-[340px_minmax(0,1fr)]"> 426 - <Card className="p-5"> 427 - <div className="flex items-center justify-between gap-3"> 428 - <h2 className="font-display text-3xl text-[var(--ink)]">Blocks</h2> 429 - <Badge>{form.blocks.length} steps</Badge> 430 - </div> 446 + <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 447 + <aside className="border-b border-[color:var(--line)] pb-6 lg:sticky lg:top-8 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"> 448 + <div className="flex items-center justify-between gap-3"> 449 + <h2 className="font-display text-3xl text-[var(--ink)]">Blocks</h2> 450 + <div className="relative" ref={blockMenuRef}> 451 + <Button 452 + size="sm" 453 + onClick={() => setIsBlockMenuOpen((current) => !current)} 454 + aria-expanded={isBlockMenuOpen} 455 + aria-haspopup="menu" 456 + > 457 + <Plus className="size-4" /> 458 + New block 459 + </Button> 460 + {isBlockMenuOpen ? ( 461 + <div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-2 shadow-[var(--shadow-card)]"> 462 + <div className="grid gap-1"> 463 + {blockCreationOrder.map((type) => { 464 + const Icon = blockIcons[type]; 465 + 466 + return ( 467 + <button 468 + key={type} 469 + type="button" 470 + className="flex items-center gap-2 rounded-[12px] px-3 py-2 text-left text-sm font-medium text-[var(--ink)] transition hover:bg-[var(--surface)]" 471 + onClick={() => addBlock(type)} 472 + > 473 + {busy === `add-${type}` ? <LoaderCircle className="size-4 animate-spin" /> : <Icon className="size-4" />} 474 + {blockTypeLabels[type]} 475 + </button> 476 + ); 477 + })} 478 + </div> 479 + </div> 480 + ) : null} 481 + </div> 482 + </div> 431 483 432 - <div className="mt-5 space-y-3"> 433 - {isDndReady ? ( 434 - <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> 435 - <SortableContext items={form.blocks.map((block) => block.id)} strategy={verticalListSortingStrategy}> 436 - {form.blocks.map((block) => ( 437 - <SortableBlockRow 484 + <div className="mt-5 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto lg:pr-2"> 485 + <div className="space-y-1.5"> 486 + {isDndReady ? ( 487 + <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> 488 + <SortableContext items={form.blocks.map((block) => block.id)} strategy={verticalListSortingStrategy}> 489 + {form.blocks.map((block) => ( 490 + <SortableBlockRow 491 + key={block.id} 492 + block={block} 493 + selected={selection.kind === "block" && selection.blockId === block.id} 494 + onSelect={(blockId) => setSelection({ kind: "block", blockId })} 495 + /> 496 + ))} 497 + </SortableContext> 498 + </DndContext> 499 + ) : ( 500 + form.blocks.map((block) => ( 501 + <StaticBlockRow 438 502 key={block.id} 439 503 block={block} 440 504 selected={selection.kind === "block" && selection.blockId === block.id} 441 505 onSelect={(blockId) => setSelection({ kind: "block", blockId })} 442 506 /> 443 - ))} 444 - </SortableContext> 445 - </DndContext> 507 + )) 508 + )} 509 + </div> 510 + </div> 511 + 512 + </aside> 513 + 514 + <section className="min-w-0 border-t border-[color:var(--line)] pt-6 lg:min-h-full lg:border-t-0 lg:pt-0"> 515 + {selection.kind === "form" ? ( 516 + <FormSettingsPanel 517 + metadataDraft={metadataDraft} 518 + setMetadataDraft={setMetadataDraft} 519 + shareHref={shareHref} 520 + copyShareLink={copyShareLink} 521 + deleteForm={deleteForm} 522 + saveMetadata={saveMetadata} 523 + busy={busy} 524 + /> 525 + ) : blockDraft ? ( 526 + <BlockEditorPanel 527 + blockDraft={blockDraft} 528 + selectedBlockIcon={SelectedBlockIcon} 529 + choiceOptionsDraft={choiceOptionsDraft} 530 + setChoiceOptionsDraft={setChoiceOptionsDraft} 531 + setBlockDraft={setBlockDraft} 532 + deleteBlock={deleteBlock} 533 + saveBlock={saveBlock} 534 + busy={busy} 535 + /> 446 536 ) : ( 447 - form.blocks.map((block) => ( 448 - <StaticBlockRow 449 - key={block.id} 450 - block={block} 451 - selected={selection.kind === "block" && selection.blockId === block.id} 452 - onSelect={(blockId) => setSelection({ kind: "block", blockId })} 453 - /> 454 - )) 537 + <EmptyEditorState onAddShortText={() => addBlock("SHORT_TEXT")} /> 455 538 )} 456 - </div> 457 - 458 - <div className="mt-6 space-y-3 border-t border-[color:var(--line)] pt-6"> 459 - <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Add block</p> 460 - <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"> 461 - {blockCreationOrder.map((type) => { 462 - const Icon = blockIcons[type]; 463 - 464 - return ( 465 - <Button 466 - key={type} 467 - variant="secondary" 468 - size="sm" 469 - className="justify-start" 470 - onClick={() => addBlock(type)} 471 - > 472 - {busy === `add-${type}` ? <LoaderCircle className="size-4 animate-spin" /> : <Icon className="size-4" />} 473 - {blockTypeLabels[type]} 474 - </Button> 475 - ); 476 - })} 477 - </div> 478 - </div> 479 - </Card> 480 - 481 - <Card className="p-6 lg:p-8"> 482 - {selection.kind === "form" ? ( 483 - <FormSettingsPanel 484 - metadataDraft={metadataDraft} 485 - setMetadataDraft={setMetadataDraft} 486 - shareHref={shareHref} 487 - copyShareLink={copyShareLink} 488 - deleteForm={deleteForm} 489 - saveMetadata={saveMetadata} 490 - busy={busy} 491 - /> 492 - ) : blockDraft ? ( 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 - /> 503 - ) : ( 504 - <EmptyEditorState onAddShortText={() => addBlock("SHORT_TEXT")} /> 505 - )} 506 - </Card> 539 + </section> 507 540 </div> 508 541 </div> 509 542 </>
+5
lib/auth.ts
··· 18 18 GoogleProvider({ 19 19 clientId: process.env.AUTH_GOOGLE_ID ?? "", 20 20 clientSecret: process.env.AUTH_GOOGLE_SECRET ?? "", 21 + authorization: { 22 + params: { 23 + prompt: "select_account", 24 + }, 25 + }, 21 26 }), 22 27 ], 23 28 callbacks: {
+2
openspec/changes/archive/2026-04-09-refine-form-builder-layout/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-09
+83
openspec/changes/archive/2026-04-09-refine-form-builder-layout/design.md
··· 1 + ## Context 2 + 3 + The recent settings redesign established a cleaner creator UI language with fewer nested surfaces and less visual stacking, but the form builder still uses a heavier panel treatment. Because the builder is one of the most frequently used creator surfaces, the mismatch is now more noticeable. 4 + 5 + The current builder also lets the block list define page height. On long forms, the left column keeps extending downward while the right editor area often leaves large empty zones in the viewport, making the whole page feel unbalanced and forcing more scrolling than necessary. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Make the form builder feel visually closer to the newer settings UI. 11 + - Reduce unnecessary surface layering, nested cards, and decorative separation in the builder. 12 + - Keep the master-detail editing model intact. 13 + - Make the left block/navigation column scroll independently for long forms. 14 + - Preserve the existing editing, publishing, and response-review workflows. 15 + 16 + **Non-Goals:** 17 + - Changing the supported block types or editing capabilities. 18 + - Reworking form validation, persistence, or public runner behavior. 19 + - Turning the builder into a fully split-pane resizable layout. 20 + - Introducing a new design system or dependency. 21 + 22 + ## Decisions 23 + 24 + ### 1. Keep the builder as a two-column master-detail layout 25 + The builder should stay structurally familiar: block navigation on the left, detail editor on the right. The change is about better balance and calmer presentation, not a new interaction model. 26 + 27 + **Alternatives considered:** 28 + - Collapse into a single-column accordion/editor flow: rejected because it would slow down navigation for larger forms. 29 + - Move block navigation into a drawer: rejected because constant access to block order is central to the builder experience. 30 + 31 + ### 2. Make the left block list independently scrollable within the viewport 32 + Instead of letting the block list continue growing the whole page, the builder should constrain the navigation column and allow its list area to scroll independently. 33 + 34 + **Why:** 35 + - Long forms stay manageable. 36 + - The right editor panel remains meaningfully present while navigating deep block lists. 37 + - The page avoids becoming disproportionately tall. 38 + 39 + **Alternatives considered:** 40 + - Let the entire page scroll only: rejected because it creates large empty regions beside shorter editor panels. 41 + - Make both panes fixed-height and independently scrollable everywhere: rejected because the editor side should remain more natural for variable settings content. 42 + 43 + ### 3. Reduce nested surface treatments inside the builder 44 + The builder should remove or soften extra card-within-card patterns, excessive borders, and layered elevation where they are not helping comprehension. 45 + 46 + **Why:** 47 + - Matches the flatter, more intentional settings redesign. 48 + - Keeps focus on content hierarchy rather than container hierarchy. 49 + - Makes dense editing screens feel lighter. 50 + 51 + **Alternatives considered:** 52 + - Remove all panel distinction entirely: rejected because the builder still benefits from clear left/right structural separation. 53 + 54 + ### 4. Use section dividers and spacing before adding new containers 55 + Where the current UI uses stacked boxes for separation, prefer spacing, typographic hierarchy, and divider lines first. 56 + 57 + **Why:** 58 + - Produces a calmer editing surface. 59 + - Reduces visual clutter without losing structure. 60 + 61 + ### 5. Preserve mobile friendliness even if scroll behavior is desktop-oriented 62 + Independent pane scrolling should be primarily a desktop/tablet enhancement. On smaller screens, the builder should remain functional and avoid brittle fixed-height behavior. 63 + 64 + **Alternatives considered:** 65 + - Force the same constrained-height behavior on every breakpoint: rejected because it risks awkward small-screen interactions. 66 + 67 + ## Risks / Trade-offs 68 + 69 + - **Independent left-column scrolling can feel disconnected if not visually obvious** → Keep the navigation panel boundary clear and ensure the scroll region is naturally discoverable. 70 + - **Reducing layers too aggressively can make sections blur together** → Preserve strong hierarchy with headings, spacing, and selective borders. 71 + - **Viewport-constrained heights can be fragile with sticky headers or browser chrome** → Use resilient CSS sizing and test across common creator screen sizes. 72 + - **Long editor content may still require page scrolling** → Accept this; the primary problem to solve is runaway page height from the block list, not removing page scroll entirely. 73 + 74 + ## Migration Plan 75 + 76 + - No database or API migration is required. 77 + - Update builder layout and supporting components together so the new visual system and scroll behavior ship consistently. 78 + - If rollback is needed, the builder can revert to the previous layout without affecting saved data. 79 + 80 + ## Open Questions 81 + 82 + - Whether the left column should have a fixed max height or a viewport-relative sticky layout. Initial recommendation: viewport-relative constrained layout. 83 + - Whether add-block controls should remain inside the same scroll region as the block list or be pinned within the sidebar. Initial recommendation: keep them visible without excessive scrolling if practical.
+27
openspec/changes/archive/2026-04-09-refine-form-builder-layout/proposal.md
··· 1 + ## Why 2 + 3 + The settings redesign made creator surfaces feel calmer and more intentional, while the form builder still feels comparatively layered and visually heavy. The builder also becomes awkward on long forms because the left block list keeps extending the page while the right editing panel leaves large empty areas, making navigation and editing less efficient. 4 + 5 + ## What Changes 6 + 7 + - Refine the form builder visual hierarchy to reduce stacked surfaces, nested cards, and other overly layered treatments. 8 + - Bring the builder’s surface language closer to the newer settings UI so creator pages feel more consistent. 9 + - Keep the builder’s master-detail structure while simplifying how sections, panels, and controls are visually separated. 10 + - Make the left block list independently scrollable so large forms do not make the entire page excessively tall. 11 + - Keep the right editing panel usable alongside long block lists without leaving most of the viewport empty during deep scrolling. 12 + - Preserve existing builder capabilities for block editing, publishing, settings, and responses while improving layout behavior. 13 + 14 + ## Capabilities 15 + 16 + ### New Capabilities 17 + - None. 18 + 19 + ### Modified Capabilities 20 + - `conversational-form-builder`: Update builder layout behavior and presentation so it stays editing-focused, visually lighter, and more usable for long forms. 21 + - `minimal-ui-surfaces`: Extend the minimal-surface design approach to the builder so creator UI uses fewer layered containers and less decorative separation. 22 + 23 + ## Impact 24 + 25 + - Affected areas: builder layout, builder panel composition, block list scrolling behavior, and shared creator surface styling. 26 + - Likely code: `components/form-builder.tsx`, `components/form-builder-panels.tsx`, related UI components, and global surface tokens/styles. 27 + - No expected database or API changes.
+27
openspec/changes/archive/2026-04-09-refine-form-builder-layout/specs/conversational-form-builder/spec.md
··· 1 + ## MODIFIED Requirements 2 + 3 + ### Requirement: Builder exposes block editing through a master-detail layout 4 + The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. For long forms, the builder SHALL keep the navigation list usable without making the entire page grow unbounded from the block column alone. 5 + 6 + #### Scenario: Creator selects a block 7 + - **WHEN** an authenticated creator selects a block in the block list 8 + - **THEN** the system displays that block's editable settings in the editor panel 9 + 10 + #### Scenario: Creator works on a long form 11 + - **WHEN** an authenticated creator opens a form with many blocks 12 + - **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column 13 + 14 + ### Requirement: Builder chrome stays focused on editing 15 + The system SHALL present the creator builder with concise, editing-focused chrome that emphasizes form status, navigation, and editing actions. The builder SHALL avoid promotional hero copy, decorative overview treatments, and overly layered surface nesting that compete with the editing workflow. 16 + 17 + #### Scenario: Creator opens the builder 18 + - **WHEN** an authenticated creator opens an owned form in the builder 19 + - **THEN** the system shows the form title, current status, and core actions needed to edit, publish, copy the link, or review responses without marketing-style framing 20 + 21 + #### Scenario: Creator reads helper copy in the builder 22 + - **WHEN** an authenticated creator views builder headings, descriptions, or empty states 23 + - **THEN** the system uses short task-oriented copy that explains the current editing context without persuasive or ornamental language 24 + 25 + #### Scenario: Creator views builder surfaces 26 + - **WHEN** an authenticated creator uses the builder 27 + - **THEN** the system organizes the layout with restrained layering and clear section hierarchy rather than stacked decorative containers
+12
openspec/changes/archive/2026-04-09-refine-form-builder-layout/specs/minimal-ui-surfaces/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Builder uses restrained surface layering 4 + The system SHALL present the creator builder with a minimal surface hierarchy that prefers spacing, dividers, and clear typography over multiple nested cards or heavy stacked containers. 5 + 6 + #### Scenario: Creator compares builder sections 7 + - **WHEN** an authenticated creator views the builder navigation and editing areas 8 + - **THEN** the system separates major regions clearly without relying on excessive nested surface treatments 9 + 10 + #### Scenario: Creator scans dense builder content 11 + - **WHEN** an authenticated creator works through builder settings for a form with many blocks 12 + - **THEN** the builder maintains a calm, low-clutter presentation instead of compounding layered boxes within the editing flow
+25
openspec/changes/archive/2026-04-09-refine-form-builder-layout/tasks.md
··· 1 + ## 1. Layout structure 2 + 3 + - [x] 1.1 Refine the builder’s top-level layout to use a calmer, less layered visual structure. 4 + - [x] 1.2 Simplify nested builder panels and section containers where spacing or dividers can replace extra surfaces. 5 + - [x] 1.3 Align the builder’s visual language with the flatter settings UI while preserving master-detail editing. 6 + 7 + ## 2. Long-form builder behavior 8 + 9 + - [x] 2.1 Constrain the left builder column so the block list does not grow the entire page indefinitely. 10 + - [x] 2.2 Make the block list independently scrollable for forms with many blocks. 11 + - [x] 2.3 Keep add-block controls and block navigation usable within the updated sidebar behavior. 12 + - [x] 2.4 Verify the right editor panel still works naturally with variable-length settings content. 13 + 14 + ## 3. Builder polish 15 + 16 + - [x] 3.1 Review builder headings, dividers, and supporting copy so the screen feels concise and editing-focused. 17 + - [x] 3.2 Remove or soften decorative treatments that make the builder feel heavier than settings. 18 + - [x] 3.3 Ensure the updated layout remains usable on smaller screens without brittle fixed-height behavior. 19 + 20 + ## 4. Verification 21 + 22 + - [x] 4.1 Verify long forms no longer make the builder page excessively tall from the left column alone. 23 + - [x] 4.2 Verify block selection, reordering, adding, and settings editing still work in the new layout. 24 + - [x] 4.3 Verify the builder feels visually consistent with the settings redesign. 25 + - [x] 4.4 Run the production build and fix any layout or type issues introduced by the change.
+10 -2
openspec/specs/conversational-form-builder/spec.md
··· 31 31 - **THEN** the system removes that block from the current form structure 32 32 33 33 ### Requirement: Builder exposes block editing through a master-detail layout 34 - The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. 34 + The system SHALL present a builder interface with an ordered block list for navigation and reordering and a separate editor panel for the selected block's settings. For long forms, the builder SHALL keep the navigation list usable without making the entire page grow unbounded from the block column alone. 35 35 36 36 #### Scenario: Creator selects a block 37 37 - **WHEN** an authenticated creator selects a block in the block list 38 38 - **THEN** the system displays that block's editable settings in the editor panel 39 + 40 + #### Scenario: Creator works on a long form 41 + - **WHEN** an authenticated creator opens a form with many blocks 42 + - **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column 39 43 40 44 ### Requirement: Question blocks support required state and type-specific configuration 41 45 The system SHALL allow question blocks to be marked as required and SHALL support type-specific configuration for placeholders and choice options. Text blocks SHALL NOT be answerable and SHALL NOT expose a required setting. ··· 64 68 - **THEN** the system marks the form as unavailable for new public submissions 65 69 66 70 ### Requirement: Builder chrome stays focused on editing 67 - The system SHALL present the creator builder with concise, editing-focused chrome that emphasizes form status, navigation, and editing actions. The builder SHALL avoid promotional hero copy or decorative overview treatments that compete with the editing workflow. 71 + The system SHALL present the creator builder with concise, editing-focused chrome that emphasizes form status, navigation, and editing actions. The builder SHALL avoid promotional hero copy, decorative overview treatments, and overly layered surface nesting that compete with the editing workflow. 68 72 69 73 #### Scenario: Creator opens the builder 70 74 - **WHEN** an authenticated creator opens an owned form in the builder ··· 73 77 #### Scenario: Creator reads helper copy in the builder 74 78 - **WHEN** an authenticated creator views builder headings, descriptions, or empty states 75 79 - **THEN** the system uses short task-oriented copy that explains the current editing context without persuasive or ornamental language 80 + 81 + #### Scenario: Creator views builder surfaces 82 + - **WHEN** an authenticated creator uses the builder 83 + - **THEN** the system organizes the layout with restrained layering and clear section hierarchy rather than stacked decorative containers 76 84 77 85 ### Requirement: Creator can configure post-submission completion content 78 86 The system SHALL allow an authenticated creator to edit the completion content for an owned form, including a completion heading, supporting message, and an optional follow-up link label and URL.
+11
openspec/specs/minimal-ui-surfaces/spec.md
··· 25 25 #### Scenario: Creator has no forms yet 26 26 - **WHEN** an authenticated creator opens an empty dashboard 27 27 - **THEN** the system shows a concise empty state that explains the next action without sales-oriented or poetic product language 28 + 29 + ### Requirement: Builder uses restrained surface layering 30 + The system SHALL present the creator builder with a minimal surface hierarchy that prefers spacing, dividers, and clear typography over multiple nested cards or heavy stacked containers. 31 + 32 + #### Scenario: Creator compares builder sections 33 + - **WHEN** an authenticated creator views the builder navigation and editing areas 34 + - **THEN** the system separates major regions clearly without relying on excessive nested surface treatments 35 + 36 + #### Scenario: Creator scans dense builder content 37 + - **WHEN** an authenticated creator works through builder settings for a form with many blocks 38 + - **THEN** the builder maintains a calm, low-clutter presentation instead of compounding layered boxes within the editing flow