mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-27 02:38:45 +02:00
Update route definitions to include archive routes and add new archive-related files
This commit is contained in:
183
packages/web/src/routes/api/archives.$archiveId.ts
Normal file
183
packages/web/src/routes/api/archives.$archiveId.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
121
packages/web/src/routes/api/archives.ts
Normal file
121
packages/web/src/routes/api/archives.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
350
packages/web/src/routes/archive.$archiveId.tsx
Normal file
350
packages/web/src/routes/archive.$archiveId.tsx
Normal 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")}`
|
||||
}
|
||||
352
packages/web/src/routes/archive.tsx
Normal file
352
packages/web/src/routes/archive.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user