feat(codebase): add image and SVG preview support to the file viewer
This commit is contained in:
@@ -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<string, unknown> }>(
|
||||
`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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ApiResponse {
|
||||
|
||||
export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [fileData, setFileData] = useState<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -159,47 +170,75 @@ export function GiteaFileViewer({ projectId, path }: GiteaFileViewerProps) {
|
||||
>
|
||||
{basename(path)}
|
||||
</div>
|
||||
<button
|
||||
onClick={copyCode}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: copied ? "#059669" : THEME.mid,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
{!isImage && (
|
||||
<button
|
||||
onClick={copyCode}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: copied ? "#059669" : THEME.mid,
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={wrap}>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "16px",
|
||||
background: THEME.cardBg,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
flex: 1,
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
lineNumberStyle={{
|
||||
minWidth: "3em",
|
||||
paddingRight: "1em",
|
||||
color: THEME.muted,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{content || ""}
|
||||
</SyntaxHighlighter>
|
||||
{isImage ? (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 32,
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`data:image/${extension === "svg" ? "svg+xml" : extension};base64,${(
|
||||
fileData?.content || ""
|
||||
).replace(/\n/g, "")}`}
|
||||
alt={basename(path)}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "16px",
|
||||
background: THEME.cardBg,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
flex: 1,
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
lineNumberStyle={{
|
||||
minWidth: "3em",
|
||||
paddingRight: "1em",
|
||||
color: THEME.muted,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{content || ""}
|
||||
</SyntaxHighlighter>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user