Skip to content

Commit 5787ad3

Browse files
authored
Merge pull request #21 from Z4phxr/styling/creative_space_dashboard_match
refactor(creative-space): enhance Creative Space component
2 parents ad44de1 + 8fd4215 commit 5787ad3

2 files changed

Lines changed: 300 additions & 38 deletions

File tree

LearningPlatform/components/student/creative-space-home-client.tsx

Lines changed: 299 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,115 @@
22

33
import Link from 'next/link'
44
import { useCallback, useEffect, useState } from 'react'
5+
import { Clock, Loader2, Trash2 } from 'lucide-react'
56
import { 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

812
type 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+
14107
export 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
}

LearningPlatform/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"npm": ">=10.0.0"
88
},
99
"scripts": {
10-
"dev": "next dev",
10+
"dev": "next dev --webpack",
1111
"build": "next build --webpack",
1212
"start": "next start -H 0.0.0.0 -p ${PORT:-10000}",
1313
"postinstall": "prisma generate",

0 commit comments

Comments
 (0)