diff --git a/vibn-frontend/app/api/projects/[projectId]/file/route.ts b/vibn-frontend/app/api/projects/[projectId]/file/route.ts index 4e6306c..0bc1396 100644 --- a/vibn-frontend/app/api/projects/[projectId]/file/route.ts +++ b/vibn-frontend/app/api/projects/[projectId]/file/route.ts @@ -5,12 +5,12 @@ * Response for directory: { type: "dir", items: [{ name, path, type }] } * Response for file: { type: "file", content: string, encoding: "utf8" | "base64" } */ -import { NextResponse } from 'next/server'; +import { NextResponse } from "next/server"; import { authSession } from "@/lib/auth/session-server"; -import { query } from '@/lib/db-postgres'; +import { query } from "@/lib/db-postgres"; -const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; -const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ''; +const GITEA_API_URL = process.env.GITEA_API_URL ?? "https://git.vibnai.com"; +const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? ""; async function giteaGet(path: string) { const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, { @@ -21,87 +21,145 @@ async function giteaGet(path: string) { return res.json(); } +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "ico", +]); + const BINARY_EXTENSIONS = new Set([ - 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', - 'woff', 'woff2', 'ttf', 'eot', - 'zip', 'tar', 'gz', 'pdf', + "woff", + "woff2", + "ttf", + "eot", + "zip", + "tar", + "gz", + "pdf", + "mp4", + "webm", + "mov", ]); function isBinary(name: string): boolean { - const ext = name.split('.').pop()?.toLowerCase() ?? ''; + const ext = name.split(".").pop()?.toLowerCase() ?? ""; return BINARY_EXTENSIONS.has(ext); } export async function GET( req: Request, - { params }: { params: Promise<{ projectId: string }> } + { params }: { params: Promise<{ projectId: string }> }, ) { try { const { projectId } = await params; const session = await authSession(); if (!session?.user?.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { searchParams } = new URL(req.url); - const filePath = searchParams.get('path') ?? ''; + const filePath = searchParams.get("path") ?? ""; // Verify ownership + get giteaRepo const rows = await query<{ data: Record }>( `SELECT p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`, - [projectId, session.user.email] + [projectId, session.user.email], ); if (rows.length === 0) { - return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + return NextResponse.json({ error: "Project not found" }, { status: 404 }); } const giteaRepo = rows[0].data?.giteaRepo as string | undefined; if (!giteaRepo) { - return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 }); + return NextResponse.json( + { error: "No Gitea repo connected" }, + { status: 404 }, + ); } - const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : ''; + const encodedPath = filePath + ? encodeURIComponent(filePath).replace(/%2F/g, "/") + : ""; const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`; const data = await giteaGet(apiPath); // Directory listing if (Array.isArray(data)) { const items = data - .map((item: { name: string; path: string; type: string; size?: number }) => ({ - name: item.name, - path: item.path, - type: item.type, // "file" | "dir" | "symlink" - size: item.size, - })) + .map( + (item: { + name: string; + path: string; + type: string; + size?: number; + }) => ({ + name: item.name, + path: item.path, + type: item.type, // "file" | "dir" | "symlink" + size: item.size, + }), + ) .sort((a, b) => { // Dirs first - if (a.type === 'dir' && b.type !== 'dir') return -1; - if (a.type !== 'dir' && b.type === 'dir') return 1; + if (a.type === "dir" && b.type !== "dir") return -1; + if (a.type !== "dir" && b.type === "dir") return 1; return a.name.localeCompare(b.name); }); - return NextResponse.json({ type: 'dir', items }); + return NextResponse.json({ type: "dir", items }); } // Single file - const item = data as { name: string; content?: string; encoding?: string; size?: number }; + const item = data as { + name: string; + content?: string; + encoding?: string; + size?: number; + }; + const raw = item.content ?? ""; + const ext = item.name.split(".").pop()?.toLowerCase() ?? ""; + + if (IMAGE_EXTENSIONS.has(ext)) { + return NextResponse.json({ + type: "file", + content: raw, + encoding: "base64", + name: item.name, + }); + } if (isBinary(item.name)) { - return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' }); + return NextResponse.json({ + type: "file", + content: "(binary file)", + encoding: "utf8", + name: item.name, + }); } // Gitea returns base64-encoded content - const raw = item.content ?? ''; let content: string; try { - content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8'); + content = Buffer.from(raw.replace(/\n/g, ""), "base64").toString("utf8"); } catch { content = raw; } - return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name }); + return NextResponse.json({ + type: "file", + content, + encoding: "utf8", + name: item.name, + }); } catch (err) { - console.error('[file API]', err); - return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 }); + console.error("[file API]", err); + return NextResponse.json( + { error: "Failed to fetch file" }, + { status: 500 }, + ); } } diff --git a/vibn-frontend/components/project/gitea-file-viewer.tsx b/vibn-frontend/components/project/gitea-file-viewer.tsx index ba95a2c..91eb47e 100644 --- a/vibn-frontend/components/project/gitea-file-viewer.tsx +++ b/vibn-frontend/components/project/gitea-file-viewer.tsx @@ -23,6 +23,7 @@ interface ApiResponse { export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) { const [content, setContent] = useState(null); + const [fileData, setFileData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -38,6 +39,7 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) { useEffect(() => { if (!path) { setContent(null); + setFileData(null); setError(null); setLoading(false); return; @@ -47,6 +49,7 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) { setLoading(true); setError(null); setContent(null); + setFileData(null); fetch(`/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`, { credentials: "include", @@ -55,10 +58,13 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) { const data = (await r.json()) as ApiResponse; if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); if (data.type !== "file") throw new Error("Not a file"); - return data.content ?? ""; + return data; }) - .then((c) => { - if (!cancelled) setContent(c); + .then((data) => { + if (!cancelled) { + setContent(data.content ?? ""); + setFileData(data); + } }) .catch((err) => { if (!cancelled) setError(err.message || "Failed to load file"); @@ -124,6 +130,11 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) { }; const language = languageMap[extension] || "text"; + const isImage = + fileData?.encoding === "base64" && + fileData.name && + /\.(png|jpg|jpeg|gif|webp|svg|ico)$/i.test(fileData.name); + return (
{basename(path)}
- + {!isImage && ( + + )}
- - {content || ""} - + {isImage ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {basename(path)} +
+ ) : ( + + {content || ""} + + )}
);