diff --git a/.ai/commit-checkpoints.json b/.ai/commit-checkpoints.json index 02cb3a23..dde312f3 100644 --- a/.ai/commit-checkpoints.json +++ b/.ai/commit-checkpoints.json @@ -1,7 +1,7 @@ { "last_commit": { - "timestamp": "2025-12-21T20:02:45.816734+00:00", - "session_id": "019b4281-5d8b-7270-9f5b-9baaa3ab5b47", - "last_entry_timestamp": "2025-12-21T20:02:42.811Z" + "timestamp": "2025-12-21T22:56:32.894190+00:00", + "session_id": "019b431c-d0bc-7511-a9ac-47633a3ec88c", + "last_entry_timestamp": "2025-12-21T22:56:29.923Z" } } \ No newline at end of file diff --git a/packages/web/package.json b/packages/web/package.json index 45ebe85a..1c545963 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -41,6 +41,8 @@ "drizzle-zod": "^0.8.3", "framer-motion": "^12.23.25", "hls.js": "^1.6.15", + "jazz-react": "^0.14.28", + "jazz-tools": "^0.19.13", "lucide-react": "^0.556.0", "postgres": "^3.4.7", "react": "^19.2.1", diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index d7225c95..5ff88bd8 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -18,11 +18,13 @@ import { Route as ChatRouteImport } from './routes/chat' import { Route as CanvasRouteImport } from './routes/canvas' import { Route as BlocksRouteImport } from './routes/blocks' import { Route as AuthRouteImport } from './routes/auth' +import { Route as ArchiveRouteImport } from './routes/archive' import { Route as UsernameRouteImport } from './routes/$username' import { Route as IndexRouteImport } from './routes/index' import { Route as CanvasIndexRouteImport } from './routes/canvas.index' import { Route as I1focusDemoRouteImport } from './routes/i.1focus-demo' import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId' +import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveId' import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events' import { Route as ApiStreamRouteImport } from './routes/api/stream' @@ -32,6 +34,7 @@ import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads' import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages' import { Route as ApiCanvasRouteImport } from './routes/api/canvas' import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions' +import { Route as ApiArchivesRouteImport } from './routes/api/archives' import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs' import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request' import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names' @@ -48,6 +51,7 @@ import { Route as ApiCanvasImagesRouteImport } from './routes/api/canvas.images' import { Route as ApiCanvasCanvasIdRouteImport } from './routes/api/canvas.$canvasId' import { Route as ApiBrowserSessionsSessionIdRouteImport } from './routes/api/browser-sessions.$sessionId' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +import { Route as ApiArchivesArchiveIdRouteImport } from './routes/api/archives.$archiveId' import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index' import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode' import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr' @@ -100,6 +104,11 @@ const AuthRoute = AuthRouteImport.update({ path: '/auth', getParentRoute: () => rootRouteImport, } as any) +const ArchiveRoute = ArchiveRouteImport.update({ + id: '/archive', + path: '/archive', + getParentRoute: () => rootRouteImport, +} as any) const UsernameRoute = UsernameRouteImport.update({ id: '/$username', path: '/$username', @@ -125,6 +134,11 @@ const CanvasCanvasIdRoute = CanvasCanvasIdRouteImport.update({ path: '/$canvasId', getParentRoute: () => CanvasRoute, } as any) +const ArchiveArchiveIdRoute = ArchiveArchiveIdRouteImport.update({ + id: '/$archiveId', + path: '/$archiveId', + getParentRoute: () => ArchiveRoute, +} as any) const ApiUsersRoute = ApiUsersRouteImport.update({ id: '/api/users', path: '/api/users', @@ -170,6 +184,11 @@ const ApiBrowserSessionsRoute = ApiBrowserSessionsRouteImport.update({ path: '/api/browser-sessions', getParentRoute: () => rootRouteImport, } as any) +const ApiArchivesRoute = ApiArchivesRouteImport.update({ + id: '/api/archives', + path: '/api/archives', + getParentRoute: () => rootRouteImport, +} as any) const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({ id: '/demo/start/server-funcs', path: '/demo/start/server-funcs', @@ -251,6 +270,11 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ path: '/api/auth/$', getParentRoute: () => rootRouteImport, } as any) +const ApiArchivesArchiveIdRoute = ApiArchivesArchiveIdRouteImport.update({ + id: '/$archiveId', + path: '/$archiveId', + getParentRoute: () => ApiArchivesRoute, +} as any) const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({ id: '/demo/start/ssr/', path: '/demo/start/ssr/', @@ -286,6 +310,7 @@ const ApiCanvasImagesImageIdGenerateRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$username': typeof UsernameRoute + '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute '/canvas': typeof CanvasRouteWithChildren @@ -295,6 +320,7 @@ export interface FileRoutesByFullPath { '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute '/users': typeof UsersRoute + '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -304,9 +330,11 @@ export interface FileRoutesByFullPath { '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/archive/$archiveId': typeof ArchiveArchiveIdRoute '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas/': typeof CanvasIndexRoute + '/api/archives/$archiveId': typeof ApiArchivesArchiveIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute '/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute @@ -333,6 +361,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/$username': typeof UsernameRoute + '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute '/chat': typeof ChatRoute @@ -341,6 +370,7 @@ export interface FileRoutesByTo { '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute '/users': typeof UsersRoute + '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -350,9 +380,11 @@ export interface FileRoutesByTo { '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/archive/$archiveId': typeof ArchiveArchiveIdRoute '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas': typeof CanvasIndexRoute + '/api/archives/$archiveId': typeof ApiArchivesArchiveIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute '/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute @@ -380,6 +412,7 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/$username': typeof UsernameRoute + '/archive': typeof ArchiveRouteWithChildren '/auth': typeof AuthRoute '/blocks': typeof BlocksRoute '/canvas': typeof CanvasRouteWithChildren @@ -389,6 +422,7 @@ export interface FileRoutesById { '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute '/users': typeof UsersRoute + '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren '/api/canvas': typeof ApiCanvasRouteWithChildren '/api/chat-messages': typeof ApiChatMessagesRoute @@ -398,9 +432,11 @@ export interface FileRoutesById { '/api/stream': typeof ApiStreamRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren + '/archive/$archiveId': typeof ArchiveArchiveIdRoute '/canvas/$canvasId': typeof CanvasCanvasIdRoute '/i/1focus-demo': typeof I1focusDemoRoute '/canvas/': typeof CanvasIndexRoute + '/api/archives/$archiveId': typeof ApiArchivesArchiveIdRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute '/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute @@ -429,6 +465,7 @@ export interface FileRouteTypes { fullPaths: | '/' | '/$username' + | '/archive' | '/auth' | '/blocks' | '/canvas' @@ -438,6 +475,7 @@ export interface FileRouteTypes { | '/sessions' | '/settings' | '/users' + | '/api/archives' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -447,9 +485,11 @@ export interface FileRouteTypes { | '/api/stream' | '/api/usage-events' | '/api/users' + | '/archive/$archiveId' | '/canvas/$canvasId' | '/i/1focus-demo' | '/canvas/' + | '/api/archives/$archiveId' | '/api/auth/$' | '/api/browser-sessions/$sessionId' | '/api/canvas/$canvasId' @@ -476,6 +516,7 @@ export interface FileRouteTypes { to: | '/' | '/$username' + | '/archive' | '/auth' | '/blocks' | '/chat' @@ -484,6 +525,7 @@ export interface FileRouteTypes { | '/sessions' | '/settings' | '/users' + | '/api/archives' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -493,9 +535,11 @@ export interface FileRouteTypes { | '/api/stream' | '/api/usage-events' | '/api/users' + | '/archive/$archiveId' | '/canvas/$canvasId' | '/i/1focus-demo' | '/canvas' + | '/api/archives/$archiveId' | '/api/auth/$' | '/api/browser-sessions/$sessionId' | '/api/canvas/$canvasId' @@ -522,6 +566,7 @@ export interface FileRouteTypes { | '__root__' | '/' | '/$username' + | '/archive' | '/auth' | '/blocks' | '/canvas' @@ -531,6 +576,7 @@ export interface FileRouteTypes { | '/sessions' | '/settings' | '/users' + | '/api/archives' | '/api/browser-sessions' | '/api/canvas' | '/api/chat-messages' @@ -540,9 +586,11 @@ export interface FileRouteTypes { | '/api/stream' | '/api/usage-events' | '/api/users' + | '/archive/$archiveId' | '/canvas/$canvasId' | '/i/1focus-demo' | '/canvas/' + | '/api/archives/$archiveId' | '/api/auth/$' | '/api/browser-sessions/$sessionId' | '/api/canvas/$canvasId' @@ -570,6 +618,7 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute UsernameRoute: typeof UsernameRoute + ArchiveRoute: typeof ArchiveRouteWithChildren AuthRoute: typeof AuthRoute BlocksRoute: typeof BlocksRoute CanvasRoute: typeof CanvasRouteWithChildren @@ -579,6 +628,7 @@ export interface RootRouteChildren { SessionsRoute: typeof SessionsRoute SettingsRoute: typeof SettingsRoute UsersRoute: typeof UsersRoute + ApiArchivesRoute: typeof ApiArchivesRouteWithChildren ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren ApiCanvasRoute: typeof ApiCanvasRouteWithChildren ApiChatMessagesRoute: typeof ApiChatMessagesRoute @@ -671,6 +721,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthRouteImport parentRoute: typeof rootRouteImport } + '/archive': { + id: '/archive' + path: '/archive' + fullPath: '/archive' + preLoaderRoute: typeof ArchiveRouteImport + parentRoute: typeof rootRouteImport + } '/$username': { id: '/$username' path: '/$username' @@ -706,6 +763,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CanvasCanvasIdRouteImport parentRoute: typeof CanvasRoute } + '/archive/$archiveId': { + id: '/archive/$archiveId' + path: '/$archiveId' + fullPath: '/archive/$archiveId' + preLoaderRoute: typeof ArchiveArchiveIdRouteImport + parentRoute: typeof ArchiveRoute + } '/api/users': { id: '/api/users' path: '/api/users' @@ -769,6 +833,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiBrowserSessionsRouteImport parentRoute: typeof rootRouteImport } + '/api/archives': { + id: '/api/archives' + path: '/api/archives' + fullPath: '/api/archives' + preLoaderRoute: typeof ApiArchivesRouteImport + parentRoute: typeof rootRouteImport + } '/demo/start/server-funcs': { id: '/demo/start/server-funcs' path: '/demo/start/server-funcs' @@ -881,6 +952,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiAuthSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/archives/$archiveId': { + id: '/api/archives/$archiveId' + path: '/$archiveId' + fullPath: '/api/archives/$archiveId' + preLoaderRoute: typeof ApiArchivesArchiveIdRouteImport + parentRoute: typeof ApiArchivesRoute + } '/demo/start/ssr/': { id: '/demo/start/ssr/' path: '/demo/start/ssr' @@ -926,6 +1004,17 @@ declare module '@tanstack/react-router' { } } +interface ArchiveRouteChildren { + ArchiveArchiveIdRoute: typeof ArchiveArchiveIdRoute +} + +const ArchiveRouteChildren: ArchiveRouteChildren = { + ArchiveArchiveIdRoute: ArchiveArchiveIdRoute, +} + +const ArchiveRouteWithChildren = + ArchiveRoute._addFileChildren(ArchiveRouteChildren) + interface CanvasRouteChildren { CanvasCanvasIdRoute: typeof CanvasCanvasIdRoute CanvasIndexRoute: typeof CanvasIndexRoute @@ -939,6 +1028,18 @@ const CanvasRouteChildren: CanvasRouteChildren = { const CanvasRouteWithChildren = CanvasRoute._addFileChildren(CanvasRouteChildren) +interface ApiArchivesRouteChildren { + ApiArchivesArchiveIdRoute: typeof ApiArchivesArchiveIdRoute +} + +const ApiArchivesRouteChildren: ApiArchivesRouteChildren = { + ApiArchivesArchiveIdRoute: ApiArchivesArchiveIdRoute, +} + +const ApiArchivesRouteWithChildren = ApiArchivesRoute._addFileChildren( + ApiArchivesRouteChildren, +) + interface ApiBrowserSessionsRouteChildren { ApiBrowserSessionsSessionIdRoute: typeof ApiBrowserSessionsSessionIdRoute } @@ -1017,6 +1118,7 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, UsernameRoute: UsernameRoute, + ArchiveRoute: ArchiveRouteWithChildren, AuthRoute: AuthRoute, BlocksRoute: BlocksRoute, CanvasRoute: CanvasRouteWithChildren, @@ -1026,6 +1128,7 @@ const rootRouteChildren: RootRouteChildren = { SessionsRoute: SessionsRoute, SettingsRoute: SettingsRoute, UsersRoute: UsersRoute, + ApiArchivesRoute: ApiArchivesRouteWithChildren, ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren, ApiCanvasRoute: ApiCanvasRouteWithChildren, ApiChatMessagesRoute: ApiChatMessagesRoute, diff --git a/packages/web/src/routes/api/archives.$archiveId.ts b/packages/web/src/routes/api/archives.$archiveId.ts new file mode 100644 index 00000000..10404cab --- /dev/null +++ b/packages/web/src/routes/api/archives.$archiveId.ts @@ -0,0 +1,183 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { requireFeatureAccess, hasFeatureAccess } from "@/lib/access" +import { db } from "@/db/connection" +import { archives } from "@/db/schema" +import { eq, and } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +// GET /api/archives/:archiveId - Get single archive +const handleGet = async ({ + request, + params, +}: { + request: Request + params: { archiveId: string } +}) => { + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + const database = db() + + try { + const [archive] = await database + .select() + .from(archives) + .where(eq(archives.id, params.archiveId)) + .limit(1) + + if (!archive) { + return json({ error: "Archive not found" }, 404) + } + + // Check access based on ownership and visibility + const isOwner = session?.user?.id === archive.user_id + + if (isOwner) { + // Owner can always view their own archives + const accessError = await requireFeatureAccess(request, "archive_view_own") + if (accessError) return accessError + } else if (archive.is_public) { + // Anyone can view public archives + const hasAccess = await hasFeatureAccess(request, "archive_view_public") + if (!hasAccess) { + return json({ error: "Unauthorized" }, 401) + } + } else { + // Private archive, not owner + return json({ error: "Archive not found" }, 404) + } + + return json({ archive }) + } catch (error) { + console.error("[archives] Error fetching archive:", error) + return json({ error: "Failed to fetch archive" }, 500) + } +} + +// PATCH /api/archives/:archiveId - Update archive +const handlePatch = async ({ + request, + params, +}: { + request: Request + params: { archiveId: string } +}) => { + const accessError = await requireFeatureAccess(request, "archive_create") + if (accessError) return accessError + + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return json({ error: "Unauthorized" }, 401) + } + + const body = await request.json() + const database = db() + + try { + // Check ownership + const [existing] = await database + .select() + .from(archives) + .where( + and( + eq(archives.id, params.archiveId), + eq(archives.user_id, session.user.id) + ) + ) + .limit(1) + + if (!existing) { + return json({ error: "Archive not found" }, 404) + } + + // Update allowed fields + const updateData: Partial = {} + if (body.title !== undefined) updateData.title = body.title + if (body.description !== undefined) updateData.description = body.description + if (body.content_url !== undefined) updateData.content_url = body.content_url + if (body.content_text !== undefined) updateData.content_text = body.content_text + if (body.thumbnail_url !== undefined) updateData.thumbnail_url = body.thumbnail_url + if (body.is_public !== undefined) updateData.is_public = body.is_public + updateData.updated_at = new Date() + + const [updated] = await database + .update(archives) + .set(updateData) + .where(eq(archives.id, params.archiveId)) + .returning() + + return json({ archive: updated }) + } catch (error) { + console.error("[archives] Error updating archive:", error) + return json({ error: "Failed to update archive" }, 500) + } +} + +// DELETE /api/archives/:archiveId - Delete archive +const handleDelete = async ({ + request, + params, +}: { + request: Request + params: { archiveId: string } +}) => { + const accessError = await requireFeatureAccess(request, "archive_create") + if (accessError) return accessError + + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return json({ error: "Unauthorized" }, 401) + } + + const database = db() + + try { + // Check ownership and delete + const [deleted] = await database + .delete(archives) + .where( + and( + eq(archives.id, params.archiveId), + eq(archives.user_id, session.user.id) + ) + ) + .returning() + + if (!deleted) { + return json({ error: "Archive not found" }, 404) + } + + return json({ success: true }) + } catch (error) { + console.error("[archives] Error deleting archive:", error) + return json({ error: "Failed to delete archive" }, 500) + } +} + +export const Route = createFileRoute("/api/archives/$archiveId")({ + server: { + handlers: { + GET: handleGet, + PATCH: handlePatch, + DELETE: handleDelete, + OPTIONS: () => + new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }), + }, + }, +}) diff --git a/packages/web/src/routes/api/archives.ts b/packages/web/src/routes/api/archives.ts new file mode 100644 index 00000000..c13dd1b3 --- /dev/null +++ b/packages/web/src/routes/api/archives.ts @@ -0,0 +1,121 @@ +import { createFileRoute } from "@tanstack/react-router" +import { getAuth } from "@/lib/auth" +import { requireFeatureAccess } from "@/lib/access" +import { db } from "@/db/connection" +import { archives } from "@/db/schema" +import { eq, desc, and } from "drizzle-orm" + +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }) + +// GET /api/archives - List user's archives +const handleGet = async ({ request }: { request: Request }) => { + // Check feature access + const accessError = await requireFeatureAccess(request, "archive_view_own") + if (accessError) return accessError + + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return json({ error: "Unauthorized" }, 401) + } + + const database = db() + + try { + const userArchives = await database + .select() + .from(archives) + .where(eq(archives.user_id, session.user.id)) + .orderBy(desc(archives.created_at)) + + return json({ archives: userArchives }) + } catch (error) { + console.error("[archives] Error fetching archives:", error) + return json({ error: "Failed to fetch archives" }, 500) + } +} + +// POST /api/archives - Create new archive +const handlePost = async ({ request }: { request: Request }) => { + // Check feature access + const accessError = await requireFeatureAccess(request, "archive_create") + if (accessError) return accessError + + const auth = getAuth() + const session = await auth.api.getSession({ headers: request.headers }) + + if (!session?.user) { + return json({ error: "Unauthorized" }, 401) + } + + const body = await request.json() + const { title, description, type, content_url, content_text, thumbnail_url, file_size_bytes, duration_seconds, mime_type, is_public } = body as { + title: string + description?: string + type: "video" | "image" | "text" + content_url?: string + content_text?: string + thumbnail_url?: string + file_size_bytes?: number + duration_seconds?: number + mime_type?: string + is_public?: boolean + } + + if (!title || !type) { + return json({ error: "Title and type are required" }, 400) + } + + if (!["video", "image", "text"].includes(type)) { + return json({ error: "Type must be video, image, or text" }, 400) + } + + const database = db() + + try { + const [newArchive] = await database + .insert(archives) + .values({ + user_id: session.user.id, + title, + description, + type, + content_url, + content_text, + thumbnail_url, + file_size_bytes: file_size_bytes ?? 0, + duration_seconds, + mime_type, + is_public: is_public ?? false, + }) + .returning() + + return json({ archive: newArchive }, 201) + } catch (error) { + console.error("[archives] Error creating archive:", error) + return json({ error: "Failed to create archive" }, 500) + } +} + +export const Route = createFileRoute("/api/archives")({ + server: { + handlers: { + GET: handleGet, + POST: handlePost, + OPTIONS: () => + new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }), + }, + }, +}) diff --git a/packages/web/src/routes/archive.$archiveId.tsx b/packages/web/src/routes/archive.$archiveId.tsx new file mode 100644 index 00000000..3ad1e4f5 --- /dev/null +++ b/packages/web/src/routes/archive.$archiveId.tsx @@ -0,0 +1,350 @@ +import { useCallback, useEffect, useState } from "react" +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" +import type { Archive } from "@/db/schema" +import { + ArrowLeft, + Video, + Image, + FileText, + Lock, + Globe, + Trash2, + Edit3, + Share2, +} from "lucide-react" + +export const Route = createFileRoute("/archive/$archiveId")({ + ssr: false, + component: ArchiveDetailPage, +}) + +function ArchiveDetailPage() { + const { archiveId } = Route.useParams() + const navigate = useNavigate() + + const [archive, setArchive] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [deleting, setDeleting] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [isEditing, setIsEditing] = useState(false) + + const loadArchive = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await fetch(`/api/archives/${archiveId}`) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to load archive") + } + + setArchive(data.archive) + } catch (err) { + console.error("[archive] failed to load archive", err) + setError(err instanceof Error ? err.message : "Failed to load archive") + } finally { + setLoading(false) + } + }, [archiveId]) + + useEffect(() => { + void loadArchive() + }, [loadArchive]) + + const handleDelete = async () => { + setDeleting(true) + try { + const response = await fetch(`/api/archives/${archiveId}`, { + method: "DELETE", + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to delete archive") + } + + navigate({ to: "/archive" }) + } catch (err) { + console.error("[archive] failed to delete", err) + setError(err instanceof Error ? err.message : "Failed to delete archive") + } finally { + setDeleting(false) + setShowDeleteConfirm(false) + } + } + + const handleToggleVisibility = async () => { + if (!archive) return + + try { + const response = await fetch(`/api/archives/${archiveId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_public: !archive.is_public }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to update visibility") + } + + setArchive(data.archive) + } catch (err) { + console.error("[archive] failed to toggle visibility", err) + setError(err instanceof Error ? err.message : "Failed to update") + } + } + + if (loading) { + return ( +
+
+
+
+
+
+ ) + } + + if (error || !archive) { + return ( +
+
+ + + Back to Archives + +
+

{error || "Archive not found"}

+ +
+
+
+ ) + } + + const TypeIcon = { + video: Video, + image: Image, + text: FileText, + }[archive.type] || FileText + + return ( +
+
+ + + Back to Archives + + +
+
+
+
+ +
+
+

{archive.title}

+
+ {archive.type} + • + + {new Date(archive.created_at).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + })} + +
+
+
+ +
+ + + +
+
+ + {archive.description && ( +

{archive.description}

+ )} + + {/* Content area */} +
+ {archive.type === "video" && ( + + )} + {archive.type === "image" && ( + + )} + {archive.type === "text" && ( + + )} +
+ + {/* Metadata */} + {archive.file_size_bytes && archive.file_size_bytes > 0 && ( +
+ Size: {formatBytes(archive.file_size_bytes)} + {archive.duration_seconds && ( + + Duration: {formatDuration(archive.duration_seconds)} + + )} +
+ )} +
+
+ + {/* Delete confirmation modal */} + {showDeleteConfirm && ( +
+
+ +

Delete Archive?

+

+ This action cannot be undone. All content will be permanently + deleted. +

+
+ + +
+
+
+ )} +
+ ) +} + +function VideoContent({ archive }: { archive: Archive }) { + if (!archive.content_url) { + return ( +
+
+ ) + } + + return ( +