mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
|
|
|
import {
|
|
createCanvasProject,
|
|
fetchCanvasList,
|
|
} from "@/lib/canvas/client"
|
|
import type {
|
|
SerializedCanvas,
|
|
SerializedCanvasSummary,
|
|
} from "@/lib/canvas/types"
|
|
|
|
export const Route = createFileRoute("/canvas/")({
|
|
ssr: false,
|
|
component: CanvasProjectsPage,
|
|
})
|
|
|
|
function summarize(snapshot: SerializedCanvas): SerializedCanvasSummary {
|
|
return {
|
|
canvas: snapshot.canvas,
|
|
previewImage: snapshot.images[0] ?? null,
|
|
imageCount: snapshot.images.length,
|
|
}
|
|
}
|
|
|
|
function CanvasProjectsPage() {
|
|
const [projects, setProjects] = useState<SerializedCanvasSummary[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
const navigate = useNavigate()
|
|
|
|
const loadProjects = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const data = await fetchCanvasList()
|
|
setProjects(data)
|
|
} catch (err) {
|
|
console.error("[canvas] failed to load projects", err)
|
|
setError("Failed to load projects")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
void loadProjects()
|
|
}, [loadProjects])
|
|
|
|
const handleCreateProject = useCallback(async () => {
|
|
if (creating) {
|
|
return
|
|
}
|
|
setCreating(true)
|
|
setError(null)
|
|
try {
|
|
const snapshot = await createCanvasProject()
|
|
const summary = summarize(snapshot)
|
|
setProjects((prev) => [summary, ...prev])
|
|
navigate({ to: "/canvas/$canvasId", params: { canvasId: snapshot.canvas.id } })
|
|
} catch (err) {
|
|
console.error("[canvas] failed to create project", err)
|
|
setError("Unable to create a new project")
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}, [creating, navigate])
|
|
|
|
const showSkeletonGrid = loading && projects.length === 0
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
|
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
|
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-white/5 pb-6">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-white/50">
|
|
Canvas
|
|
</p>
|
|
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
|
|
My Projects
|
|
</h1>
|
|
<p className="mt-3 max-w-2xl text-sm text-white/60">
|
|
Choose a canvas to continue exploring ideas. Each project preserves
|
|
its own layout, prompts, and styles.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2 text-sm text-white/60">
|
|
{loading ? (
|
|
<span className="text-xs uppercase tracking-[0.3em] text-white/40">
|
|
Loading…
|
|
</span>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="rounded-full bg-white/90 px-5 py-2 text-sm font-semibold uppercase tracking-[0.3em] text-slate-900 transition hover:bg-white disabled:cursor-not-allowed disabled:bg-white/40"
|
|
onClick={handleCreateProject}
|
|
disabled={creating}
|
|
>
|
|
{creating ? "Creating" : "New Project"}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{error ? (
|
|
<div className="flex items-center justify-between rounded-xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
|
<span>{error}</span>
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-red-200/40 px-3 py-1 text-xs uppercase tracking-[0.2em]"
|
|
onClick={() => void loadProjects()}
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{showSkeletonGrid ? (
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, index) => (
|
|
<div
|
|
key={index}
|
|
className="h-64 rounded-3xl border border-white/5 bg-white/5/50 animate-pulse"
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<ProjectsGrid projects={projects} />
|
|
)}
|
|
|
|
{!projects.length && !loading ? (
|
|
<div className="rounded-3xl border border-dashed border-white/20 bg-white/5/20 px-10 py-12 text-center text-white/70">
|
|
<p className="text-lg font-semibold">You don't have any projects yet.</p>
|
|
<p className="mt-2 text-sm">
|
|
Start a new canvas to begin planning, brainstorming, or designing.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="mt-6 rounded-full border border-white/40 px-5 py-2 text-xs font-semibold uppercase tracking-[0.3em]"
|
|
onClick={handleCreateProject}
|
|
disabled={creating}
|
|
>
|
|
{creating ? "Creating…" : "Create your first project"}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProjectsGrid({
|
|
projects,
|
|
}: {
|
|
projects: SerializedCanvasSummary[]
|
|
}) {
|
|
if (!projects.length) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{projects.map((project) => (
|
|
<CanvasProjectCard key={project.canvas.id} project={project} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CanvasProjectCard({
|
|
project,
|
|
}: {
|
|
project: SerializedCanvasSummary
|
|
}) {
|
|
const previewUrl = useMemo(() => {
|
|
const preview = project.previewImage
|
|
if (!preview) {
|
|
return null
|
|
}
|
|
if (preview.imageUrl) {
|
|
return preview.imageUrl
|
|
}
|
|
if (preview.imageData) {
|
|
const mime =
|
|
preview.metadata && typeof preview.metadata.mimeType === "string"
|
|
? (preview.metadata.mimeType as string)
|
|
: "image/png"
|
|
return `data:${mime};base64,${preview.imageData}`
|
|
}
|
|
return null
|
|
}, [project.previewImage])
|
|
|
|
const imageCountLabel = project.imageCount === 1 ? "image" : "images"
|
|
const updatedAt = useMemo(() => {
|
|
const date = new Date(project.canvas.updatedAt)
|
|
return date.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
})
|
|
}, [project.canvas.updatedAt])
|
|
|
|
return (
|
|
<Link
|
|
to="/canvas/$canvasId"
|
|
params={{ canvasId: project.canvas.id }}
|
|
className="group relative flex h-64 flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl ring-1 ring-white/5 transition hover:-translate-y-1 hover:border-white/30 hover:ring-white/20"
|
|
>
|
|
<div className="relative flex-1 bg-gradient-to-br from-slate-800 via-slate-900 to-black">
|
|
{previewUrl ? (
|
|
<div
|
|
className="absolute inset-0 bg-cover bg-center transition duration-500 group-hover:scale-105"
|
|
style={{ backgroundImage: `url(${previewUrl})` }}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-xs uppercase tracking-[0.3em] text-white/40">
|
|
No preview yet
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent" />
|
|
</div>
|
|
<div className="relative z-10 space-y-1 px-4 pb-4 pt-3">
|
|
<div className="flex items-center justify-between text-xs uppercase tracking-[0.3em] text-white/60">
|
|
<span>{updatedAt}</span>
|
|
<span>
|
|
{project.imageCount} {imageCountLabel}
|
|
</span>
|
|
</div>
|
|
<p className="text-lg font-semibold text-white">
|
|
{project.canvas.name}
|
|
</p>
|
|
<p className="text-xs text-white/60">
|
|
Tap to open canvas
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|