-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(creative-space): enhance Creative Space component #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,17 +2,115 @@ | |||||||||||
|
|
||||||||||||
| import Link from 'next/link' | ||||||||||||
| import { useCallback, useEffect, useState } from 'react' | ||||||||||||
| import { Clock, Loader2, Trash2 } from 'lucide-react' | ||||||||||||
| import { Button } from '@/components/ui/button' | ||||||||||||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' | ||||||||||||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' | ||||||||||||
| import { Input } from '@/components/ui/input' | ||||||||||||
| import { studentGlassCard } from '@/lib/student-glass-styles' | ||||||||||||
| import { cn } from '@/lib/utils' | ||||||||||||
|
|
||||||||||||
| type CreativeSpace = { | ||||||||||||
| id: string | ||||||||||||
| title: string | ||||||||||||
| updatedAt: string | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function formatUpdatedAt(iso: string) { | ||||||||||||
| const d = new Date(iso) | ||||||||||||
| if (Number.isNaN(d.getTime())) return iso | ||||||||||||
| return new Intl.DateTimeFormat(undefined, { | ||||||||||||
| dateStyle: 'medium', | ||||||||||||
| timeStyle: 'short', | ||||||||||||
| }).format(d) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const spaceRowSurface = | ||||||||||||
| 'group flex flex-col gap-4 rounded-2xl border p-4 sm:flex-row sm:items-stretch sm:justify-between sm:gap-6 sm:p-5 ' + | ||||||||||||
| 'border-slate-300/45 bg-white/[0.28] shadow-sm backdrop-blur-md transition-[box-shadow,transform] duration-200 ' + | ||||||||||||
| 'hover:shadow-md dark:border-white/15 dark:bg-white/[0.1] dark:shadow-none dark:hover:brightness-[1.02]' | ||||||||||||
|
|
||||||||||||
| const inlineNewRowSurface = | ||||||||||||
| 'flex flex-col gap-4 rounded-2xl border p-4 sm:flex-row sm:items-stretch sm:justify-between sm:gap-6 sm:p-5 ' + | ||||||||||||
| 'border-primary/35 bg-white/[0.34] shadow-sm ring-2 ring-primary/20 backdrop-blur-md dark:border-primary/30 dark:bg-white/[0.12] dark:ring-primary/25' | ||||||||||||
|
|
||||||||||||
| type CreativeSpaceRowProps = { | ||||||||||||
| space: CreativeSpace | ||||||||||||
| deleting: boolean | ||||||||||||
| deleteBusy: boolean | ||||||||||||
| onDelete: () => void | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function CreativeSpaceRow({ space, deleting, deleteBusy, onDelete }: CreativeSpaceRowProps) { | ||||||||||||
| const openDisabled = deleting | ||||||||||||
|
|
||||||||||||
| return ( | ||||||||||||
| <div className={cn('group flex flex-col gap-4', spaceRowSurface)}> | ||||||||||||
| <div className="flex min-w-0 flex-1 flex-col justify-center gap-2.5 text-left"> | ||||||||||||
| <h3 className="text-balance text-base font-semibold leading-snug tracking-tight text-gray-900 dark:text-gray-50 sm:text-lg"> | ||||||||||||
| {space.title} | ||||||||||||
| </h3> | ||||||||||||
| <div className="flex items-start gap-2.5 text-muted-foreground"> | ||||||||||||
| <Clock | ||||||||||||
| className="mt-0.5 size-4 shrink-0 text-slate-500 opacity-80 dark:text-gray-400" | ||||||||||||
| aria-hidden | ||||||||||||
| /> | ||||||||||||
| <div className="min-w-0 space-y-0.5"> | ||||||||||||
| <p className="text-[0.7rem] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"> | ||||||||||||
| Last updated | ||||||||||||
| </p> | ||||||||||||
| <time | ||||||||||||
| dateTime={space.updatedAt} | ||||||||||||
| className="block text-sm tabular-nums leading-snug text-gray-600 dark:text-gray-300 sm:text-[0.9375rem]" | ||||||||||||
| > | ||||||||||||
| {formatUpdatedAt(space.updatedAt)} | ||||||||||||
| </time> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| <div className="flex w-full shrink-0 flex-row items-stretch gap-2 sm:w-auto sm:items-center sm:border-l sm:border-slate-200/70 sm:pl-6 dark:sm:border-white/15"> | ||||||||||||
| {openDisabled ? ( | ||||||||||||
| <Button | ||||||||||||
| type="button" | ||||||||||||
| variant="hero" | ||||||||||||
| size="default" | ||||||||||||
| disabled | ||||||||||||
| className="auth-hero-cta h-11 min-h-11 flex-1 px-5 sm:min-w-[11.5rem]" | ||||||||||||
| > | ||||||||||||
| Open whiteboard | ||||||||||||
| </Button> | ||||||||||||
| ) : ( | ||||||||||||
| <Button | ||||||||||||
| asChild | ||||||||||||
| variant="hero" | ||||||||||||
| size="default" | ||||||||||||
| className="auth-hero-cta h-11 min-h-11 flex-1 px-5 sm:min-w-[11.5rem]" | ||||||||||||
| > | ||||||||||||
| <Link href={`/creative-space/${space.id}`}>Open whiteboard</Link> | ||||||||||||
| </Button> | ||||||||||||
| )} | ||||||||||||
| <Button | ||||||||||||
| type="button" | ||||||||||||
| variant="outline" | ||||||||||||
| size="icon" | ||||||||||||
| className="size-11 shrink-0 border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive" | ||||||||||||
| aria-label={`Delete “${space.title}”`} | ||||||||||||
| disabled={deleteBusy} | ||||||||||||
| onClick={() => void onDelete()} | ||||||||||||
| > | ||||||||||||
| {deleting ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />} | ||||||||||||
| </Button> | ||||||||||||
| </div> | ||||||||||||
| </div> | ||||||||||||
| ) | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function CreativeSpaceHomeClient() { | ||||||||||||
| const [spaces, setSpaces] = useState<CreativeSpace[]>([]) | ||||||||||||
| const [showCreateRow, setShowCreateRow] = useState(false) | ||||||||||||
| const [createTitle, setCreateTitle] = useState('') | ||||||||||||
| const [createSubmitting, setCreateSubmitting] = useState(false) | ||||||||||||
| const [createError, setCreateError] = useState<string | null>(null) | ||||||||||||
| const [deletingId, setDeletingId] = useState<string | null>(null) | ||||||||||||
|
|
||||||||||||
| const loadSpaces = useCallback(async () => { | ||||||||||||
| const res = await fetch('/api/creative-spaces') | ||||||||||||
|
|
@@ -28,48 +126,212 @@ export function CreativeSpaceHomeClient() { | |||||||||||
| return () => window.clearTimeout(t) | ||||||||||||
| }, [loadSpaces]) | ||||||||||||
|
|
||||||||||||
| const createSpace = useCallback(async () => { | ||||||||||||
| const name = window.prompt('Creative space name') | ||||||||||||
| if (!name) return | ||||||||||||
| const res = await fetch('/api/creative-spaces', { | ||||||||||||
| method: 'POST', | ||||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||||
| body: JSON.stringify({ title: name }), | ||||||||||||
| }) | ||||||||||||
| if (!res.ok) return | ||||||||||||
| await loadSpaces() | ||||||||||||
| const startInlineCreate = useCallback(() => { | ||||||||||||
| setCreateTitle('') | ||||||||||||
| setCreateError(null) | ||||||||||||
| setShowCreateRow(true) | ||||||||||||
| }, []) | ||||||||||||
|
|
||||||||||||
| const cancelInlineCreate = useCallback(() => { | ||||||||||||
| if (createSubmitting) return | ||||||||||||
| setShowCreateRow(false) | ||||||||||||
| setCreateTitle('') | ||||||||||||
| setCreateError(null) | ||||||||||||
| }, [createSubmitting]) | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| if (!showCreateRow) return | ||||||||||||
| const t = window.setTimeout(() => { | ||||||||||||
| document.getElementById('inline-create-space-name')?.focus() | ||||||||||||
| }, 0) | ||||||||||||
| return () => window.clearTimeout(t) | ||||||||||||
| }, [showCreateRow]) | ||||||||||||
|
|
||||||||||||
| useEffect(() => { | ||||||||||||
| if (!showCreateRow) return | ||||||||||||
| const onKey = (e: KeyboardEvent) => { | ||||||||||||
| if (e.key === 'Escape') cancelInlineCreate() | ||||||||||||
| } | ||||||||||||
| window.addEventListener('keydown', onKey) | ||||||||||||
| return () => window.removeEventListener('keydown', onKey) | ||||||||||||
| }, [showCreateRow, cancelInlineCreate]) | ||||||||||||
|
|
||||||||||||
| const submitCreateSpace = useCallback(async () => { | ||||||||||||
| const name = createTitle.trim() | ||||||||||||
| if (!name) { | ||||||||||||
| setCreateError('Enter a name for your space.') | ||||||||||||
| document.getElementById('inline-create-space-name')?.focus() | ||||||||||||
|
Comment on lines
+159
to
+163
|
||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| setCreateSubmitting(true) | ||||||||||||
| setCreateError(null) | ||||||||||||
| try { | ||||||||||||
| const res = await fetch('/api/creative-spaces', { | ||||||||||||
| method: 'POST', | ||||||||||||
| headers: { 'Content-Type': 'application/json' }, | ||||||||||||
| body: JSON.stringify({ title: name }), | ||||||||||||
| }) | ||||||||||||
| if (!res.ok) { | ||||||||||||
| setCreateError('Could not create the space. Try again.') | ||||||||||||
| return | ||||||||||||
| } | ||||||||||||
| setShowCreateRow(false) | ||||||||||||
| setCreateTitle('') | ||||||||||||
| await loadSpaces() | ||||||||||||
|
||||||||||||
| await loadSpaces() | |
| await loadSpaces() | |
| } catch (error) { | |
| console.error('Failed to create creative space', error) | |
| setCreateError('Could not create the space. Try again.') |
Copilot
AI
Apr 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
deleteSpace also lacks a catch path. If the DELETE request throws (network error) or returns a failure, the UI clears the spinner but provides no feedback, which can look like the action did nothing. Consider handling errors explicitly (e.g., surface an inline message/toast and/or keep the row disabled until acknowledgement).
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |||||||||
| "npm": ">=10.0.0" | ||||||||||
| }, | ||||||||||
| "scripts": { | ||||||||||
| "dev": "next dev", | ||||||||||
| "dev": "next dev --webpack", | ||||||||||
| "build": "next build --webpack", | ||||||||||
|
Comment on lines
+10
to
11
|
||||||||||
| "dev": "next dev --webpack", | |
| "build": "next build --webpack", | |
| "dev": "next dev", | |
| "build": "next build", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
spaceRowSurfacealready includesgroup flex flex-col gap-4, and those same classes are also passed again viacn('group flex flex-col gap-4', spaceRowSurface), resulting in duplicated Tailwind classes. Consider removing the duplicate from either the constant or thecn(...)call to keep the styling source of truth in one place.