FileBlob.tsx
Raw
1import type { RepositoryRow } from "../../db/index.ts";
2import type { SessionUser } from "../../middleware/session.ts";
3import type { FileView } from "../../services/highlight.ts";
4import { Layout } from "../layout.tsx";
5import { RepoHeader } from "../repos/RepoHeader.tsx";
6import { BranchSelector } from "./BranchSelector.tsx";
7import { RepoNav } from "./RepoNav.tsx";
8
9interface FileBlobProps {
10 user: SessionUser | null;
11 repo: RepositoryRow;
12 ref: string;
13 filePath: string;
14 view: FileView;
15 branches: string[];
16 markdownHtml?: string;
17}
18
19export function FileBlob({
20 user,
21 repo,
22 ref: blobRef,
23 filePath,
24 view,
25 branches,
26 markdownHtml,
27}: FileBlobProps) {
28 const parts = filePath.split("/");
29 const filename = parts[parts.length - 1] ?? filePath;
30 const dir = parts.slice(0, -1).join("/");
31 const _backHref = dir
32 ? `/${repo.name}/tree/${blobRef}/${dir}`
33 : `/${repo.name}/tree/${blobRef}`;
34 return (
35 <Layout user={user} title={`${repo.name}/${filePath}`}>
36 <div class="container">
37 <RepoHeader repo={repo} />
38 <RepoNav repo={repo} active="code" user={user} />
39 <div class="breadcrumb">
40 <a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
41 {parts.map((part, i) => {
42 const partPath = parts.slice(0, i + 1).join("/");
43 const isLast = i === parts.length - 1;
44 return (
45 <>
46 <span class="breadcrumb-sep">/</span>
47 {isLast ? (
48 <span class="breadcrumb-current">
49 {part}
50 </span>
51 ) : (
52 <a
53 href={`/${repo.name}/tree/${blobRef}/${partPath}`}
54 >
55 {part}
56 </a>
57 )}
58 </>
59 );
60 })}
61 </div>
62 <div class="file-blob-header">
63 <span class="file-blob-name">{filename}</span>
64 <div class="file-blob-actions">
65 <BranchSelector
66 repoName={repo.name}
67 branches={branches}
68 currentRef={blobRef}
69 view="blob"
70 path={filePath}
71 />
72 <a
73 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
74 class="btn btn-sm btn-ghost"
75 >
76 Raw
77 </a>
78 {view.type === "inline" &&
79 branches.includes(blobRef) &&
80 user?.isAdmin && (
81 <a
82 href={`/${repo.name}/edit/${blobRef}/${filePath}`}
83 class="btn btn-sm btn-primary"
84 >
85 Edit
86 </a>
87 )}
88 </div>
89 </div>
90 <div class="file-blob-body">
91 {markdownHtml ? (
92 <div class="markdown-body">{markdownHtml}</div>
93 ) : view.type === "inline" ? (
94 <div class="shiki-wrapper">{view.html}</div>
95 ) : view.type === "media" ? (
96 <div class="file-media">
97 {view.mimeType.startsWith("image/") ? (
98 <img
99 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
100 alt={filename}
101 class="file-media-img"
102 />
103 ) : view.mimeType.startsWith("audio/") ? (
104 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
105 <audio
106 controls="true"
107 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
108 class="file-media-audio"
109 />
110 ) : (
111 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
112 <video
113 controls
114 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
115 class="file-media-video"
116 />
117 )}
118 </div>
119 ) : view.type === "binary" ? (
120 <div class="file-download-notice">
121 <p>Binary file ({formatSize(view.size)})</p>
122 <a
123 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
124 class="btn btn-primary"
125 >
126 Download
127 </a>
128 </div>
129 ) : (
130 <div class="file-download-notice">
131 <p>
132 File too large to display inline (
133 {formatSize(view.size)})
134 </p>
135 <a
136 href={`/${repo.name}/raw/${blobRef}/${filePath}`}
137 class="btn btn-primary"
138 >
139 Download
140 </a>
141 </div>
142 )}
143 </div>
144 </div>
145 </Layout>
146 );
147}
148
149function formatSize(bytes: number): string {
150 if (bytes < 1024) return `${bytes} B`;
151 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
152 return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
153}
154