get clo file viewer running

This commit is contained in:
chloe caruso 2025-06-21 16:04:57 -07:00
parent c7dfbe1090
commit a367dfdb29
43 changed files with 1005 additions and 291 deletions

View file

@ -28,24 +28,28 @@ export async function bundleClientJavaScript(
]; ];
const bundle = await esbuild.build({ const bundle = await esbuild.build({
assetNames: "/asset/[hash]",
bundle: true, bundle: true,
chunkNames: "/js/c.[hash]", chunkNames: "/js/c.[hash]",
entryNames: "/js/[name]", entryNames: "/js/[name]",
assetNames: "/asset/[hash]",
entryPoints, entryPoints,
format: "esm", format: "esm",
jsx: "automatic",
jsxDev: dev,
jsxImportSource: "#ssr",
logLevel: "silent",
metafile: true,
minify: !dev, minify: !dev,
outdir: "/out!", outdir: "/out!",
plugins: clientPlugins, plugins: clientPlugins,
write: false, write: false,
metafile: true,
external: ["node_modules/"],
jsx: "automatic",
jsxImportSource: "#ssr",
jsxDev: dev,
define: { define: {
"ASSERT": "console.assert", "ASSERT": "console.assert",
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
}, },
}).catch((err: any) => {
err.message = `Client ${err.message}`;
throw err;
}); });
if (bundle.errors.length || bundle.warnings.length) { if (bundle.errors.length || bundle.warnings.length) {
throw new AggregateError( throw new AggregateError(
@ -66,7 +70,8 @@ export async function bundleClientJavaScript(
const { text } = file; const { text } = file;
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/"); let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
const { inputs } = UNWRAP(metafile.outputs["out!" + route]); const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
const sources = Object.keys(inputs); const sources = Object.keys(inputs)
.filter((x) => !x.startsWith("<define:"));
// Register non-chunks as script entries. // Register non-chunks as script entries.
const chunk = route.startsWith("/js/c."); const chunk = route.startsWith("/js/c.");
@ -172,11 +177,15 @@ export async function bundleServerJavaScript(
outdir: "/out!", outdir: "/out!",
plugins: serverPlugins, plugins: serverPlugins,
splitting: true, splitting: true,
logLevel: "silent",
write: false, write: false,
metafile: true, metafile: true,
jsx: "automatic", jsx: "automatic",
jsxImportSource: "#ssr", jsxImportSource: "#ssr",
jsxDev: false, jsxDev: false,
define: {
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
},
external: Object.keys(pkg.dependencies) external: Object.keys(pkg.dependencies)
.filter((x) => !x.startsWith("@paperclover")), .filter((x) => !x.startsWith("@paperclover")),
}); });
@ -204,6 +213,7 @@ export async function bundleServerJavaScript(
fileWithMagicWord, fileWithMagicWord,
}, },
sources: Object.keys(metafile.inputs).filter((x) => sources: Object.keys(metafile.inputs).filter((x) =>
!x.includes("<define:") &&
!x.startsWith("vfs:") && !x.startsWith("vfs:") &&
!x.startsWith("dropped:") && !x.startsWith("dropped:") &&
!x.includes("node_modules") !x.includes("node_modules")
@ -283,6 +293,9 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
.replaceAll("\\", "/"); .replaceAll("\\", "/");
const cacheEntry = incr.out.serverMarko.get(key); const cacheEntry = incr.out.serverMarko.get(key);
if (!cacheEntry) { if (!cacheEntry) {
if (!fs.existsSync(file)) {
console.log(`File does not exist: ${file}`);
}
throw new Error("Marko file not in cache: " + file); throw new Error("Marko file not in cache: " + file);
} }
return ({ return ({
@ -295,15 +308,13 @@ function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
}, },
}; };
} }
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import * as path from "node:path"; import * as path from "node:path";
import process from "node:process"; import process from "node:process";
import * as hot from "./hot.ts"; import * as hot from "./hot.ts";
import { import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts";
banFiles,
projectRelativeResolution,
virtualFiles,
} from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts"; import { Incremental } from "./incremental.ts";
import * as css from "./css.ts"; import * as css from "./css.ts";
import * as fs from "#sitegen/fs"; import * as fs from "#sitegen/fs";
import * as mime from "#sitegen/mime";

View file

@ -86,7 +86,7 @@ export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
* to avoid functions that are missing a return statement. * to avoid functions that are missing a return statement.
*/ */
export type Component = ( export type Component = (
props: Record<string, unknown>, props: Record<any, any>,
) => Exclude<Node, undefined>; ) => Exclude<Node, undefined>;
/** /**

View file

@ -1,11 +1,12 @@
const entries = fs.readFileSync( declare const MIME_INLINE_DATA: never;
path.join(import.meta.dirname, "mime.txt"), const entries = typeof MIME_INLINE_DATA !== "undefined"
"utf8", ? MIME_INLINE_DATA
) : fs.readFileSync(path.join(import.meta.dirname, "mime.txt"), "utf8")
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
.filter((line) => line && !line.startsWith("#")) .filter((line) => line && !line.startsWith("#"))
.map((line) => line.split(/\s+/, 2) as [string, string]); .map((line) => line.split(/\s+/, 2) as [string, string]);
export const rawEntriesText = entries;
const extensions = new Map(entries.filter((x) => x[0].startsWith("."))); const extensions = new Map(entries.filter((x) => x[0].startsWith(".")));
const fullNames = new Map(entries.filter((x) => !x[0].startsWith("."))); const fullNames = new Map(entries.filter((x) => !x[0].startsWith(".")));

View file

@ -48,7 +48,14 @@ export class WrappedDatabase {
prepare<Args extends unknown[] = [], Result = unknown>( prepare<Args extends unknown[] = [], Result = unknown>(
query: string, query: string,
): Stmt<Args, Result> { ): Stmt<Args, Result> {
return new Stmt(this.node.prepare(query)); let prepared;
try {
prepared = this.node.prepare(query);
} catch (err) {
if (err) (err as { query: string }).query = query;
throw err;
}
return new Stmt(prepared);
} }
} }

BIN
meow.txt

Binary file not shown.

View file

@ -34,10 +34,10 @@
"node": "marko/debug/html" "node": "marko/debug/html"
}, },
"#hono": "hono", "#hono": "hono",
"#hono/platform": { "#hono/conninfo": {
"bun": "hono/bun", "bun": "hono/bun",
"deno": "hono/deno", "deno": "hono/deno",
"node": "@hono/node-server", "node": "@hono/node-server/conninfo",
"worker": "hono/cloudflare-workers" "worker": "hono/cloudflare-workers"
} }
} }

View file

@ -11,7 +11,7 @@ process.stderr.write("Loading...");
const { hot } = await import("./run.js"); // get plugins ready const { hot } = await import("./run.js"); // get plugins ready
const { errorAllWidgets } = hot.load("@paperclover/console/Widget"); const { errorAllWidgets } = hot.load("@paperclover/console/Widget");
process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r"); process.stderr.write("\r" + " ".repeat("Loading...".length) + "\r");
hot.load("node:repl").start({ const repl = hot.load("node:repl").start({
prompt: "% ", prompt: "% ",
eval(code, _global, _id, done) { eval(code, _global, _id, done) {
evaluate(code) evaluate(code)
@ -25,6 +25,7 @@ hot.load("node:repl").start({
ignoreUndefined: true, ignoreUndefined: true,
//completer, //completer,
}); });
repl.setupHistory(".clover/repl-history.txt", () => {});
setTimeout(() => { setTimeout(() => {
hot.reloadRecursive("./framework/generate.ts"); hot.reloadRecursive("./framework/generate.ts");

View file

@ -10,7 +10,7 @@ app.use(logger((msg) => msg.startsWith("-->") && logHttp(msg.slice(4))));
app.use(admin.middleware); app.use(admin.middleware);
app.route("", require("./q+a/backend.ts").app); app.route("", require("./q+a/backend.ts").app);
// app.route("", require("./file-viewer/backend.tsx").app); app.route("", require("./file-viewer/backend.tsx").app);
// app.route("", require("./friends/backend.tsx").app); // app.route("", require("./friends/backend.tsx").app);
app.use(assets.middleware); app.use(assets.middleware);

0
src/blog/layout.tsx Normal file
View file

View file

@ -5,6 +5,7 @@ export const blog: BlogMeta = {
draft: true, draft: true,
}; };
export const meta = formatBlogMeta(blob); export const meta = formatBlogMeta(blob);
export * as layout from "@/blog/layout.tsx";
I've been recently playing around [Marko][1], and after adding limited support I've been recently playing around [Marko][1], and after adding limited support
for it in my website generator, [sitegen][2], I instantly fell in love with how for it in my website generator, [sitegen][2], I instantly fell in love with how

View file

@ -1,17 +1,4 @@
import { type Context, Hono } from "hono"; export const app = new Hono();
import * as path from "node:path";
import { etagMatches, serveAsset } from "../assets.ts";
import { FilePermissions, MediaFile } from "../db.ts";
import { renderDynamicPage } from "../framework/dynamic-pages.ts";
import { renderToStringSync } from "../framework/render-to-string.ts";
import { MediaPanel } from "../pages-dynamic/file_viewer.tsx";
import mimeTypeDb from "./mime.json" with { type: "json" };
import { Speedbump } from "./cotyledon.tsx";
import { hasAsset } from "../assets.ts";
import { CompressionFormat, fetchFile, prefetchFile } from "./cache.ts";
import { requireFriendAuth } from "../journal/backend.ts";
const app = new Hono();
interface APIDirectoryList { interface APIDirectoryList {
path: string; path: string;
@ -86,13 +73,13 @@ app.get("/file/*", async (c, next) => {
} }
// File listings // File listings
if (file.kind === MediaFile.Kind.directory) { if (file.kind === MediaFileKind.directory) {
if (c.req.header("Accept")?.includes("application/json")) { if (c.req.header("Accept")?.includes("application/json")) {
const json = { const json = {
path: file.path, path: file.path,
files: file.getPublicChildren().map((f) => ({ files: file.getPublicChildren().map((f) => ({
basename: f.basename, basename: f.basename,
dir: f.kind === MediaFile.Kind.directory, dir: f.kind === MediaFileKind.directory,
time: f.date.getTime(), time: f.date.getTime(),
size: f.size, size: f.size,
duration: f.duration ? f.duration : null, duration: f.duration ? f.duration : null,
@ -101,7 +88,7 @@ app.get("/file/*", async (c, next) => {
} satisfies APIDirectoryList; } satisfies APIDirectoryList;
return c.json(json); return c.json(json);
} }
c.res = await renderDynamicPage(c.req.raw, "file_viewer", { c.res = await renderView(c, "file-viewer/clofi", {
file, file,
hasCotyledonCookie, hasCotyledonCookie,
}); });
@ -115,7 +102,7 @@ app.get("/file/*", async (c, next) => {
} }
if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) { if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {
prefetchFile(file.path); prefetchFile(file.path);
c.res = await renderDynamicPage(c.req.raw, "file_viewer", { c.res = await renderView(c, "file-viewer/clofi", {
file, file,
hasCotyledonCookie, hasCotyledonCookie,
}); });
@ -220,7 +207,7 @@ app.get("/canvas/:script", async (c, next) => {
if (!hasAsset(`/js/canvas/${script}.js`)) { if (!hasAsset(`/js/canvas/${script}.js`)) {
return next(); return next();
} }
return renderDynamicPage(c.req.raw, "canvas", { return renderView(c, "file-viewer/canvas", {
script, script,
}); });
}); });
@ -238,7 +225,7 @@ function fileHeaders(
) { ) {
return { return {
Vary: "Accept-Encoding, Accept", Vary: "Accept-Encoding, Accept",
"Content-Type": mimeType(file.path), "Content-Type": contentTypeFor(file.path),
"Content-Length": size.toString(), "Content-Length": size.toString(),
ETag: file.hash, ETag: file.hash,
"Last-Modified": file.date.toUTCString(), "Last-Modified": file.date.toUTCString(),
@ -318,7 +305,7 @@ function applyRangesToBuffer(
const result = new Uint8Array(rangeSize); const result = new Uint8Array(rangeSize);
let offset = 0; let offset = 0;
for (const [start, end] of ranges) { for (const [start, end] of ranges) {
result.set(buffer.slice(start, end + 1), offset); result.set(buffer.subarray(start, end + 1), offset);
offset += end - start + 1; offset += end - start + 1;
} }
return result; return result;
@ -379,18 +366,14 @@ function applySingleRangeToStream(
}); });
} }
function mimeType(file: string) {
return (mimeTypeDb as any)[path.extname(file)] ?? "application/octet-stream";
}
function getPartialPage(c: Context, rawFilePath: string) { function getPartialPage(c: Context, rawFilePath: string) {
if (isCotyledonPath(rawFilePath)) { if (isCotyledonPath(rawFilePath)) {
if (!checkCotyledonCookie(c)) { if (!checkCotyledonCookie(c)) {
let root = Speedbump(); let root = Speedbump();
// Remove the root element, it's created client side! // Remove the root element, it's created client side!
root = root.props.children; root = root[2].children as ssr.Element;
const html = renderToStringSync(root); const html = ssr.ssrSync(root).text;
c.header("X-Cotyledon", "true"); c.header("X-Cotyledon", "true");
return c.html(html); return c.html(html);
} }
@ -416,10 +399,26 @@ function getPartialPage(c: Context, rawFilePath: string) {
hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c), hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
}); });
// Remove the root element, it's created client side! // Remove the root element, it's created client side!
root = root.props.children; root = root[2].children as ssr.Element;
const html = renderToStringSync(root); const html = ssr.ssrSync(root).text;
return c.html(html); return c.html(html);
} }
export { app as mediaApp }; import { type Context, Hono } from "hono";
import * as ssr from "#ssr";
import { etagMatches, hasAsset, serveAsset } from "#sitegen/assets";
import { renderView } from "#sitegen/view";
import { contentTypeFor } from "#sitegen/mime";
import { requireFriendAuth } from "@/friend-auth.ts";
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts";
import { MediaPanel } from "@/file-viewer/views/clofi.tsx";
import { Speedbump } from "@/file-viewer/cotyledon.tsx";
import {
type CompressionFormat,
fetchFile,
prefetchFile,
} from "@/file-viewer/cache.ts";

View file

@ -0,0 +1,2 @@
import "@/file-viewer/models/MediaFile.ts";
import "@/file-viewer/models/BlobAsset.ts";

View file

@ -3,16 +3,16 @@ import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import type { ClientRequest } from "node:http"; import type { ClientRequest } from "node:http";
import { LRUCache } from "lru-cache"; import LRUCache from "lru-cache";
import { open } from "node:fs/promises"; import { open } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { scoped } from "@paperclover/console"; import { scoped } from "@paperclover/console";
import { escapeUri } from "./share.ts"; import { escapeUri } from "./format.ts";
declare const Deno: any; declare const Deno: any;
const sourceOfTruth = "https://nas.paperclover.net:43250"; const sourceOfTruth = "https://nas.paperclover.net:43250";
const caCert = fs.readFileSync(path.join(import.meta.dirname, "cert.pem")); const caCert = fs.readFileSync("src/file-viewer/cert.pem");
const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/"); const diskCacheRoot = path.join(import.meta.dirname, "../.clover/filecache/");
const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB const diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB

View file

@ -137,155 +137,215 @@ export function Readme() {
} }
export function ForEveryone() { export function ForEveryone() {
// deno-fmt-ignore return (
return <><div class="for_everyone"> <>
<p>today is my 21st birthday. april 30th, 2025.</p> <div class="for_everyone">
<p>it's been nearly six months starting hormones.</p> <p>today is my 21st birthday. april 30th, 2025.</p>
<p>sometimes i feel great,</p> <p>it's been nearly six months starting hormones.</p>
<p>sometimes i get dysphoria.</p> <p>sometimes i feel great,</p>
<p>with the walls around me gone</p> <p>sometimes i get dysphoria.</p>
<p>that shit hits way harder than it did before.</p> <p>with the walls around me gone</p>
<p>ugh..</p> <p>that shit hits way harder than it did before.</p>
<p>i'm glad the pain i felt is now explained,</p> <p>ugh..</p>
<p>but now rendered in high definition.</p> <p>i'm glad the pain i felt is now explained,</p>
<p>the smallest strands of hair on my face and belly act</p> <p>but now rendered in high definition.</p>
<p>as sharpened nails to pierce my soul.</p> <p>the smallest strands of hair on my face and belly act</p>
<p></p> <p>as sharpened nails to pierce my soul.</p>
<p>it's all a pathway to better days; the sun had risen.</p> <p></p>
<p>one little step at a time for both of us.</p> <p>it's all a pathway to better days; the sun had risen.</p>
<p>today i quit my job. free falling, it feels so weird.</p> <p>one little step at a time for both of us.</p>
<p>like sky diving.</p> <p>today i quit my job. free falling, it feels so weird.</p>
<p>the only thing i feel is cold wind.</p> <p>like sky diving.</p>
<p>the only thing i see is everything,</p> <p>the only thing i feel is cold wind.</p>
<p>and it's beautiful.</p> <p>the only thing i see is everything,</p>
<p>i have a month of falling before the parachute activates,</p> <p>and it's beautiful.</p>
<p>gonna spend as much time of it on art as i can.</p> <p>i have a month of falling before the parachute activates,</p>
<p>that was, after all, my life plan:</p> <p>gonna spend as much time of it on art as i can.</p>
<p>i wanted to make art, all the time,</p> <p>that was, after all, my life plan:</p>
<p>for everyone.</p> <p>i wanted to make art, all the time,</p>
<p></p> <p>for everyone.</p>
<p>then you see what happened</p> <p></p>
<p>to the world and the internet.</p> <p>then you see what happened</p>
<p>i never really got to live through that golden age,</p> <p>to the world and the internet.</p>
<p>it probably sucked back then too.</p> <p>i never really got to live through that golden age,</p>
<p>but now the big sites definitely stopped being fun.</p> <p>it probably sucked back then too.</p>
<p>they slide their cold hands up my body</p> <p>but now the big sites definitely stopped being fun.</p>
<p>and feel me around. it's unwelcoming, and</p> <p>they slide their cold hands up my body</p>
<p>inconsiderate to how sensitive my skin is.</p> <p>and feel me around. it's unwelcoming, and</p>
<p>i'm so fucking glad i broke up with YouTube</p> <p>inconsiderate to how sensitive my skin is.</p>
<p>and their devilish friends.</p> <p>i'm so fucking glad i broke up with YouTube</p>
<p>my NAS is at 5 / 24 TB</p> <p>and their devilish friends.</p>
<p>and probably wont fill for the next decade.</p> <p>my NAS is at 5 / 24 TB</p>
<p></p> <p>and probably wont fill for the next decade.</p>
<p>it took 2 months for me to notice my body changed.</p> <p></p>
<p>that day was really nice, but it hurt a lot.</p> <p>it took 2 months for me to notice my body changed.</p>
<p>a sharp, satisfying pain in my chest gave me life.</p> <p>that day was really nice, but it hurt a lot.</p>
<p>learned new instincts for my arms</p> <p>a sharp, satisfying pain in my chest gave me life.</p>
<p>so they'd stop poking my new shape.</p> <p>learned new instincts for my arms</p>
<p>when i look at my face</p> <p>so they'd stop poking my new shape.</p>
<p>it's like a different person.</p> <p>when i look at my face</p>
<p>she was the same as before, but completely new.</p> <p>it's like a different person.</p>
<p>something changed</p> <p>she was the same as before, but completely new.</p>
<p>or i'm now used to seeing what makes me smile.</p> <p>something changed</p>
<p>regardless, whatever i see in the mirror, i smile.</p> <p>or i'm now used to seeing what makes me smile.</p>
<p>and, i don't hear that old name much anymore</p> <p>regardless, whatever i see in the mirror, i smile.</p>
<p>aside from nightmares. and you'll never repeat it, ok?</p> <p>and, i don't hear that old name much anymore</p>
<p>okay.</p> <p>aside from nightmares. and you'll never repeat it, ok?</p>
<p></p> <p>okay.</p>
<p>been playing 'new canaan' by 'bill wurtz' on loop</p> <p></p>
<p>in the background.</p> <p>been playing 'new canaan' by 'bill wurtz' on loop</p>
<p>it kinda just feels right.</p> <p>in the background.</p>
<p>especially when that verse near the end comes on.</p> <p>it kinda just feels right.</p>
<p></p> <p>especially when that verse near the end comes on.</p>
<p>more people have been allowed to visit me.</p> <p></p>
<p>my apartment used to be just for me,</p> <p>more people have been allowed to visit me.</p>
<p>but the more i felt like a person</p> <p>my apartment used to be just for me,</p>
<p>the more i felt like having others over.</p> <p>but the more i felt like a person</p>
<p>still have to decorate and clean it a little,</p> <p>the more i felt like having others over.</p>
<p>but it isn't a job to do alone.</p> <p>still have to decorate and clean it a little,</p>
<p>we dragged a giant a rug across the city one day,</p> <p>but it isn't a job to do alone.</p>
<p>and it felt was like anything was possible.</p> <p>we dragged a giant a rug across the city one day,</p>
<p>sometimes i have ten people visit in a day,</p> <p>and it felt was like anything was possible.</p>
<p>or sometimes i focus my little eyes on just one.</p> <p>sometimes i have ten people visit in a day,</p>
<p>i never really know what i want to do</p> <p>or sometimes i focus my little eyes on just one.</p>
<p>until the time actually comes.</p> <p>i never really know what i want to do</p>
<p></p> <p>until the time actually comes.</p>
{/* FILIP */} <p></p>
<p>i think about the times i was by the water with you.</p> {/* FILIP */}
<p>the sun setting warmly, icy air fell on our shoulders.</p> <p>i think about the times i was by the water with you.</p>
{/* NATALIE */} <p>the sun setting warmly, icy air fell on our shoulders.</p>
<p>and how we walked up to the top of that hill,</p> {/* NATALIE */}
<p>you picked up and disposed a nail on the ground,</p> <p>and how we walked up to the top of that hill,</p>
<p>walking the city thru places i've never been.</p> <p>you picked up and disposed a nail on the ground,</p>
{/* BEN */} <p>walking the city thru places i've never been.</p>
<p>or hiking through the park talking about compilers,</p> {/* BEN */}
<p>tiring me out until i'd fall asleep in your arms.</p> <p>or hiking through the park talking about compilers,</p>
{/* ELENA */} <p>tiring me out until i'd fall asleep in your arms.</p>
<p>and the way you held on to my hand as i woke up,</p> {/* ELENA */}
<p>noticing how i was trying to hide nightmare's tears.</p> <p>and the way you held on to my hand as i woke up,</p>
<p></p> <p>noticing how i was trying to hide nightmare's tears.</p>
{/* HIGH SCHOOL */} <p></p>
<p>i remember we were yelling lyrics loudly,</p> {/* HIGH SCHOOL */}
<p>out of key yet cheered on because it was fun.</p> <p>i remember we were yelling lyrics loudly,</p>
{/* ADVAITH/NATALIE */} <p>out of key yet cheered on because it was fun.</p>
<p>and when we all toured the big corporate office,</p> {/* ADVAITH/NATALIE */}
{/* AYU/HARRIS */} <p>and when we all toured the big corporate office,</p>
<p>then snuck in to some startup's office after hours;</p> {/* AYU/HARRIS */}
<p>i don't remember what movie we watched.</p> <p>then snuck in to some startup's office after hours;</p>
{/* COLLEGE, DAY 1 IN EV's ROOM */} <p>i don't remember what movie we watched.</p>
<p>i remember laying on the bunk bed,</p> {/* COLLEGE, DAY 1 IN EV's ROOM */}
<p>while the rest played a card game.</p> <p>i remember laying on the bunk bed,</p>
{/* MEGHAN/MORE */} <p>while the rest played a card game.</p>
<p>with us all laying on the rug, staring at the TV</p> {/* MEGHAN/MORE */}
<p>as the ending twist to {/* SEVERANCE */'that show'} was revealed.</p> <p>with us all laying on the rug, staring at the TV</p>
<p></p> <p>
<p>all the moments i cherish,</p> as the ending twist to {/* SEVERANCE */ "that show"} was revealed.
<p>i love because it was always me.</p> </p>
<p>i didn't have to pretend,</p> <p></p>
<p>even if i didn't know who i was at the time.</p> <p>all the moments i cherish,</p>
<p>you all were there. for me.</p> <p>i love because it was always me.</p>
<p></p> <p>i didn't have to pretend,</p>
<p>i don't want to pretend any more</p> <p>even if i didn't know who i was at the time.</p>
<p>i want to be myself. for everyone.</p> <p>you all were there. for me.</p>
<p></p> <p></p>
<p>oh, the song ended. i thought it was on loop?</p> <p>i don't want to pretend any more</p>
<p>it's late... can hear the crickets...</p> <p>i want to be myself. for everyone.</p>
<p>and i can almost see the moon... mmmm...</p> <p></p>
<p>...nah, too much light pollution.</p> <p>oh, the song ended. i thought it was on loop?</p>
<p></p> <p>it's late... can hear the crickets...</p>
<p>one day. one day.</p> <p>and i can almost see the moon... mmmm...</p>
<p></p> <p>...nah, too much light pollution.</p>
<p class="normal">before i go, i want to show the uncensored version of "journal about a girl", because i can trust you at least. keep in mind, i think you're one of the first people to ever see this.</p> <p></p>
</div> <p>one day. one day.</p>
<div class="for_everyone" style="max-width:80ch;"> <p></p>
<blockquote> <p class="normal">
<p>journal - 2024-09-14</p> before i go, i want to show the uncensored version of "journal about a
<p>been at HackMIT today on behalf of the company. it's fun. me and zack were running around looking for people that might be good hires. he had this magic arbitrary criteria to tell "oh this person is probably cracked let's talk to them" and we go to the first one. they were a nerd, perfect. they seemed to be extremely talented with some extreme software projects.<br/> girl", because i can trust you at least. keep in mind, i think you're
okay.. oof... its still clouding my mind<br/> one of the first people to ever see this.
i cant shake that feeling away</p> </p>
<p>hold on...</p> </div>
<p>at some point they open one of their profiles to navigate to some code, and it displays for a couple of seconds: "pronouns: she/they". i don't actually know anything about this person, but it was my perception that she is trans. their appearance, physique, and age felt similar to me, which tends makes people think you are male.</p> <div class="for_everyone" style="max-width:80ch;">
<p>but... she was having fun being herself. being a legend of identity and of her skill in computer science. winning the physics major. making cool shit at the hackathon, and probably in life. my perception of her was the exact essence of who i myself wanted to be. i was jealous of her life.</p> <blockquote>
<p>i tried hard to avoid a breakdown. success. but i was feeling distant. the next hour or so was disorienting, trying not to think about it too hard. i think there was one possibly interesting person we talked to. i don't remember any of the other conversations. they were not important. but i couldn't think through them regardless.</p> <p>journal - 2024-09-14</p>
<p>later, i decided to read some of her code. i either have a huge dislike towards the Rust programming language and/or it was not high quality code. welp, so just is a person studying. my perception was just a perception, inaccurate but impacting. i know i need to become myself, whoever that is. otherwise, i'm just going to feel this shit at higher doses. i think about this every day, and the amount of time i feel being consumed by these problems only grows.</p> <p>
<p>getting through it all is a lonely feeling. not because no one is around, but because i am isolated emotionally. i know other people hit these feelings, but we all are too afraid to speak up, and it's all lonely.</p> been at HackMIT today on behalf of the company. it's fun. me and
<p>waiting on a reply from someone from healthcare. it'll be slow, but it will be okay.</p> zack were running around looking for people that might be good
</blockquote> hires. he had this magic arbitrary criteria to tell "oh this person
</div> is probably cracked let's talk to them" and we go to the first one.
<div class="for_everyone"> they were a nerd, perfect. they seemed to be extremely talented with
<p class="normal"> some extreme software projects.<br />
i've learned that even when i feel alone, it doesn't have to feel lonely. i know it's hard, dear. i know it's scary. but i promise it's possible. we're all in this together. struggling together. sacrificing together. we dedicate our lives to each you, and our art for everyone. okay.. oof... its still clouding my mind<br />
</p> i cant shake that feeling away
</p>
<p>hold on...</p>
<p>
at some point they open one of their profiles to navigate to some
code, and it displays for a couple of seconds: "pronouns: she/they".
i don't actually know anything about this person, but it was my
perception that she is trans. their appearance, physique, and age
felt similar to me, which tends makes people think you are male.
</p>
<p>
but... she was having fun being herself. being a legend of identity
and of her skill in computer science. winning the physics major.
making cool shit at the hackathon, and probably in life. my
perception of her was the exact essence of who i myself wanted to
be. i was jealous of her life.
</p>
<p>
i tried hard to avoid a breakdown. success. but i was feeling
distant. the next hour or so was disorienting, trying not to think
about it too hard. i think there was one possibly interesting person
we talked to. i don't remember any of the other conversations. they
were not important. but i couldn't think through them regardless.
</p>
<p>
later, i decided to read some of her code. i either have a huge
dislike towards the Rust programming language and/or it was not high
quality code. welp, so just is a person studying. my perception was
just a perception, inaccurate but impacting. i know i need to become
myself, whoever that is. otherwise, i'm just going to feel this shit
at higher doses. i think about this every day, and the amount of
time i feel being consumed by these problems only grows.
</p>
<p>
getting through it all is a lonely feeling. not because no one is
around, but because i am isolated emotionally. i know other people
hit these feelings, but we all are too afraid to speak up, and it's
all lonely.
</p>
<p>
waiting on a reply from someone from healthcare. it'll be slow, but
it will be okay.
</p>
</blockquote>
</div>
<div class="for_everyone">
<p class="normal">
i've learned that even when i feel alone, it doesn't have to feel
lonely. i know it's hard, dear. i know it's scary. but i promise it's
possible. we're all in this together. struggling together. sacrificing
together. we dedicate our lives to each you, and our art for everyone.
</p>
<p class="normal" style="font-size:2rem;color:#9C91FF;font-family:times,serif;font-style:italic"> <p
and then we knew,<br/> class="normal"
just like paper airplanes: that we could fly... style="font-size:2rem;color:#9C91FF;font-family:times,serif;font-style:italic"
</p> >
<br /> and then we knew,<br />
<p class="normal"> just like paper airplanes: that we could fly...
<a href="/" style='text-decoration:underline;text-underline-offset:0.2em;'>fin.</a> </p>
</p> <br />
</div> <p class="normal">
</> <a
href="/"
style="text-decoration:underline;text-underline-offset:0.2em;"
>
fin.
</a>
</p>
</div>
</>
);
} }
ForEveryone.class = "text"; ForEveryone.class = "text";

View file

@ -1,3 +1,5 @@
const findDomain = "paperclover.net";
export function formatSize(bytes: number) { export function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} bytes`; if (bytes < 1024) return `${bytes} bytes`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@ -6,21 +8,25 @@ export function formatSize(bytes: number) {
} }
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
} }
export function formatDate(date: Date) { export function formatDate(date: Date) {
// YYYY-MM-DD, format in PST timezone // YYYY-MM-DD, format in PST timezone
return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" }); return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });
} }
export function formatShortDate(date: Date) { export function formatShortDate(date: Date) {
// YY-MM-DD, format in PST timezone // YY-MM-DD, format in PST timezone
return formatDate(date).slice(2); return formatDate(date).slice(2);
} }
export function formatDuration(seconds: number) { export function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
} }
export const escapeUri = (uri: string) =>
encodeURIComponent(uri) export function escapeUri(uri: string) {
return encodeURIComponent(uri)
.replace(/%2F/gi, "/") .replace(/%2F/gi, "/")
.replace(/%3A/gi, ":") .replace(/%3A/gi, ":")
.replace(/%2B/gi, "+") .replace(/%2B/gi, "+")
@ -29,10 +35,7 @@ export const escapeUri = (uri: string) =>
.replace(/%5F/gi, "_") .replace(/%5F/gi, "_")
.replace(/%2E/gi, ".") .replace(/%2E/gi, ".")
.replace(/%2C/gi, ","); .replace(/%2C/gi, ",");
}
import type { MediaFile } from "../db.ts";
import { escapeHTML } from "../framework/bun-polyfill.ts";
const findDomain = "paperclover.net";
// Returns escaped HTML // Returns escaped HTML
// Features: // Features:
@ -43,7 +46,7 @@ const findDomain = "paperclover.net";
// - via name of a sibling file's basename // - via name of a sibling file's basename
// - reformat (c) into © // - reformat (c) into ©
// //
// This formatter was written with AI. // This formatter was written with AI. Then manually fixed since AI does not work.
export function highlightLinksInTextView( export function highlightLinksInTextView(
text: string, text: string,
siblingFiles: MediaFile[] = [], siblingFiles: MediaFile[] = [],
@ -55,7 +58,7 @@ export function highlightLinksInTextView(
); );
// First escape the HTML to prevent XSS // First escape the HTML to prevent XSS
let processedText = escapeHTML(text); let processedText = escapeHtml(text);
// Replace (c) with © // Replace (c) with ©
processedText = processedText.replace(/\(c\)/gi, "©"); processedText = processedText.replace(/\(c\)/gi, "©");
@ -116,14 +119,11 @@ export function highlightLinksInTextView(
// Case 4: ./ relative paths // Case 4: ./ relative paths
if (match.startsWith("./")) { if (match.startsWith("./")) {
const filename = match.substring(2); const filename = match.substring(2);
// Check if the filename exists in sibling files
const siblingFile = siblingFiles.find((f) => f.basename === filename); const siblingFile = siblingFiles.find((f) => f.basename === filename);
if (siblingFile) { if (siblingFile) {
return `<a href="/file/${siblingFile.path}">${match}</a>`; return `<a href="/file/${siblingFile.path}">${match}</a>`;
} }
// If no exact match but we have sibling files, try to create a reasonable link
if (siblingFiles.length > 0) { if (siblingFiles.length > 0) {
const currentDir = siblingFiles[0].path const currentDir = siblingFiles[0].path
.split("/") .split("/")
@ -138,21 +138,13 @@ export function highlightLinksInTextView(
// Match sibling file names (only if they're not already part of a link) // Match sibling file names (only if they're not already part of a link)
if (siblingFiles.length > 0) { if (siblingFiles.length > 0) {
// Create a regex pattern that matches any of the sibling file basenames
// We need to escape special regex characters in the filenames
const escapedBasenames = siblingFiles.map((f) => const escapedBasenames = siblingFiles.map((f) =>
f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
); );
// Join all basenames with | for the regex alternation
const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g"); const pattern = new RegExp(`\\b(${escapedBasenames.join("|")})\\b`, "g");
// We need to be careful not to replace text that's already in a link
// So we'll split the text by HTML tags and only process the text parts
const parts = processedText.split(/(<[^>]*>)/); const parts = processedText.split(/(<[^>]*>)/);
for (let i = 0; i < parts.length; i += 2) { for (let i = 0; i < parts.length; i += 2) {
// Only process text parts (even indices), not HTML tags (odd indices)
if (i < parts.length) { if (i < parts.length) {
parts[i] = parts[i].replace(pattern, (match: string) => { parts[i] = parts[i].replace(pattern, (match: string) => {
const file = siblingLookup[match]; const file = siblingLookup[match];
@ -262,3 +254,6 @@ export function highlightHashComments(text: string) {
}) })
.join("\n"); .join("\n");
} }
import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
import { escapeHtml } from "#ssr";

View file

@ -0,0 +1,57 @@
const db = getDb("cache.sqlite");
db.table(
"blob_assets",
/* SQL */ `
CREATE TABLE IF NOT EXISTS blob_assets (
hash TEXT PRIMARY KEY,
refs INTEGER NOT NULL DEFAULT 0
);
`,
);
/**
* Uncompressed files are read directly from the media store root. Compressed
* files are stored as `<compress store>/<first 2 chars of hash>/<hash>` Since
* multiple files can share the same hash, the number of references is tracked
* so that when a file is deleted, the compressed data is only removed when all
* references are gone.
*/
export class BlobAsset {
/** sha1 of the contents */
hash!: string;
refs!: number;
decrementOrDelete() {
BlobAsset.decrementOrDelete(this.hash);
}
static get(hash: string) {
return getQuery.get(hash);
}
static putOrIncrement(hash: string) {
ASSERT(hash.length === 40);
putOrIncrementQuery.get(hash);
return BlobAsset.get(hash)!;
}
static decrementOrDelete(hash: string) {
ASSERT(hash.length === 40);
decrementQuery.run(hash);
return deleteQuery.run(hash).changes > 0;
}
}
const getQuery = db.prepare<[hash: string]>(/* SQL */ `
SELECT * FROM blob_assets WHERE hash = ?;
`).as(BlobAsset);
const putOrIncrementQuery = db.prepare<[hash: string]>(/* SQL */ `
INSERT INTO blob_assets (hash, refs) VALUES (?, 1)
ON CONFLICT(hash) DO UPDATE SET refs = refs + 1;
`);
const decrementQuery = db.prepare<[hash: string]>(/* SQL */ `
UPDATE blob_assets SET refs = refs - 1 WHERE hash = ? AND refs > 0;
`);
const deleteQuery = db.prepare<[hash: string]>(/* SQL */ `
DELETE FROM blob_assets WHERE hash = ? AND refs <= 0;
`);
import { getDb } from "#sitegen/sqlite";

View file

@ -0,0 +1,59 @@
const db = getDb("cache.sqlite");
db.table(
"permissions",
/* SQL */ `
CREATE TABLE IF NOT EXISTS permissions (
prefix TEXT PRIMARY KEY,
allow INTEGER NOT NULL
);
`,
);
export class FilePermissions {
prefix!: string;
/** Currently set to 1 always */
allow!: number;
// -- static ops --
static getByPrefix(filePath: string): number {
return getByPrefixQuery.get(filePath)?.allow ?? 0;
}
static getExact(filePath: string): number {
return getExactQuery.get(filePath)?.allow ?? 0;
}
static setPermissions(prefix: string, allow: number) {
if (allow) {
insertQuery.run({ prefix, allow });
} else {
deleteQuery.run(prefix);
}
}
}
const getByPrefixQuery = db.prepare<
[prefix: string],
Pick<FilePermissions, "allow">
>(/* SQL */ `
SELECT allow
FROM permissions
WHERE ? GLOB prefix || '*'
ORDER BY LENGTH(prefix) DESC
LIMIT 1;
`);
const getExactQuery = db.prepare<
[file: string],
Pick<FilePermissions, "allow">
>(/* SQL */ `
SELECT allow FROM permissions WHERE ? == prefix
`);
const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
REPLACE INTO permissions(prefix, allow) VALUES($prefix, $allow);
`);
const deleteQuery = db.prepare<[file: string]>(/* SQL */ `
DELETE FROM permissions WHERE prefix = ?;
`);
import { getDb } from "#sitegen/sqlite";

View file

@ -0,0 +1,336 @@
const db = getDb("cache.sqlite");
db.table(
"media_files",
/* SQL */ `
CREATE TABLE IF NOT EXISTS media_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER,
path TEXT UNIQUE,
kind INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
timestamp_updated INTEGER NOT NULL DEFAULT NOW,
hash TEXT NOT NULL,
size INTEGER NOT NULL,
duration INTEGER NOT NULL DEFAULT 0,
dimensions TEXT NOT NULL DEFAULT "",
contents TEXT NOT NULL,
dirsort TEXT,
processed INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES media_files(id)
);
-- Index for quickly looking up files by path
CREATE INDEX IF NOT EXISTS media_files_path ON media_files (path);
-- Index for finding directories that need to be processed
CREATE INDEX IF NOT EXISTS media_files_directory_processed ON media_files (kind, processed);
`,
);
export enum MediaFileKind {
directory = 0,
file = 1,
}
export class MediaFile {
id!: number;
parent_id!: number;
/**
* Has leading slash, does not have `/file` prefix.
* @example "/2025/waterfalls/waterfalls.mp3"
*/
path!: string;
kind!: MediaFileKind;
private timestamp!: number;
private timestamp_updated!: number;
/** for mp3/mp4 files, measured in seconds */
duration?: number;
/** for images and videos, the dimensions. Two numbers split by `x` */
dimensions?: string;
/**
* sha1 of
* - files: the contents
* - directories: the JSON array of strings + the content of `readme.txt`
* this is used
* - to inform changes in caching mechanisms (etag, page render cache)
* - as a filename for compressed files (.clover/compressed/<hash>.{gz,zstd})
*/
hash!: string;
/**
* Depends on the file kind.
*
* - For directories, this is the contents of `readme.txt`, if it exists.
* - Otherwise, it is an empty string.
*/
contents!: string;
/**
* For directories, if this is set, it is a JSON-encoded array of the explicit
* sorting order. Derived off of `.dirsort` files.
*/
dirsort!: string | null;
/** in bytes */
size!: number;
/**
* 0 - not processed
* 1 - processed
*
* file: this is for compression
* directory: this is for re-indexing contents
*/
processed!: number;
// -- instance ops --
get date() {
return new Date(this.timestamp);
}
get lastUpdateDate() {
return new Date(this.timestamp_updated);
}
parseDimensions() {
const dimensions = this.dimensions;
if (!dimensions) return null;
const [width, height] = dimensions.split("x").map(Number);
return { width, height };
}
get basename() {
return path.basename(this.path);
}
get basenameWithoutExt() {
return path.basename(this.path, path.extname(this.path));
}
get extension() {
return path.extname(this.path);
}
getChildren() {
return MediaFile.getChildren(this.id)
.filter((file) => !file.basename.startsWith("."));
}
getPublicChildren() {
const children = MediaFile.getChildren(this.id);
if (FilePermissions.getByPrefix(this.path) == 0) {
return children.filter(({ path }) => FilePermissions.getExact(path) == 0);
}
return children;
}
getParent() {
const dirPath = this.path;
if (dirPath === "/") return null;
const parentPath = path.dirname(dirPath);
if (parentPath === dirPath) return null;
const result = MediaFile.getByPath(parentPath);
if (!result) return null;
ASSERT(result.kind === MediaFileKind.directory);
return result;
}
setCompressed(compressed: boolean) {
MediaFile.markCompressed(this.id, compressed);
}
// -- static ops --
static getByPath(filePath: string): MediaFile | null {
const result = getByPathQuery.get(filePath);
if (result) return result;
if (filePath === "/") {
return Object.assign(new MediaFile(), {
id: 0,
parent_id: 0,
path: "/",
kind: MediaFileKind.directory,
timestamp: 0,
timestamp_updated: Date.now(),
hash: "0".repeat(40),
contents: "the file scanner has not been run yet",
dirsort: null,
size: 0,
processed: 1,
});
}
return null;
}
static createFile({
path: filePath,
date,
hash,
size,
duration = 0,
dimensions = "",
content = "",
}: CreateFile) {
createFileQuery.get({
path: filePath,
parentId: MediaFile.getOrPutDirectoryId(path.dirname(filePath)),
timestamp: date.getTime(),
timestampUpdated: Date.now(),
hash,
size,
duration,
dimensions,
contents: content,
});
}
static getOrPutDirectoryId(filePath: string) {
filePath = path.posix.normalize(filePath);
const row = getDirectoryIdQuery.get(filePath) as { id: number };
if (row) return row.id;
let current = filePath;
let parts = [];
let parentId: null | number = 0;
if (filePath === "/") {
return createDirectoryQuery.run(filePath, 0).lastInsertRowid as number;
}
// walk down the path until we find a directory that exists
do {
parts.unshift(path.basename(current));
current = path.dirname(current);
parentId = (getDirectoryIdQuery.get(current) as { id: number })?.id;
} while (parentId == undefined && current !== "/");
if (parentId == undefined) {
parentId = createDirectoryQuery.run({
path: current,
parentId: 0,
}).lastInsertRowid as number;
}
// walk back up the path, creating directories as needed
for (const part of parts) {
current = path.join(current, part);
ASSERT(parentId != undefined);
parentId = createDirectoryQuery.run({ path: current, parentId })
.lastInsertRowid as number;
}
return parentId;
}
static markDirectoryProcessed({
id,
timestamp,
contents,
size,
hash,
dirsort,
}: MarkDirectoryProcessed) {
markDirectoryProcessedQuery.get({
id,
timestamp: timestamp.getTime(),
contents,
dirsort: dirsort ? JSON.stringify(dirsort) : "",
hash,
size,
});
}
static markCompressed(id: number, compressed: boolean) {
markCompressedQuery.run({ id, processed: compressed ? 2 : 1 });
}
static createOrUpdateDirectory(dirPath: string) {
const id = MediaFile.getOrPutDirectoryId(dirPath);
return updateDirectoryQuery.get(id);
}
static getChildren(id: number) {
return getChildrenQuery.array(id);
}
}
// Create a `file` entry with a given path, date, file hash, size, and duration
// If the file already exists, update the date and duration.
// If the file exists and the hash is different, sets `compress` to 0.
interface CreateFile {
path: string;
date: Date;
hash: string;
size: number;
duration?: number;
dimensions?: string;
content?: string;
}
// Set the `processed` flag true and update the metadata for a directory
export interface MarkDirectoryProcessed {
id: number;
timestamp: Date;
contents: string;
size: number;
hash: string;
dirsort: null | string[];
}
export interface DirConfig {
/** Overridden sorting */
sort: string[];
}
// -- queries --
// Get a directory ID by path, creating it if it doesn't exist
const createDirectoryQuery = db.prepare<[{ path: string; parentId: number }]>(
/* SQL */ `
insert into media_files (
path, parent_id, kind, timestamp, hash, size,
duration, dimensions, contents, dirsort, processed)
values (
$path, $parentId, ${MediaFileKind.directory}, 0, '', 0,
0, '', '', '', 0);
`,
);
const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};
`);
const createFileQuery = db.prepare<[{
path: string;
parentId: number;
timestamp: number;
timestampUpdated: number;
hash: string;
size: number;
duration: number;
dimensions: string;
contents: string;
}], void>(/* SQL */ `
insert into media_files (
path, parent_id, kind, timestamp, timestamp_updated, hash,
size, duration, dimensions, contents, processed)
values (
$path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
$hash, $size, $duration, $dimensions, $contents, 0)
on conflict(path) do update set
timestamp = excluded.timestamp,
timestamp_updated = excluded.timestamp_updated,
duration = excluded.duration,
size = excluded.size,
contents = excluded.contents,
processed = case
when media_files.hash != excluded.hash then 0
else media_files.processed
end;
`);
const markCompressedQuery = db.prepare<[{
id: number;
processed: number;
}]>(/* SQL */ `
update media_files set processed = $processed where id = $id;
`);
const getByPathQuery = db.prepare<[string]>(/* SQL */ `
select * from media_files where path = ?;
`).as(MediaFile);
const markDirectoryProcessedQuery = db.prepare<[{
timestamp: number;
contents: string;
dirsort: string;
hash: string;
size: number;
id: number;
}]>(/* SQL */ `
update media_files set
processed = 1,
timestamp = $timestamp,
contents = $contents,
dirsort = $dirsort,
hash = $hash,
size = $size
where id = $id;
`);
const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
update media_files set processed = 0 where id = ?;
`);
const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
select * from media_files where parent_id = ?;
`).as(MediaFile);
import { getDb } from "#sitegen/sqlite";
import * as path from "node:path";
import { FilePermissions } from "./FilePermissions.ts";

View file

@ -1,8 +1,7 @@
import { MediaFile } from "../db"; import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
import { useInlineScript } from "../framework/page-resources"; import { addScript } from "#sitegen";
import { Readme } from "../media/cotyledon"; import { Readme } from "@/file-viewer/cotyledon.tsx";
import { MediaPanel } from "../pages-dynamic/file_viewer"; import { MediaPanel } from "../views/clofi.tsx";
import "../media/files.css";
export const theme = { export const theme = {
bg: "#312652", bg: "#312652",
@ -10,9 +9,10 @@ export const theme = {
primary: "#fabe32", primary: "#fabe32",
}; };
export const meta = { title: "living room" };
export default function CotyledonPage() { export default function CotyledonPage() {
useInlineScript("canvas_cotyledon"); addScript("../scripts/canvas_cotyledon.client.ts");
useInlineScript("file_viewer");
return ( return (
<div class="files ctld ctld-et"> <div class="files ctld ctld-et">
<MediaPanel <MediaPanel

View file

@ -1,8 +1,7 @@
import { MediaFile } from "../db"; import { MediaFile } from "../models/MediaFile.ts";
import { useInlineScript } from "../framework/page-resources"; import { Speedbump } from "../cotyledon.tsx";
import { Speedbump } from "../media/cotyledon"; import { MediaPanel } from "../views/clofi.tsx";
import { MediaPanel } from "../pages-dynamic/file_viewer"; import { addScript } from "#sitegen";
import "../media/files.css";
export const theme = { export const theme = {
bg: "#312652", bg: "#312652",
@ -10,9 +9,10 @@ export const theme = {
primary: "#fabe32", primary: "#fabe32",
}; };
export const meta = { title: "the front door" };
export default function CotyledonPage() { export default function CotyledonPage() {
useInlineScript("canvas_cotyledon"); addScript("../scripts/canvas_cotyledon.client.ts");
useInlineScript("file_viewer");
return ( return (
<div class="files ctld ctld-sb"> <div class="files ctld ctld-sb">
<MediaPanel <MediaPanel

143
src/file-viewer/rules.ts Normal file
View file

@ -0,0 +1,143 @@
// -- file extension rules --
/** Extensions that must have EXIF/etc data stripped */
const extScrubExif = new Set([
".jpg",
".jpeg",
".png",
".mov",
".mp4",
".m4a",
]);
/** Extensions that rendered syntax-highlighted code */
const extsCode = new Map<string, highlight.Language>(Object.entries({
".json": "json",
".toml": "toml",
".ts": "ts",
".js": "ts",
".tsx": "tsx",
".jsx": "tsx",
".css": "css",
".py": "python",
".lua": "lua",
".sh": "shell",
".bat": "dosbatch",
".ps1": "powershell",
".cmd": "dosbatch",
".yaml": "yaml",
".yml": "yaml",
".zig": "zig",
".astro": "astro",
".mdx": "mdx",
".xml": "xml",
".jsonc": "json",
".php": "php",
".patch": "diff",
".diff": "diff",
}));
/** These files show an audio embed. */
const extsAudio = new Set([
".mp3",
".flac",
".wav",
".ogg",
".m4a",
]);
/** These files show a video embed. */
const extsVideo = new Set([
".mp4",
".mkv",
".webm",
".avi",
".mov",
]);
/** These files show an image embed */
const extsImage = new Set([
".jpg",
".jpeg",
".png",
".gif",
".webp",
".avif",
".heic",
".svg",
]);
/** These files populate `duration` using `ffprobe` */
export const extsDuration = new Set([...extsAudio, ...extsVideo]);
/** These files populate `dimensions` using `ffprobe` */
export const extsDimensions = new Set([...extsImage, ...extsVideo]);
/** These files read file contents into `contents`, as-is */
export const extsReadContents = new Set([".txt", ".chat"]);
export const extsArchive = new Set([
".zip",
".rar",
".7z",
".tar",
".gz",
".bz2",
".xz",
]);
/**
* Formats which are already compression formats, meaning a pass
* through zstd would offer little to negative benefits
*/
export const extsHaveCompression = new Set([
...extsAudio,
...extsVideo,
...extsImage,
...extsArchive,
// TODO: are any of these NOT good for compression
]);
export function fileIcon(
file: Pick<MediaFile, "kind" | "basename" | "path">,
dirOpen?: boolean,
) {
const { kind, basename } = file;
if (kind === MediaFileKind.directory) return dirOpen ? "dir-open" : "dir";
// -- special cases --
if (file.path === "/2024/for everyone") return "snow";
// -- basename cases --
if (basename === "readme.txt") return "readme";
// -- extension cases --
const ext = path.extname(file.basename).toLowerCase();
if ([".comp", ".fuse", ".setting"].includes(ext)) return "fusion";
if ([".json", ".toml", ".yaml", ".yml"].includes(ext)) return "json";
if (ext === ".blend") return "blend";
if (ext === ".chat") return "chat";
if (ext === ".html") return "webpage";
if (ext === ".lnk") return "link";
if (ext === ".txt" || ext === ".md") return "text";
// -- extension categories --
if (extsVideo.has(ext)) return "video";
if (extsAudio.has(ext)) return "audio";
if (extsImage.has(ext)) return "image";
if (extsArchive.has(ext)) return "archive";
if (extsCode.has(ext)) return "code";
return "file";
}
// -- viewer rules --
const pathToCanvas = new Map<string, string>(Object.entries({
"/2017": "2017",
"/2018": "2018",
"/2019": "2019",
"/2020": "2020",
"/2021": "2021",
"/2022": "2022",
"/2023": "2023",
"/2024": "2024",
}));
import type * as highlight from "./highlight.ts";
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
import * as path from "node:path";

View file

@ -1,4 +1,4 @@
// Vibe coded with AI // Vibe coded with AI. Heavily tuned.
(globalThis as any).canvas_2020 = function (canvas: HTMLCanvasElement) { (globalThis as any).canvas_2020 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true"; const isStandalone = canvas.getAttribute("data-standalone") === "true";
// Rain effect with slanted lines // Rain effect with slanted lines

View file

@ -1,4 +1,11 @@
// Vibe coded. // Initially vibe coded with AI, but a lot of tuning was done manually
// for it to feel natural. Some of the tuning was done through AI and some
// was manual. This implementation is not very performant and might get
// re-visited, but it runs mostly-fine, mostly in chromium.
//
// The parts that need improvement are how particles are computed. Those
// nested loops take way too long. 2d Canvas is fine for rendering. A
// good chance moving computation to WASM and rendering to JS would help.
(globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) { (globalThis as any).canvas_2021 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true"; const isStandalone = canvas.getAttribute("data-standalone") === "true";
// Constants for simulation // Constants for simulation

View file

@ -1,3 +1,4 @@
// Written by AI. Options tuned with AI.
(globalThis as any).canvas_2022 = function (canvas: HTMLCanvasElement) { (globalThis as any).canvas_2022 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true"; const isStandalone = canvas.getAttribute("data-standalone") === "true";
// Configuration for the grid of rotating squares // Configuration for the grid of rotating squares

View file

@ -1,3 +1,5 @@
// Partially vibe coded. A lot of manually tuning with the heart scale function
// and how the mouse interacts with the hearts.
(globalThis as any).canvas_2023 = function (canvas: HTMLCanvasElement) { (globalThis as any).canvas_2023 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true"; const isStandalone = canvas.getAttribute("data-standalone") === "true";
const config = { const config = {

View file

@ -1,4 +1,4 @@
// Vibe coded with AI // Vibe coded with AI, manually tuned randomness shader + opacity.
(globalThis as any).canvas_2024 = function (canvas: HTMLCanvasElement) { (globalThis as any).canvas_2024 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true"; const isStandalone = canvas.getAttribute("data-standalone") === "true";
if (isStandalone) { if (isStandalone) {

View file

@ -1,3 +1,4 @@
// This canvas was written partially by AI
// @ts-ignore // @ts-ignore
globalThis.canvas_cotyledon = function ( globalThis.canvas_cotyledon = function (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,

View file

@ -1,23 +1,15 @@
import "./clofi.css";
import assert from "node:assert";
import path, { dirname } from "node:path";
import { MediaFile } from "../db.ts";
import { useInlineScript } from "../framework/page-resources.ts";
import { escapeUri, formatDuration, formatSize } from "../media/share.ts";
import {
highlightConvo,
highlightHashComments,
highlightLinksInTextView,
} from "../media/text-formatting.ts";
import { TestID } from "../test/id.ts";
import { ForEveryone } from "../media/cotyledon.tsx";
export const theme = { export const theme = {
bg: "#312652", bg: "#312652",
fg: "#f0f0ff", fg: "#f0f0ff",
primary: "#fabe32", primary: "#fabe32",
}; };
export function meta({ file }: { file: MediaFile }) {
if (file.path === "/") return { title: "clo's files" };
return { title: file.basename + " - clo's files" };
}
// TODO: use rules.ts
export const extensionToViewer: { export const extensionToViewer: {
[key: string]: (props: { [key: string]: (props: {
file: MediaFile; file: MediaFile;
@ -78,7 +70,7 @@ export default function MediaList({
file: MediaFile; file: MediaFile;
hasCotyledonCookie: boolean; hasCotyledonCookie: boolean;
}) { }) {
useInlineScript("file_viewer"); addScript("./clofi.client.ts");
const dirs: MediaFile[] = []; const dirs: MediaFile[] = [];
let dir: MediaFile | null = file; let dir: MediaFile | null = file;
@ -130,7 +122,7 @@ export function MediaPanel({
) )
: ( : (
<> <>
{file.kind === MediaFile.Kind.directory {file.kind === MediaFileKind.directory
? ( ? (
<span> <span>
{file.basename} {file.basename}
@ -184,7 +176,7 @@ export function MediaPanel({
"/2024": "2024", "/2024": "2024",
}; };
if (canvases[file.path]) { if (canvases[file.path]) {
useInlineScript(`canvas_${canvases[file.path]}` as any); addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
} }
return ( return (
<div <div
@ -198,7 +190,7 @@ export function MediaPanel({
> >
</canvas> </canvas>
)} )}
{file.kind === MediaFile.Kind.directory && ( {file.kind === MediaFileKind.directory && (
<a class="custom header" href={`/file${escapeUri(file.path)}`}> <a class="custom header" href={`/file${escapeUri(file.path)}`}>
<div class="ico ico-dir-open"></div> <div class="ico ico-dir-open"></div>
{label} {label}
@ -210,7 +202,7 @@ export function MediaPanel({
{label} {label}
</div> </div>
{file.kind === MediaFile.Kind.directory {file.kind === MediaFileKind.directory
? ( ? (
<DirView <DirView
dir={file} dir={file}
@ -276,7 +268,7 @@ function sortDefault(a: MediaFile, b: MediaFile) {
// Then sort directories before files // Then sort directories before files
if (a.kind !== b.kind) { if (a.kind !== b.kind) {
return a.kind === MediaFile.Kind.directory ? -1 : 1; return a.kind === MediaFileKind.directory ? -1 : 1;
} }
// Finally sort by date (newest first), then by name (a-z) if dates are the same // Finally sort by date (newest first), then by name (a-z) if dates are the same
@ -433,7 +425,7 @@ function ListItem({
let basenameWithoutExt = file.basenameWithoutExt; let basenameWithoutExt = file.basenameWithoutExt;
let meta = file.kind === MediaFile.Kind.directory let meta = file.kind === MediaFileKind.directory
? formatSize(file.size) ? formatSize(file.size)
: (file.duration ?? 0) > 0 : (file.duration ?? 0) > 0
? formatDuration(file.duration!) ? formatDuration(file.duration!)
@ -468,7 +460,7 @@ function ListItem({
)} )}
{basenameWithoutExt} {basenameWithoutExt}
<span class="ext">{file.extension}</span> <span class="ext">{file.extension}</span>
{file.kind === MediaFile.Kind.directory {file.kind === MediaFileKind.directory
? <span class="bold-slash">/</span> ? <span class="bold-slash">/</span>
: ( : (
"" ""
@ -482,7 +474,7 @@ function ListItem({
} }
function fileIcon(file: MediaFile, dirOpen?: boolean) { function fileIcon(file: MediaFile, dirOpen?: boolean) {
if (file.kind === MediaFile.Kind.directory) { if (file.kind === MediaFileKind.directory) {
return dirOpen ? "dir-open" : "dir"; return dirOpen ? "dir-open" : "dir";
} }
if (file.basename === "readme.txt") { if (file.basename === "readme.txt") {
@ -712,7 +704,7 @@ function TextView({
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: highlightLinksInTextView( __html: highlightLinksInTextView(
contents, contents,
siblingFiles.filter((f) => f.kind === MediaFile.Kind.file), siblingFiles.filter((f) => f.kind === MediaFileKind.file),
), ),
}} }}
></pre> ></pre>
@ -752,7 +744,7 @@ function CodeView({ file }: { file: MediaFile }) {
} }
function VideoView({ file }: { file: MediaFile }) { function VideoView({ file }: { file: MediaFile }) {
useInlineScript("video_player"); addScript("@/tags/hls-polyfill.client.ts");
const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 }; const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
return ( return (
<> <>
@ -768,7 +760,7 @@ function VideoView({ file }: { file: MediaFile }) {
> >
<source <source
src={"/file" + file.path} src={"/file" + file.path}
type={contentTypeFromExt(file.path)} type={mime.contentTypeFor(file.path)}
/> />
</video> </video>
</> </>
@ -791,7 +783,7 @@ function AudioView({
// - there is lyrics.txt in the same directory // - there is lyrics.txt in the same directory
const audioFiles = siblings.filter( const audioFiles = siblings.filter(
(f) => (f) =>
f.kind === MediaFile.Kind.file && f.kind === MediaFileKind.file &&
extensionToViewer[path.extname(f.basename)] === AudioView, extensionToViewer[path.extname(f.basename)] === AudioView,
); );
if ( if (
@ -967,12 +959,12 @@ function ReadmeView({
extra?: any; extra?: any;
}) { }) {
// let showFileCandidates = siblingFiles.filter(f => // let showFileCandidates = siblingFiles.filter(f =>
// f.kind === MediaFile.Kind.file // f.kind === MediaFileKind.file
// && (extensionToViewer[path.extname(f.basename)] === AudioView // && (extensionToViewer[path.extname(f.basename)] === AudioView
// || extensionToViewer[path.extname(f.basename)] === VideoView) // || extensionToViewer[path.extname(f.basename)] === VideoView)
// ); // );
return ( return (
<div class="content readme" data-testid={TestID.Readme}> <div class="content readme">
{/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */} {/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */}
<TextView file={file} siblingFiles={siblingFiles} /> <TextView file={file} siblingFiles={siblingFiles} />
{extra} {extra}
@ -986,18 +978,6 @@ function simplifyFraction(n: number, d: number) {
return `${n / divisor}/${d / divisor}`; return `${n / divisor}/${d / divisor}`;
} }
export function contentTypeFromExt(src: string) {
src = src.toLowerCase();
if (src.endsWith(".m3u8")) return "application/x-mpegURL";
if (src.endsWith(".webm")) return "video/webm";
if (src.endsWith(".mp4")) return "video/mp4";
if (src.endsWith(".mkv")) return "video/x-matroska";
if (src.endsWith(".avi")) return "video/x-msvideo";
if (src.endsWith(".mov")) return "video/quicktime";
if (src.endsWith(".ogg")) return "video/ogg";
throw new Error("Unknown video extension: " + path.extname(src));
}
// Add class properties to the components // Add class properties to the components
AudioView.class = "audio"; AudioView.class = "audio";
ImageView.class = "image"; ImageView.class = "image";
@ -1005,3 +985,19 @@ VideoView.class = "video";
TextView.class = "text"; TextView.class = "text";
DownloadView.class = "download"; DownloadView.class = "download";
CodeView.class = "code"; CodeView.class = "code";
import "./clofi.css";
import assert from "node:assert";
import path, { dirname } from "node:path";
import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
import { addScript } from "#sitegen";
import {
escapeUri,
formatDuration,
formatSize,
highlightConvo,
highlightHashComments,
highlightLinksInTextView,
} from "@/file-viewer/format.ts";
import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
import * as mime from "#sitegen/mime";

View file

@ -72,4 +72,4 @@ app.post("/friends", async (c) => {
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
import { serveAsset } from "#sitegen/assets"; import { serveAsset } from "#sitegen/assets";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
import { getConnInfo } from "hono/bun"; import { getConnInfo } from "#hono/conninfo";

View file

@ -1,3 +1,5 @@
export const meta = { title: "password required" };
<main> <main>
<p>incorrect or outdated password</p> <p>incorrect or outdated password</p>
<p>please contact clover</p> <p>please contact clover</p>

View file

@ -1,3 +1,5 @@
export const meta = { title: "password required" };
<main> <main>
<form method="post" action="/friends"> <form method="post" action="/friends">
<h1>what's the password</h1> <h1>what's the password</h1>

View file

@ -1,4 +1,7 @@
import { Video } from "@/tags/Video.tsx"; import { Video } from "@/tags/Video.tsx";
export const meta = { title: "waterfalls by paper clover" };
export default () => ( export default () => (
<main class="waterfalls"> <main class="waterfalls">
<div class="waterfalls_bg1" /> <div class="waterfalls_bg1" />

View file

@ -1,7 +1,7 @@
import { EditorState } from "@codemirror/state"; import { EditorState } from "@codemirror/state";
import { basicSetup, EditorView } from "codemirror"; import { basicSetup, EditorView } from "codemirror";
import { ssrSync } from "#ssr"; import { ssrSync } from "#ssr";
import { ScriptPayload } from "@/q+a/view/editor.marko"; import type { ScriptPayload } from "@/q+a/views/editor.marko";
import QuestionRender from "@/q+a/tags/question.marko"; import QuestionRender from "@/q+a/tags/question.marko";
declare const payload: ScriptPayload; declare const payload: ScriptPayload;

View file

@ -18,7 +18,7 @@ export interface ScriptPayload {
<const/{ question } = input /> <const/{ question } = input />
<const/payload= { <const/payload = {
id: question.id, id: question.id,
qmid: question.qmid, qmid: question.qmid,
text: question.text, text: question.text,

View file

@ -13,4 +13,4 @@ export interface Input {
<question question=input.question /> <question question=input.question />
</main> </main>
import { Question } from '@/q+a/models/Question'; import { Question } from '@/q+a/models/Question.ts';

View file

@ -40,6 +40,7 @@ export function PhotoGrid({
heights, heights,
items, items,
}: PhotoGridProps) { }: PhotoGridProps) {
return;
if (!base.endsWith("/")) base += "/"; if (!base.endsWith("/")) base += "/";
let rows: boolean[][] = []; let rows: boolean[][] = [];
const row = (y: number) => (rows[y] ??= new Array(width).fill(false)); const row = (y: number) => (rows[y] ??= new Array(width).fill(false));

View file

@ -1,7 +1,8 @@
import "./video.css";
import * as path from "node:path"; import * as path from "node:path";
import { addScript } from "#sitegen"; import { addScript } from "#sitegen";
import { PrecomputedBlurhash } from "./blurhash.tsx"; import { PrecomputedBlurhash } from "./blurhash.tsx";
import "./Video.css";
export namespace Video { export namespace Video {
export interface Props { export interface Props {
title: string; title: string;
@ -14,11 +15,12 @@ export namespace Video {
borderless?: boolean; borderless?: boolean;
} }
} }
export function Video( export function Video(
{ title, sources, height, poster, posterHash, width, borderless }: { title, sources, height, poster, posterHash, width, borderless }:
Video.Props, Video.Props,
) { ) {
addScript("./video.client.ts"); addScript("./hls-polyfill.client.ts");
return ( return (
<figure class={`video ${borderless ? "borderless" : ""}`}> <figure class={`video ${borderless ? "borderless" : ""}`}>
<figcaption>{title}</figcaption> <figcaption>{title}</figcaption>

25
src/tags/video.css Normal file
View file

@ -0,0 +1,25 @@
.video {
border: 4px solid var(--fg);
display: flex;
flex-direction: column;
position: relative;
}
.video > img,
.video > span {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.video figcaption {
background-color: var(--fg);
color: var(--bg);
width: 100%;
margin-top: -1px;
padding-bottom: 2px;
}