Update route definitions to include archive routes and add new archive-related files

This commit is contained in:
Nikita
2025-12-21 15:04:40 -08:00
parent 103a4ba19c
commit c16440c876
8 changed files with 3903 additions and 57 deletions

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<typeof archives.$inferInsert> = {}
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",
},
}),
},
},
})

View File

@@ -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",
},
}),
},
},
})

View File

@@ -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<Archive | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
<div className="mx-auto max-w-4xl">
<div className="h-8 w-48 rounded bg-white/10 animate-pulse" />
<div className="mt-8 h-64 rounded-2xl bg-white/5 animate-pulse" />
</div>
</div>
)
}
if (error || !archive) {
return (
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
<div className="mx-auto max-w-4xl">
<Link
to="/archive"
className="inline-flex items-center gap-2 text-white/60 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to Archives
</Link>
<div className="mt-8 rounded-2xl border border-red-400/40 bg-red-500/10 p-8 text-center">
<p className="text-red-100">{error || "Archive not found"}</p>
<button
onClick={() => void loadArchive()}
className="mt-4 rounded-full border border-red-200/40 px-4 py-2 text-sm"
>
Retry
</button>
</div>
</div>
</div>
)
}
const TypeIcon = {
video: Video,
image: Image,
text: FileText,
}[archive.type] || FileText
return (
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
<div className="mx-auto max-w-4xl">
<Link
to="/archive"
className="inline-flex items-center gap-2 text-white/60 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to Archives
</Link>
<div className="mt-8">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-white/10">
<TypeIcon className="h-7 w-7 text-white/70" />
</div>
<div>
<h1 className="text-3xl font-semibold">{archive.title}</h1>
<div className="mt-2 flex items-center gap-3 text-sm text-white/60">
<span className="uppercase tracking-wider">{archive.type}</span>
<span></span>
<span>
{new Date(archive.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleToggleVisibility}
className={`flex items-center gap-2 rounded-full px-4 py-2 text-sm transition ${
archive.is_public
? "bg-green-500/20 text-green-300 hover:bg-green-500/30"
: "bg-white/10 text-white/70 hover:bg-white/20"
}`}
>
{archive.is_public ? (
<>
<Globe className="h-4 w-4" />
Public
</>
) : (
<>
<Lock className="h-4 w-4" />
Private
</>
)}
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 rounded-full bg-red-500/20 px-4 py-2 text-sm text-red-300 hover:bg-red-500/30"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
{archive.description && (
<p className="mt-6 text-white/70">{archive.description}</p>
)}
{/* Content area */}
<div className="mt-8 rounded-2xl border border-white/10 bg-white/5 p-8">
{archive.type === "video" && (
<VideoContent archive={archive} />
)}
{archive.type === "image" && (
<ImageContent archive={archive} />
)}
{archive.type === "text" && (
<TextContent archive={archive} />
)}
</div>
{/* Metadata */}
{archive.file_size_bytes && archive.file_size_bytes > 0 && (
<div className="mt-4 text-sm text-white/50">
Size: {formatBytes(archive.file_size_bytes)}
{archive.duration_seconds && (
<span className="ml-4">
Duration: {formatDuration(archive.duration_seconds)}
</span>
)}
</div>
)}
</div>
</div>
{/* Delete confirmation modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-[#0a0f1c] p-6 text-center">
<Trash2 className="mx-auto h-12 w-12 text-red-400" />
<h2 className="mt-4 text-xl font-semibold">Delete Archive?</h2>
<p className="mt-2 text-white/60">
This action cannot be undone. All content will be permanently
deleted.
</p>
<div className="mt-6 flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 rounded-xl border border-white/10 px-4 py-3 text-sm text-white/70 hover:bg-white/5"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="flex-1 rounded-xl bg-red-500 px-4 py-3 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function VideoContent({ archive }: { archive: Archive }) {
if (!archive.content_url) {
return (
<div className="flex flex-col items-center justify-center py-12 text-white/50">
<Video className="h-16 w-16 mb-4" />
<p>No video uploaded yet</p>
<button className="mt-4 rounded-full border border-white/20 px-6 py-2 text-sm hover:bg-white/5">
Upload Video
</button>
</div>
)
}
return (
<video
src={archive.content_url}
controls
className="w-full rounded-xl"
poster={archive.thumbnail_url ?? undefined}
/>
)
}
function ImageContent({ archive }: { archive: Archive }) {
if (!archive.content_url) {
return (
<div className="flex flex-col items-center justify-center py-12 text-white/50">
<Image className="h-16 w-16 mb-4" />
<p>No image uploaded yet</p>
<button className="mt-4 rounded-full border border-white/20 px-6 py-2 text-sm hover:bg-white/5">
Upload Image
</button>
</div>
)
}
return (
<img
src={archive.content_url}
alt={archive.title}
className="w-full rounded-xl"
/>
)
}
function TextContent({ archive }: { archive: Archive }) {
if (!archive.content_text) {
return (
<div className="flex flex-col items-center justify-center py-12 text-white/50">
<FileText className="h-16 w-16 mb-4" />
<p>No text content yet</p>
<button className="mt-4 rounded-full border border-white/20 px-6 py-2 text-sm hover:bg-white/5">
Add Content
</button>
</div>
)
}
return (
<div className="prose prose-invert max-w-none">
<pre className="whitespace-pre-wrap font-sans text-white/80">
{archive.content_text}
</pre>
</div>
)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, "0")}`
}

View File

@@ -0,0 +1,352 @@
import { useCallback, useEffect, useState } from "react"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import type { Archive } from "@/db/schema"
import { Plus, Video, Image, FileText, Lock, Globe } from "lucide-react"
export const Route = createFileRoute("/archive")({
ssr: false,
component: ArchivePage,
})
type ArchiveType = "video" | "image" | "text"
function ArchivePage() {
const [archives, setArchives] = useState<Archive[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [hasAccess, setHasAccess] = useState(true)
const navigate = useNavigate()
const loadArchives = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch("/api/archives")
const data = await response.json()
if (!response.ok) {
if (response.status === 403) {
setHasAccess(false)
return
}
throw new Error(data.error || "Failed to load archives")
}
setArchives(data.archives || [])
setHasAccess(true)
} catch (err) {
console.error("[archive] failed to load archives", err)
setError("Failed to load archives")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadArchives()
}, [loadArchives])
const handleCreateArchive = async (
title: string,
type: ArchiveType,
description?: string
) => {
setCreating(true)
setError(null)
try {
const response = await fetch("/api/archives", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, type, description }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to create archive")
}
setArchives((prev) => [data.archive, ...prev])
setShowCreateModal(false)
} catch (err) {
console.error("[archive] failed to create archive", err)
setError(err instanceof Error ? err.message : "Failed to create archive")
} finally {
setCreating(false)
}
}
if (!hasAccess) {
return <UpgradePrompt />
}
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">
Archive
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
My Archives
</h1>
<p className="mt-3 max-w-2xl text-sm text-white/60">
Store videos, images, and text privately. Share what you want.
</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>
)}
<button
type="button"
className="flex items-center gap-2 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={() => setShowCreateModal(true)}
disabled={creating}
>
<Plus className="h-4 w-4" />
New Archive
</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 loadArchives()}
>
Retry
</button>
</div>
)}
{loading && archives.length === 0 ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="h-48 rounded-2xl border border-white/5 bg-white/5 animate-pulse"
/>
))}
</div>
) : archives.length > 0 ? (
<ArchiveGrid archives={archives} />
) : (
!loading && (
<div className="rounded-3xl border border-dashed border-white/20 bg-white/5 px-10 py-12 text-center text-white/70">
<p className="text-lg font-semibold">No archives yet</p>
<p className="mt-2 text-sm">
Create your first archive to start storing content.
</p>
<button
type="button"
className="mt-6 flex items-center gap-2 mx-auto rounded-full border border-white/40 px-5 py-2 text-xs font-semibold uppercase tracking-[0.3em]"
onClick={() => setShowCreateModal(true)}
disabled={creating}
>
<Plus className="h-4 w-4" />
Create your first archive
</button>
</div>
)
)}
</div>
{showCreateModal && (
<CreateArchiveModal
onClose={() => setShowCreateModal(false)}
onCreate={handleCreateArchive}
creating={creating}
/>
)}
</div>
)
}
function ArchiveGrid({ archives }: { archives: Archive[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{archives.map((archive) => (
<ArchiveCard key={archive.id} archive={archive} />
))}
</div>
)
}
function ArchiveCard({ archive }: { archive: Archive }) {
const TypeIcon = {
video: Video,
image: Image,
text: FileText,
}[archive.type] || FileText
const createdAt = new Date(archive.created_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
return (
<Link
to="/archive/$archiveId"
params={{ archiveId: archive.id }}
className="group relative flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 shadow-xl transition hover:-translate-y-1 hover:border-white/30"
>
<div className="flex items-start justify-between">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/10">
<TypeIcon className="h-6 w-6 text-white/70" />
</div>
<div className="flex items-center gap-1 text-white/50">
{archive.is_public ? (
<Globe className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</div>
</div>
<div className="mt-4 flex-1">
<h3 className="font-semibold text-white">{archive.title}</h3>
{archive.description && (
<p className="mt-1 text-sm text-white/60 line-clamp-2">
{archive.description}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-between text-xs text-white/50">
<span className="uppercase tracking-wider">{archive.type}</span>
<span>{createdAt}</span>
</div>
</Link>
)
}
function CreateArchiveModal({
onClose,
onCreate,
creating,
}: {
onClose: () => void
onCreate: (title: string, type: ArchiveType, description?: string) => void
creating: boolean
}) {
const [title, setTitle] = useState("")
const [type, setType] = useState<ArchiveType>("video")
const [description, setDescription] = useState("")
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
onCreate(title.trim(), type, description.trim() || undefined)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#0a0f1c] p-6">
<h2 className="text-xl font-semibold text-white">Create Archive</h2>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="block text-sm text-white/70 mb-2">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 focus:border-white/30 focus:outline-none"
placeholder="My Archive"
required
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-2">Type</label>
<div className="flex gap-2">
{(["video", "image", "text"] as ArchiveType[]).map((t) => {
const Icon = { video: Video, image: Image, text: FileText }[t]
return (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`flex flex-1 items-center justify-center gap-2 rounded-xl border px-4 py-3 text-sm capitalize transition ${
type === t
? "border-white/30 bg-white/10 text-white"
: "border-white/10 bg-white/5 text-white/60 hover:border-white/20"
}`}
>
<Icon className="h-4 w-4" />
{t}
</button>
)
})}
</div>
</div>
<div>
<label className="block text-sm text-white/70 mb-2">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/40 focus:border-white/30 focus:outline-none resize-none"
placeholder="What's this archive about?"
rows={3}
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-xl border border-white/10 px-4 py-3 text-sm text-white/70 hover:bg-white/5"
>
Cancel
</button>
<button
type="submit"
disabled={creating || !title.trim()}
className="flex-1 rounded-xl bg-white/90 px-4 py-3 text-sm font-semibold text-slate-900 hover:bg-white disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</form>
</div>
</div>
)
}
function UpgradePrompt() {
return (
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white flex items-center justify-center">
<div className="max-w-md text-center">
<div className="mx-auto h-16 w-16 rounded-2xl bg-white/10 flex items-center justify-center mb-6">
<Lock className="h-8 w-8 text-white/70" />
</div>
<h1 className="text-2xl font-semibold">Archive Access Required</h1>
<p className="mt-3 text-white/60">
Archive is a premium feature for storing and sharing your content.
Upgrade to access video, image, and text storage.
</p>
<div className="mt-8 flex flex-col gap-3">
<Link
to="/"
className="rounded-full border border-white/20 px-6 py-3 text-sm hover:bg-white/5"
>
Go Home
</Link>
</div>
</div>
</div>
)
}

2843
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff