22
33import Link from 'next/link'
44import { useCallback , useEffect , useState } from 'react'
5+ import { Clock , Loader2 , Trash2 } from 'lucide-react'
56import { Button } from '@/components/ui/button'
6- import { Card , CardContent , CardHeader , CardTitle } from '@/components/ui/card'
7+ import { Card , CardContent , CardDescription , CardHeader , CardTitle } from '@/components/ui/card'
8+ import { Input } from '@/components/ui/input'
9+ import { studentGlassCard } from '@/lib/student-glass-styles'
10+ import { cn } from '@/lib/utils'
711
812type CreativeSpace = {
913 id : string
1014 title : string
1115 updatedAt : string
1216}
1317
18+ function formatUpdatedAt ( iso : string ) {
19+ const d = new Date ( iso )
20+ if ( Number . isNaN ( d . getTime ( ) ) ) return iso
21+ return new Intl . DateTimeFormat ( undefined , {
22+ dateStyle : 'medium' ,
23+ timeStyle : 'short' ,
24+ } ) . format ( d )
25+ }
26+
27+ const spaceRowSurface =
28+ '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 ' +
29+ 'border-slate-300/45 bg-white/[0.28] shadow-sm backdrop-blur-md transition-[box-shadow,transform] duration-200 ' +
30+ 'hover:shadow-md dark:border-white/15 dark:bg-white/[0.1] dark:shadow-none dark:hover:brightness-[1.02]'
31+
32+ const inlineNewRowSurface =
33+ '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 ' +
34+ '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'
35+
36+ type CreativeSpaceRowProps = {
37+ space : CreativeSpace
38+ deleting : boolean
39+ deleteBusy : boolean
40+ onDelete : ( ) => void
41+ }
42+
43+ function CreativeSpaceRow ( { space, deleting, deleteBusy, onDelete } : CreativeSpaceRowProps ) {
44+ const openDisabled = deleting
45+
46+ return (
47+ < div className = { cn ( 'group flex flex-col gap-4' , spaceRowSurface ) } >
48+ < div className = "flex min-w-0 flex-1 flex-col justify-center gap-2.5 text-left" >
49+ < h3 className = "text-balance text-base font-semibold leading-snug tracking-tight text-gray-900 dark:text-gray-50 sm:text-lg" >
50+ { space . title }
51+ </ h3 >
52+ < div className = "flex items-start gap-2.5 text-muted-foreground" >
53+ < Clock
54+ className = "mt-0.5 size-4 shrink-0 text-slate-500 opacity-80 dark:text-gray-400"
55+ aria-hidden
56+ />
57+ < div className = "min-w-0 space-y-0.5" >
58+ < p className = "text-[0.7rem] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400" >
59+ Last updated
60+ </ p >
61+ < time
62+ dateTime = { space . updatedAt }
63+ className = "block text-sm tabular-nums leading-snug text-gray-600 dark:text-gray-300 sm:text-[0.9375rem]"
64+ >
65+ { formatUpdatedAt ( space . updatedAt ) }
66+ </ time >
67+ </ div >
68+ </ div >
69+ </ div >
70+ < 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" >
71+ { openDisabled ? (
72+ < Button
73+ type = "button"
74+ variant = "hero"
75+ size = "default"
76+ disabled
77+ className = "auth-hero-cta h-11 min-h-11 flex-1 px-5 sm:min-w-[11.5rem]"
78+ >
79+ Open whiteboard
80+ </ Button >
81+ ) : (
82+ < Button
83+ asChild
84+ variant = "hero"
85+ size = "default"
86+ className = "auth-hero-cta h-11 min-h-11 flex-1 px-5 sm:min-w-[11.5rem]"
87+ >
88+ < Link href = { `/creative-space/${ space . id } ` } > Open whiteboard</ Link >
89+ </ Button >
90+ ) }
91+ < Button
92+ type = "button"
93+ variant = "outline"
94+ size = "icon"
95+ className = "size-11 shrink-0 border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
96+ aria-label = { `Delete “${ space . title } ”` }
97+ disabled = { deleteBusy }
98+ onClick = { ( ) => void onDelete ( ) }
99+ >
100+ { deleting ? < Loader2 className = "size-4 animate-spin" /> : < Trash2 className = "size-4" /> }
101+ </ Button >
102+ </ div >
103+ </ div >
104+ )
105+ }
106+
14107export function CreativeSpaceHomeClient ( ) {
15108 const [ spaces , setSpaces ] = useState < CreativeSpace [ ] > ( [ ] )
109+ const [ showCreateRow , setShowCreateRow ] = useState ( false )
110+ const [ createTitle , setCreateTitle ] = useState ( '' )
111+ const [ createSubmitting , setCreateSubmitting ] = useState ( false )
112+ const [ createError , setCreateError ] = useState < string | null > ( null )
113+ const [ deletingId , setDeletingId ] = useState < string | null > ( null )
16114
17115 const loadSpaces = useCallback ( async ( ) => {
18116 const res = await fetch ( '/api/creative-spaces' )
@@ -28,48 +126,212 @@ export function CreativeSpaceHomeClient() {
28126 return ( ) => window . clearTimeout ( t )
29127 } , [ loadSpaces ] )
30128
31- const createSpace = useCallback ( async ( ) => {
32- const name = window . prompt ( 'Creative space name' )
33- if ( ! name ) return
34- const res = await fetch ( '/api/creative-spaces' , {
35- method : 'POST' ,
36- headers : { 'Content-Type' : 'application/json' } ,
37- body : JSON . stringify ( { title : name } ) ,
38- } )
39- if ( ! res . ok ) return
40- await loadSpaces ( )
129+ const startInlineCreate = useCallback ( ( ) => {
130+ setCreateTitle ( '' )
131+ setCreateError ( null )
132+ setShowCreateRow ( true )
133+ } , [ ] )
134+
135+ const cancelInlineCreate = useCallback ( ( ) => {
136+ if ( createSubmitting ) return
137+ setShowCreateRow ( false )
138+ setCreateTitle ( '' )
139+ setCreateError ( null )
140+ } , [ createSubmitting ] )
141+
142+ useEffect ( ( ) => {
143+ if ( ! showCreateRow ) return
144+ const t = window . setTimeout ( ( ) => {
145+ document . getElementById ( 'inline-create-space-name' ) ?. focus ( )
146+ } , 0 )
147+ return ( ) => window . clearTimeout ( t )
148+ } , [ showCreateRow ] )
149+
150+ useEffect ( ( ) => {
151+ if ( ! showCreateRow ) return
152+ const onKey = ( e : KeyboardEvent ) => {
153+ if ( e . key === 'Escape' ) cancelInlineCreate ( )
154+ }
155+ window . addEventListener ( 'keydown' , onKey )
156+ return ( ) => window . removeEventListener ( 'keydown' , onKey )
157+ } , [ showCreateRow , cancelInlineCreate ] )
158+
159+ const submitCreateSpace = useCallback ( async ( ) => {
160+ const name = createTitle . trim ( )
161+ if ( ! name ) {
162+ setCreateError ( 'Enter a name for your space.' )
163+ document . getElementById ( 'inline-create-space-name' ) ?. focus ( )
164+ return
165+ }
166+ setCreateSubmitting ( true )
167+ setCreateError ( null )
168+ try {
169+ const res = await fetch ( '/api/creative-spaces' , {
170+ method : 'POST' ,
171+ headers : { 'Content-Type' : 'application/json' } ,
172+ body : JSON . stringify ( { title : name } ) ,
173+ } )
174+ if ( ! res . ok ) {
175+ setCreateError ( 'Could not create the space. Try again.' )
176+ return
177+ }
178+ setShowCreateRow ( false )
179+ setCreateTitle ( '' )
180+ await loadSpaces ( )
181+ } finally {
182+ setCreateSubmitting ( false )
183+ }
184+ } , [ createTitle , loadSpaces ] )
185+
186+ const deleteSpace = useCallback ( async ( id : string ) => {
187+ setDeletingId ( id )
188+ try {
189+ const res = await fetch ( `/api/creative-spaces/${ encodeURIComponent ( id ) } ` , { method : 'DELETE' } )
190+ if ( ! res . ok ) return
191+ await loadSpaces ( )
192+ } finally {
193+ setDeletingId ( null )
194+ }
41195 } , [ loadSpaces ] )
42196
197+ const listHasContent = spaces . length > 0 || showCreateRow
198+
43199 return (
44- < div className = "container mx-auto max-w-6xl space-y-6 px-4 py-6 md:px-6" >
45- < Card className = "py-4" >
46- < CardHeader className = "flex flex-row items-center justify-between pb-3" >
47- < CardTitle className = "text-xl" > Creative Spaces</ CardTitle >
48- < Button onClick = { ( ) => void createSpace ( ) } > Create Creative Space</ Button >
49- </ CardHeader >
50- < CardContent className = "space-y-2" >
51- { spaces . length === 0 ? (
52- < p className = "text-sm text-muted-foreground" > No creative spaces yet.</ p >
53- ) : (
54- spaces . map ( ( space ) => (
55- < div
56- key = { space . id }
57- className = "flex items-center justify-between rounded-md border border-border/70 bg-card px-3 py-2"
200+ < div className = "container mx-auto px-5 py-7 md:px-6 md:py-8" >
201+ < div className = "mx-auto w-full max-w-5xl space-y-8" >
202+ < section className = "w-full space-y-4" >
203+ < div className = "flex w-full items-center" >
204+ < div className = "flex-1 text-center" >
205+ < h2 className = "text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 md:text-4xl" >
206+ Creative Spaces
207+ </ h2 >
208+ < p className = "mt-2 text-base leading-relaxed text-gray-600 dark:text-gray-400 md:text-lg" >
209+ Open a board or start a new canvas for notes, decks, and sketches.
210+ </ p >
211+ </ div >
212+ </ div >
213+
214+ < Card className = { cn ( 'border-0 shadow-none' , studentGlassCard ) } >
215+ < CardHeader className = "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0" >
216+ < div className = "space-y-1.5 text-center sm:text-left" >
217+ < p className = "text-[0.65rem] font-semibold uppercase tracking-widest text-muted-foreground" >
218+ Your boards
219+ </ p >
220+ < CardTitle className = "text-lg font-bold tracking-tight text-foreground sm:text-xl" >
221+ All creative spaces
222+ </ CardTitle >
223+ < CardDescription className = "text-sm leading-relaxed text-muted-foreground sm:text-[0.9375rem]" >
224+ Each space is its own whiteboard. Create as many as you need.
225+ </ CardDescription >
226+ </ div >
227+ < Button
228+ type = "button"
229+ variant = "hero"
230+ className = "auth-hero-cta w-full shrink-0 sm:w-auto"
231+ onClick = { startInlineCreate }
232+ disabled = { showCreateRow }
58233 >
59- < div >
60- < p className = "text-sm font-semibold" > { space . title } </ p >
61- < p className = "text-xs text-muted-foreground" >
62- Updated { new Date ( space . updatedAt ) . toLocaleString ( ) }
234+ Create creative space
235+ </ Button >
236+ </ CardHeader >
237+ < CardContent className = "space-y-4 pt-0 sm:pt-0" >
238+ { ! listHasContent ? (
239+ < div className = "flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-white/30 bg-white/10 px-4 py-10 text-center backdrop-blur-lg dark:border-white/20 dark:bg-white/10" >
240+ < p className = "max-w-md text-base leading-relaxed text-gray-600 dark:text-gray-400 md:text-lg" >
241+ No creative spaces yet. Create one to open the whiteboard.
63242 </ p >
243+ < Button type = "button" variant = "hero" className = "auth-hero-cta" onClick = { startInlineCreate } >
244+ Create creative space
245+ </ Button >
64246 </ div >
65- < Button asChild variant = "outline" size = "sm" >
66- < Link href = { `/creative-space/${ space . id } ` } > Open Whiteboard</ Link >
67- </ Button >
68- </ div >
69- ) )
70- ) }
71- </ CardContent >
72- </ Card >
247+ ) : (
248+ < >
249+ { spaces . length === 0 && showCreateRow ? (
250+ < p className = "text-center text-sm text-muted-foreground sm:text-left" >
251+ Name your first space, then save to open the whiteboard.
252+ </ p >
253+ ) : null }
254+
255+ { showCreateRow ? (
256+ < form
257+ className = { inlineNewRowSurface }
258+ onSubmit = { ( e ) => {
259+ e . preventDefault ( )
260+ void submitCreateSpace ( )
261+ } }
262+ >
263+ < div className = "flex min-w-0 flex-1 flex-col justify-center gap-3 text-left" >
264+ < div className = "space-y-1.5" >
265+ < p className = "text-[0.7rem] font-semibold uppercase tracking-wider text-primary" >
266+ New creative space
267+ </ p >
268+ </ div >
269+ < Input
270+ id = "inline-create-space-name"
271+ name = "title"
272+ autoComplete = "off"
273+ placeholder = "e.g. Data science intro"
274+ value = { createTitle }
275+ onChange = { ( e ) => {
276+ setCreateTitle ( e . target . value )
277+ if ( createError ) setCreateError ( null )
278+ } }
279+ disabled = { createSubmitting }
280+ aria-invalid = { createError ? true : undefined }
281+ aria-describedby = { createError ? 'inline-create-error' : undefined }
282+ maxLength = { 120 }
283+ className = "h-10 bg-white/50 text-base dark:bg-black/20 sm:max-w-xl"
284+ />
285+ { createError ? (
286+ < p id = "inline-create-error" className = "text-sm text-destructive" >
287+ { createError }
288+ </ p >
289+ ) : null }
290+ </ div >
291+ < div className = "flex shrink-0 flex-col gap-2 sm:items-stretch sm:justify-center sm:border-l sm:border-slate-200/70 sm:pl-6 dark:sm:border-white/15" >
292+ < Button
293+ type = "submit"
294+ variant = "hero"
295+ className = "auth-hero-cta h-auto min-h-11 w-full px-5 sm:min-w-[10.5rem]"
296+ disabled = { createSubmitting || ! createTitle . trim ( ) }
297+ >
298+ { createSubmitting ? (
299+ < >
300+ < Loader2 className = "mr-2 h-4 w-4 animate-spin" />
301+ Saving…
302+ </ >
303+ ) : (
304+ 'Save'
305+ ) }
306+ </ Button >
307+ < Button
308+ type = "button"
309+ variant = "outline"
310+ className = "w-full sm:min-w-[10.5rem]"
311+ onClick = { cancelInlineCreate }
312+ disabled = { createSubmitting }
313+ >
314+ Cancel
315+ </ Button >
316+ </ div >
317+ </ form >
318+ ) : null }
319+
320+ { spaces . map ( ( space ) => (
321+ < CreativeSpaceRow
322+ key = { space . id }
323+ space = { space }
324+ deleting = { deletingId === space . id }
325+ deleteBusy = { deletingId !== null }
326+ onDelete = { ( ) => void deleteSpace ( space . id ) }
327+ />
328+ ) ) }
329+ </ >
330+ ) }
331+ </ CardContent >
332+ </ Card >
333+ </ section >
334+ </ div >
73335 </ div >
74336 )
75337}
0 commit comments