Skip to content

Commit 992399d

Browse files
committed
Implement VFS
1 parent 3b446cd commit 992399d

File tree

13 files changed

+1245
-349
lines changed

13 files changed

+1245
-349
lines changed

frontend/src/app.html

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
<!doctype html>
22
<html lang="en">
3-
<head>
4-
<meta charset="utf-8" />
5-
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1" />
7-
%sveltekit.head%
8-
</head>
9-
<body data-sveltekit-preload-data="hover">
10-
<div style="display: contents">%sveltekit.body%</div>
11-
</body>
3+
4+
<head>
5+
<meta charset="utf-8" />
6+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
7+
<link rel="preconnect" href="https://fonts.googleapis.com">
8+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9+
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:[email protected]&display=swap" rel="stylesheet">
10+
<meta name="viewport" content="width=device-width, initial-scale=1" />
11+
%sveltekit.head%
12+
</head>
13+
14+
<body data-sveltekit-preload-data="hover">
15+
<div style="display: contents">%sveltekit.body%</div>
16+
</body>
17+
1218
</html>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface VFSFile {
2+
path: string;
3+
content?: string | URL;
4+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<script lang="ts">
2+
import type { VFSFile } from "./types";
3+
import Png from "./views/png.svelte";
4+
import Txt from "./views/txt.svelte";
5+
import type { Component } from "svelte";
6+
import Word from "./views/word.svelte";
7+
import Pdf from "./views/pdf.svelte";
8+
9+
interface Props {
10+
file: VFSFile;
11+
}
12+
13+
const { file }: Props = $props();
14+
const views: Record<string, Component<{ file: VFSFile }>> = {
15+
"ts": Txt,
16+
"txt": Txt,
17+
"png": Png,
18+
"doc": Word,
19+
"docx": Word,
20+
"pdf": Pdf,
21+
};
22+
23+
const extension = $derived(file.path.split(".").pop() ?? "txt");
24+
const View = $derived(views[extension] ?? views["txt"]);
25+
</script>
26+
27+
<div class="checkerboard-bg h-full">
28+
<View {file} />
29+
</div>
30+
31+
<style>
32+
:global(.transparent-file-vfs) {
33+
display: inline-block;
34+
background-color: white;
35+
background-image:
36+
linear-gradient(45deg, #f0f0f0 25%, transparent 25%),
37+
linear-gradient(-45deg, #f0f0f0 25%, transparent 25%),
38+
linear-gradient(45deg, transparent 75%, #f0f0f0 75%),
39+
linear-gradient(-45deg, transparent 75%, #f0f0f0 75%);
40+
background-size: 20px 20px;
41+
background-position:
42+
0 0,
43+
0 10px,
44+
10px -10px,
45+
-10px 0px;
46+
}
47+
</style>
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<script lang="ts">
2+
import { ChevronDown, File, Folder, Icon, Search, FileImage, FileText, Check } from "lucide-svelte";
3+
import { Input } from "../ui/input";
4+
import { Separator } from "../ui/separator";
5+
import { SvelteSet } from "svelte/reactivity";
6+
import { cn } from "$lib/utils";
7+
import type { VFSFile } from "./types";
8+
import Button from "../ui/button/button.svelte";
9+
10+
interface Props {
11+
files?: VFSFile[];
12+
onFileSelect?: (file: VFSFile) => void | Promise<void>;
13+
}
14+
15+
interface VFSNode {
16+
name: string;
17+
type: "file" | "directory";
18+
content?: string | URL;
19+
children: VFSNode[];
20+
}
21+
22+
let selected = $state<VFSFile | null>(null);
23+
const { files = [], onFileSelect }: Props = $props();
24+
25+
// ====== //
26+
27+
function getIcon(file: VFSNode) {
28+
const parts = file.name.split(".");
29+
return iconMap[parts.pop() || ""] || File;
30+
}
31+
32+
let search = $state("");
33+
const visibleFiles = $derived.by(() => {
34+
if (!search.trim()) return files;
35+
return files.filter((file) => file.path.toLowerCase().includes(search.toLowerCase()));
36+
});
37+
38+
function build(files: VFSFile[]): VFSNode[] {
39+
const nodeMap: Record<string, VFSNode> = {
40+
"/": { name: "/", type: "directory", children: [] },
41+
};
42+
43+
for (const file of files) {
44+
const path = file.path.startsWith("/") ? file.path : `/${file.path}`;
45+
if (path === "/") continue;
46+
47+
const segments = path.split("/").filter(Boolean);
48+
const fileName = segments.pop() || "";
49+
50+
let currentPath = "";
51+
let parent = nodeMap["/"];
52+
53+
for (const segment of segments) {
54+
currentPath += `/${segment}`;
55+
if (!nodeMap[currentPath]) {
56+
nodeMap[currentPath] = { name: segment, type: "directory", children: [] };
57+
parent.children.push(nodeMap[currentPath]);
58+
}
59+
parent = nodeMap[currentPath];
60+
}
61+
62+
parent.children.push({
63+
name: fileName,
64+
type: "file",
65+
content: file.content,
66+
children: [],
67+
});
68+
}
69+
70+
Object.values(nodeMap).forEach((dir) => {
71+
dir.children.sort((a, b) =>
72+
a.type !== b.type
73+
? a.type === "directory"
74+
? -1
75+
: 1
76+
: a.name.localeCompare(b.name),
77+
);
78+
});
79+
80+
return [nodeMap["/"]];
81+
}
82+
83+
const vfs = $derived(build(visibleFiles));
84+
85+
// ====== //
86+
87+
const iconMap: Record<string, typeof Icon> = {
88+
"pdf": FileText,
89+
"doc": FileText,
90+
"docx": FileText,
91+
"png": FileImage
92+
};
93+
</script>
94+
95+
<div id="vfs" class="file-tree">
96+
<Input
97+
type="text"
98+
icon={Search}
99+
placeholder="Search files..."
100+
bind:value={search}
101+
class="bg-background focus:ring-primary w-full rounded border py-1 pl-8 pr-2 text-sm focus:outline-none focus:ring-1"
102+
/>
103+
104+
<Separator class="my-2" />
105+
106+
<ul>
107+
{#if vfs.length > 0 && vfs[0].children.length > 0}
108+
{#each vfs[0].children as child, i}
109+
{@render renderNode(child, i)}
110+
{/each}
111+
{:else}
112+
<li class="text-muted-foreground p-4 text-center">
113+
{search ? "No matching files found" : "No files available"}
114+
</li>
115+
{/if}
116+
</ul>
117+
</div>
118+
{#snippet renderNode(node: VFSNode, index: number)}
119+
{#if node.type === "directory"}
120+
<li class="directory-item">
121+
<details open={search.length > 0}>
122+
<summary
123+
class="hover:bg-muted flex cursor-pointer select-none items-center rounded px-2 py-1 transition-colors"
124+
>
125+
<ChevronDown class="h-4 min-w-4 transition-transform" />
126+
<Folder class="h-4 min-w-4" />
127+
<span class="truncate font-medium">{node.name}</span>
128+
</summary>
129+
130+
{#if node.children && node.children.length > 0}
131+
<ul class="border-muted ml-5 mt-1 border-l pl-2">
132+
{#each node.children as child, i}
133+
{@render renderNode(child, index + i)}
134+
{/each}
135+
</ul>
136+
{/if}
137+
</details>
138+
</li>
139+
{:else}
140+
{@const IconComponent = getIcon(node)}
141+
{@const isSelected = selected?.path.endsWith(node.name)}
142+
<li class="file-item group">
143+
<Button
144+
variant="ghost"
145+
class={cn(
146+
"hover:bg-muted w-full select-none justify-start shadow-none relative",
147+
isSelected && "bg-muted/70",
148+
)}
149+
onclick={() => {
150+
selected = files.find((f) => f.path.endsWith(node.name)) ?? null;
151+
onFileSelect?.(selected!);
152+
}}
153+
>
154+
<IconComponent class="h-4 min-w-4" />
155+
<span class={cn("truncate", isSelected && "line-through")}>{node.name}</span>
156+
<Check class={cn("h-4 w-4 ml-auto", !isSelected && "opacity-0 group-hover:opacity-100")} />
157+
</Button>
158+
</li>
159+
{/if}
160+
{/snippet}
161+
162+
<style>
163+
:global(.file-tree details > summary::-webkit-details-marker) {
164+
display: none;
165+
}
166+
167+
:global(.file-tree details[open] > summary .lucide-chevron-down) {
168+
transform: rotate(0deg);
169+
}
170+
171+
:global(.file-tree details:not([open]) > summary .lucide-chevron-down) {
172+
transform: rotate(-90deg);
173+
}
174+
175+
:global(.file-tree) {
176+
font-family: 'Geist Mono', 'JetBrains Mono', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
177+
}
178+
</style>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import type { VFSFile } from "../types";
3+
4+
interface Props {
5+
file: VFSFile;
6+
}
7+
8+
const { file }: Props = $props();
9+
</script>
10+
11+
<div class="relative h-full w-full">
12+
<iframe
13+
title="PDF Document"
14+
src={file.content?.toString() || ""}
15+
width="100%"
16+
height="100%"
17+
style="min-height: 80vh;"
18+
frameborder="0"
19+
></iframe>
20+
</div>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
import type { VFSFile } from "../types";
3+
4+
interface Props {
5+
file: VFSFile;
6+
}
7+
8+
const { file }: Props = $props();
9+
</script>
10+
11+
<div class="transparent-file-vfs flex justify-center p-4">
12+
<img
13+
src={file.content?.toString() || ""}
14+
alt={file.path}
15+
class="max-h-[calc(100vh-12rem)] max-w-full object-contain"
16+
/>
17+
</div>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script lang="ts">
2+
import type { VFSFile } from "../types";
3+
4+
interface Props {
5+
file: VFSFile;
6+
}
7+
8+
const { file }: Props = $props();
9+
</script>
10+
11+
<div class="p-4">
12+
<pre class="bg-muted overflow-auto whitespace-pre-wrap rounded-md p-4">
13+
<code>{file.content?.toString() || ""}</code>
14+
</pre>
15+
</div>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script lang="ts">
2+
import type { VFSFile } from "../types";
3+
4+
interface Props {
5+
file: VFSFile;
6+
}
7+
8+
const { file }: Props = $props();
9+
let success = $state(true);
10+
</script>
11+
12+
<div class="relative h-full w-full">
13+
<iframe
14+
title="Word Document"
15+
src="https://view.officeapps.live.com/op/embed.aspx?src={file.}"
16+
width="100%"
17+
height="100%"
18+
frameborder="0"
19+
onerror={() => (success = false)}
20+
>This is an embedded <a target="_blank" href="http://office.com">Microsoft Office</a>
21+
document, powered by
22+
<a target="_blank" href="http://office.com/webapps">Office Online</a>.</iframe
23+
>
24+
25+
{#if !success}
26+
<div
27+
class="absolute inset-0 flex flex-col items-center justify-center bg-white/90 p-4"
28+
>
29+
<div class="max-w-md rounded border border-red-400 bg-red-100 p-4 text-red-700">
30+
<p class="mb-2 text-lg font-bold">Error Loading Document</p>
31+
<p>
32+
The document could not be loaded. Please check the file format or try again
33+
later.
34+
</p>
35+
</div>
36+
</div>
37+
{/if}
38+
</div>

0 commit comments

Comments
 (0)