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

View file

@ -1,11 +1,12 @@
const entries = fs.readFileSync(
path.join(import.meta.dirname, "mime.txt"),
"utf8",
)
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => line.split(/\s+/, 2) as [string, string]);
declare const MIME_INLINE_DATA: never;
const entries = typeof MIME_INLINE_DATA !== "undefined"
? MIME_INLINE_DATA
: fs.readFileSync(path.join(import.meta.dirname, "mime.txt"), "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"))
.map((line) => line.split(/\s+/, 2) as [string, string]);
export const rawEntriesText = entries;
const extensions = 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>(
query: string,
): 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"
},
"#hono": "hono",
"#hono/platform": {
"#hono/conninfo": {
"bun": "hono/bun",
"deno": "hono/deno",
"node": "@hono/node-server",
"node": "@hono/node-server/conninfo",
"worker": "hono/cloudflare-workers"
}
}

View file

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

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

View file

@ -5,6 +5,7 @@ export const blog: BlogMeta = {
draft: true,
};
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
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";
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();
export const app = new Hono();
interface APIDirectoryList {
path: string;
@ -86,13 +73,13 @@ app.get("/file/*", async (c, next) => {
}
// File listings
if (file.kind === MediaFile.Kind.directory) {
if (file.kind === MediaFileKind.directory) {
if (c.req.header("Accept")?.includes("application/json")) {
const json = {
path: file.path,
files: file.getPublicChildren().map((f) => ({
basename: f.basename,
dir: f.kind === MediaFile.Kind.directory,
dir: f.kind === MediaFileKind.directory,
time: f.date.getTime(),
size: f.size,
duration: f.duration ? f.duration : null,
@ -101,7 +88,7 @@ app.get("/file/*", async (c, next) => {
} satisfies APIDirectoryList;
return c.json(json);
}
c.res = await renderDynamicPage(c.req.raw, "file_viewer", {
c.res = await renderView(c, "file-viewer/clofi", {
file,
hasCotyledonCookie,
});
@ -115,7 +102,7 @@ app.get("/file/*", async (c, next) => {
}
if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {
prefetchFile(file.path);
c.res = await renderDynamicPage(c.req.raw, "file_viewer", {
c.res = await renderView(c, "file-viewer/clofi", {
file,
hasCotyledonCookie,
});
@ -220,7 +207,7 @@ app.get("/canvas/:script", async (c, next) => {
if (!hasAsset(`/js/canvas/${script}.js`)) {
return next();
}
return renderDynamicPage(c.req.raw, "canvas", {
return renderView(c, "file-viewer/canvas", {
script,
});
});
@ -238,7 +225,7 @@ function fileHeaders(
) {
return {
Vary: "Accept-Encoding, Accept",
"Content-Type": mimeType(file.path),
"Content-Type": contentTypeFor(file.path),
"Content-Length": size.toString(),
ETag: file.hash,
"Last-Modified": file.date.toUTCString(),
@ -318,7 +305,7 @@ function applyRangesToBuffer(
const result = new Uint8Array(rangeSize);
let offset = 0;
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;
}
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) {
if (isCotyledonPath(rawFilePath)) {
if (!checkCotyledonCookie(c)) {
let root = Speedbump();
// 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");
return c.html(html);
}
@ -416,10 +399,26 @@ function getPartialPage(c: Context, rawFilePath: string) {
hasCotyledonCookie: rawFilePath === "" && checkCotyledonCookie(c),
});
// 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);
}
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 { Buffer } from "node:buffer";
import type { ClientRequest } from "node:http";
import { LRUCache } from "lru-cache";
import LRUCache from "lru-cache";
import { open } from "node:fs/promises";
import { createHash } from "node:crypto";
import { scoped } from "@paperclover/console";
import { escapeUri } from "./share.ts";
import { escapeUri } from "./format.ts";
declare const Deno: any;
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 diskCacheMaxSize = 14 * 1024 * 1024 * 1024; // 14GB

View file

@ -137,155 +137,215 @@ export function Readme() {
}
export function ForEveryone() {
// deno-fmt-ignore
return <><div class="for_everyone">
<p>today is my 21st birthday. april 30th, 2025.</p>
<p>it's been nearly six months starting hormones.</p>
<p>sometimes i feel great,</p>
<p>sometimes i get dysphoria.</p>
<p>with the walls around me gone</p>
<p>that shit hits way harder than it did before.</p>
<p>ugh..</p>
<p>i'm glad the pain i felt is now explained,</p>
<p>but now rendered in high definition.</p>
<p>the smallest strands of hair on my face and belly act</p>
<p>as sharpened nails to pierce my soul.</p>
<p></p>
<p>it's all a pathway to better days; the sun had risen.</p>
<p>one little step at a time for both of us.</p>
<p>today i quit my job. free falling, it feels so weird.</p>
<p>like sky diving.</p>
<p>the only thing i feel is cold wind.</p>
<p>the only thing i see is everything,</p>
<p>and it's beautiful.</p>
<p>i have a month of falling before the parachute activates,</p>
<p>gonna spend as much time of it on art as i can.</p>
<p>that was, after all, my life plan:</p>
<p>i wanted to make art, all the time,</p>
<p>for everyone.</p>
<p></p>
<p>then you see what happened</p>
<p>to the world and the internet.</p>
<p>i never really got to live through that golden age,</p>
<p>it probably sucked back then too.</p>
<p>but now the big sites definitely stopped being fun.</p>
<p>they slide their cold hands up my body</p>
<p>and feel me around. it's unwelcoming, and</p>
<p>inconsiderate to how sensitive my skin is.</p>
<p>i'm so fucking glad i broke up with YouTube</p>
<p>and their devilish friends.</p>
<p>my NAS is at 5 / 24 TB</p>
<p>and probably wont fill for the next decade.</p>
<p></p>
<p>it took 2 months for me to notice my body changed.</p>
<p>that day was really nice, but it hurt a lot.</p>
<p>a sharp, satisfying pain in my chest gave me life.</p>
<p>learned new instincts for my arms</p>
<p>so they'd stop poking my new shape.</p>
<p>when i look at my face</p>
<p>it's like a different person.</p>
<p>she was the same as before, but completely new.</p>
<p>something changed</p>
<p>or i'm now used to seeing what makes me smile.</p>
<p>regardless, whatever i see in the mirror, i smile.</p>
<p>and, i don't hear that old name much anymore</p>
<p>aside from nightmares. and you'll never repeat it, ok?</p>
<p>okay.</p>
<p></p>
<p>been playing 'new canaan' by 'bill wurtz' on loop</p>
<p>in the background.</p>
<p>it kinda just feels right.</p>
<p>especially when that verse near the end comes on.</p>
<p></p>
<p>more people have been allowed to visit me.</p>
<p>my apartment used to be just for me,</p>
<p>but the more i felt like a person</p>
<p>the more i felt like having others over.</p>
<p>still have to decorate and clean it a little,</p>
<p>but it isn't a job to do alone.</p>
<p>we dragged a giant a rug across the city one day,</p>
<p>and it felt was like anything was possible.</p>
<p>sometimes i have ten people visit in a day,</p>
<p>or sometimes i focus my little eyes on just one.</p>
<p>i never really know what i want to do</p>
<p>until the time actually comes.</p>
<p></p>
{/* FILIP */}
<p>i think about the times i was by the water with you.</p>
<p>the sun setting warmly, icy air fell on our shoulders.</p>
{/* NATALIE */}
<p>and how we walked up to the top of that hill,</p>
<p>you picked up and disposed a nail on the ground,</p>
<p>walking the city thru places i've never been.</p>
{/* BEN */}
<p>or hiking through the park talking about compilers,</p>
<p>tiring me out until i'd fall asleep in your arms.</p>
{/* ELENA */}
<p>and the way you held on to my hand as i woke up,</p>
<p>noticing how i was trying to hide nightmare's tears.</p>
<p></p>
{/* HIGH SCHOOL */}
<p>i remember we were yelling lyrics loudly,</p>
<p>out of key yet cheered on because it was fun.</p>
{/* ADVAITH/NATALIE */}
<p>and when we all toured the big corporate office,</p>
{/* AYU/HARRIS */}
<p>then snuck in to some startup's office after hours;</p>
<p>i don't remember what movie we watched.</p>
{/* COLLEGE, DAY 1 IN EV's ROOM */}
<p>i remember laying on the bunk bed,</p>
<p>while the rest played a card game.</p>
{/* MEGHAN/MORE */}
<p>with us all laying on the rug, staring at the TV</p>
<p>as the ending twist to {/* SEVERANCE */'that show'} was revealed.</p>
<p></p>
<p>all the moments i cherish,</p>
<p>i love because it was always me.</p>
<p>i didn't have to pretend,</p>
<p>even if i didn't know who i was at the time.</p>
<p>you all were there. for me.</p>
<p></p>
<p>i don't want to pretend any more</p>
<p>i want to be myself. for everyone.</p>
<p></p>
<p>oh, the song ended. i thought it was on loop?</p>
<p>it's late... can hear the crickets...</p>
<p>and i can almost see the moon... mmmm...</p>
<p>...nah, too much light pollution.</p>
<p></p>
<p>one day. one day.</p>
<p></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>
</div>
<div class="for_everyone" style="max-width:80ch;">
<blockquote>
<p>journal - 2024-09-14</p>
<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/>
okay.. oof... its still clouding my mind<br/>
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>
return (
<>
<div class="for_everyone">
<p>today is my 21st birthday. april 30th, 2025.</p>
<p>it's been nearly six months starting hormones.</p>
<p>sometimes i feel great,</p>
<p>sometimes i get dysphoria.</p>
<p>with the walls around me gone</p>
<p>that shit hits way harder than it did before.</p>
<p>ugh..</p>
<p>i'm glad the pain i felt is now explained,</p>
<p>but now rendered in high definition.</p>
<p>the smallest strands of hair on my face and belly act</p>
<p>as sharpened nails to pierce my soul.</p>
<p></p>
<p>it's all a pathway to better days; the sun had risen.</p>
<p>one little step at a time for both of us.</p>
<p>today i quit my job. free falling, it feels so weird.</p>
<p>like sky diving.</p>
<p>the only thing i feel is cold wind.</p>
<p>the only thing i see is everything,</p>
<p>and it's beautiful.</p>
<p>i have a month of falling before the parachute activates,</p>
<p>gonna spend as much time of it on art as i can.</p>
<p>that was, after all, my life plan:</p>
<p>i wanted to make art, all the time,</p>
<p>for everyone.</p>
<p></p>
<p>then you see what happened</p>
<p>to the world and the internet.</p>
<p>i never really got to live through that golden age,</p>
<p>it probably sucked back then too.</p>
<p>but now the big sites definitely stopped being fun.</p>
<p>they slide their cold hands up my body</p>
<p>and feel me around. it's unwelcoming, and</p>
<p>inconsiderate to how sensitive my skin is.</p>
<p>i'm so fucking glad i broke up with YouTube</p>
<p>and their devilish friends.</p>
<p>my NAS is at 5 / 24 TB</p>
<p>and probably wont fill for the next decade.</p>
<p></p>
<p>it took 2 months for me to notice my body changed.</p>
<p>that day was really nice, but it hurt a lot.</p>
<p>a sharp, satisfying pain in my chest gave me life.</p>
<p>learned new instincts for my arms</p>
<p>so they'd stop poking my new shape.</p>
<p>when i look at my face</p>
<p>it's like a different person.</p>
<p>she was the same as before, but completely new.</p>
<p>something changed</p>
<p>or i'm now used to seeing what makes me smile.</p>
<p>regardless, whatever i see in the mirror, i smile.</p>
<p>and, i don't hear that old name much anymore</p>
<p>aside from nightmares. and you'll never repeat it, ok?</p>
<p>okay.</p>
<p></p>
<p>been playing 'new canaan' by 'bill wurtz' on loop</p>
<p>in the background.</p>
<p>it kinda just feels right.</p>
<p>especially when that verse near the end comes on.</p>
<p></p>
<p>more people have been allowed to visit me.</p>
<p>my apartment used to be just for me,</p>
<p>but the more i felt like a person</p>
<p>the more i felt like having others over.</p>
<p>still have to decorate and clean it a little,</p>
<p>but it isn't a job to do alone.</p>
<p>we dragged a giant a rug across the city one day,</p>
<p>and it felt was like anything was possible.</p>
<p>sometimes i have ten people visit in a day,</p>
<p>or sometimes i focus my little eyes on just one.</p>
<p>i never really know what i want to do</p>
<p>until the time actually comes.</p>
<p></p>
{/* FILIP */}
<p>i think about the times i was by the water with you.</p>
<p>the sun setting warmly, icy air fell on our shoulders.</p>
{/* NATALIE */}
<p>and how we walked up to the top of that hill,</p>
<p>you picked up and disposed a nail on the ground,</p>
<p>walking the city thru places i've never been.</p>
{/* BEN */}
<p>or hiking through the park talking about compilers,</p>
<p>tiring me out until i'd fall asleep in your arms.</p>
{/* ELENA */}
<p>and the way you held on to my hand as i woke up,</p>
<p>noticing how i was trying to hide nightmare's tears.</p>
<p></p>
{/* HIGH SCHOOL */}
<p>i remember we were yelling lyrics loudly,</p>
<p>out of key yet cheered on because it was fun.</p>
{/* ADVAITH/NATALIE */}
<p>and when we all toured the big corporate office,</p>
{/* AYU/HARRIS */}
<p>then snuck in to some startup's office after hours;</p>
<p>i don't remember what movie we watched.</p>
{/* COLLEGE, DAY 1 IN EV's ROOM */}
<p>i remember laying on the bunk bed,</p>
<p>while the rest played a card game.</p>
{/* MEGHAN/MORE */}
<p>with us all laying on the rug, staring at the TV</p>
<p>
as the ending twist to {/* SEVERANCE */ "that show"} was revealed.
</p>
<p></p>
<p>all the moments i cherish,</p>
<p>i love because it was always me.</p>
<p>i didn't have to pretend,</p>
<p>even if i didn't know who i was at the time.</p>
<p>you all were there. for me.</p>
<p></p>
<p>i don't want to pretend any more</p>
<p>i want to be myself. for everyone.</p>
<p></p>
<p>oh, the song ended. i thought it was on loop?</p>
<p>it's late... can hear the crickets...</p>
<p>and i can almost see the moon... mmmm...</p>
<p>...nah, too much light pollution.</p>
<p></p>
<p>one day. one day.</p>
<p></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>
</div>
<div class="for_everyone" style="max-width:80ch;">
<blockquote>
<p>journal - 2024-09-14</p>
<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 />
okay.. oof... its still clouding my mind<br />
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">
and then we knew,<br/>
just like paper airplanes: that we could fly...
</p>
<br />
<p class="normal">
<a href="/" style='text-decoration:underline;text-underline-offset:0.2em;'>fin.</a>
</p>
</div>
</>
<p
class="normal"
style="font-size:2rem;color:#9C91FF;font-family:times,serif;font-style:italic"
>
and then we knew,<br />
just like paper airplanes: that we could fly...
</p>
<br />
<p class="normal">
<a
href="/"
style="text-decoration:underline;text-underline-offset:0.2em;"
>
fin.
</a>
</p>
</div>
</>
);
}
ForEveryone.class = "text";

View file

@ -1,3 +1,5 @@
const findDomain = "paperclover.net";
export function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} bytes`;
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`;
}
export function formatDate(date: Date) {
// YYYY-MM-DD, format in PST timezone
return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });
}
export function formatShortDate(date: Date) {
// YY-MM-DD, format in PST timezone
return formatDate(date).slice(2);
}
export function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
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(/%3A/gi, ":")
.replace(/%2B/gi, "+")
@ -29,10 +35,7 @@ export const escapeUri = (uri: string) =>
.replace(/%5F/gi, "_")
.replace(/%2E/gi, ".")
.replace(/%2C/gi, ",");
import type { MediaFile } from "../db.ts";
import { escapeHTML } from "../framework/bun-polyfill.ts";
const findDomain = "paperclover.net";
}
// Returns escaped HTML
// Features:
@ -43,7 +46,7 @@ const findDomain = "paperclover.net";
// - via name of a sibling file's basename
// - 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(
text: string,
siblingFiles: MediaFile[] = [],
@ -55,7 +58,7 @@ export function highlightLinksInTextView(
);
// First escape the HTML to prevent XSS
let processedText = escapeHTML(text);
let processedText = escapeHtml(text);
// Replace (c) with ©
processedText = processedText.replace(/\(c\)/gi, "©");
@ -116,14 +119,11 @@ export function highlightLinksInTextView(
// Case 4: ./ relative paths
if (match.startsWith("./")) {
const filename = match.substring(2);
// Check if the filename exists in sibling files
const siblingFile = siblingFiles.find((f) => f.basename === filename);
if (siblingFile) {
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) {
const currentDir = siblingFiles[0].path
.split("/")
@ -138,21 +138,13 @@ export function highlightLinksInTextView(
// Match sibling file names (only if they're not already part of a link)
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) =>
f.basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
);
// Join all basenames with | for the regex alternation
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(/(<[^>]*>)/);
for (let i = 0; i < parts.length; i += 2) {
// Only process text parts (even indices), not HTML tags (odd indices)
if (i < parts.length) {
parts[i] = parts[i].replace(pattern, (match: string) => {
const file = siblingLookup[match];
@ -262,3 +254,6 @@ export function highlightHashComments(text: string) {
})
.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 { useInlineScript } from "../framework/page-resources";
import { Readme } from "../media/cotyledon";
import { MediaPanel } from "../pages-dynamic/file_viewer";
import "../media/files.css";
import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
import { addScript } from "#sitegen";
import { Readme } from "@/file-viewer/cotyledon.tsx";
import { MediaPanel } from "../views/clofi.tsx";
export const theme = {
bg: "#312652",
@ -10,9 +9,10 @@ export const theme = {
primary: "#fabe32",
};
export const meta = { title: "living room" };
export default function CotyledonPage() {
useInlineScript("canvas_cotyledon");
useInlineScript("file_viewer");
addScript("../scripts/canvas_cotyledon.client.ts");
return (
<div class="files ctld ctld-et">
<MediaPanel

View file

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

View file

@ -1,3 +1,4 @@
// Written by AI. Options tuned with AI.
(globalThis as any).canvas_2022 = function (canvas: HTMLCanvasElement) {
const isStandalone = canvas.getAttribute("data-standalone") === "true";
// 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) {
const isStandalone = canvas.getAttribute("data-standalone") === "true";
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) {
const isStandalone = canvas.getAttribute("data-standalone") === "true";
if (isStandalone) {

View file

@ -1,3 +1,4 @@
// This canvas was written partially by AI
// @ts-ignore
globalThis.canvas_cotyledon = function (
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 = {
bg: "#312652",
fg: "#f0f0ff",
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: {
[key: string]: (props: {
file: MediaFile;
@ -78,7 +70,7 @@ export default function MediaList({
file: MediaFile;
hasCotyledonCookie: boolean;
}) {
useInlineScript("file_viewer");
addScript("./clofi.client.ts");
const dirs: MediaFile[] = [];
let dir: MediaFile | null = file;
@ -130,7 +122,7 @@ export function MediaPanel({
)
: (
<>
{file.kind === MediaFile.Kind.directory
{file.kind === MediaFileKind.directory
? (
<span>
{file.basename}
@ -184,7 +176,7 @@ export function MediaPanel({
"/2024": "2024",
};
if (canvases[file.path]) {
useInlineScript(`canvas_${canvases[file.path]}` as any);
addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
}
return (
<div
@ -198,7 +190,7 @@ export function MediaPanel({
>
</canvas>
)}
{file.kind === MediaFile.Kind.directory && (
{file.kind === MediaFileKind.directory && (
<a class="custom header" href={`/file${escapeUri(file.path)}`}>
<div class="ico ico-dir-open"></div>
{label}
@ -210,7 +202,7 @@ export function MediaPanel({
{label}
</div>
{file.kind === MediaFile.Kind.directory
{file.kind === MediaFileKind.directory
? (
<DirView
dir={file}
@ -276,7 +268,7 @@ function sortDefault(a: MediaFile, b: MediaFile) {
// Then sort directories before files
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
@ -433,7 +425,7 @@ function ListItem({
let basenameWithoutExt = file.basenameWithoutExt;
let meta = file.kind === MediaFile.Kind.directory
let meta = file.kind === MediaFileKind.directory
? formatSize(file.size)
: (file.duration ?? 0) > 0
? formatDuration(file.duration!)
@ -468,7 +460,7 @@ function ListItem({
)}
{basenameWithoutExt}
<span class="ext">{file.extension}</span>
{file.kind === MediaFile.Kind.directory
{file.kind === MediaFileKind.directory
? <span class="bold-slash">/</span>
: (
""
@ -482,7 +474,7 @@ function ListItem({
}
function fileIcon(file: MediaFile, dirOpen?: boolean) {
if (file.kind === MediaFile.Kind.directory) {
if (file.kind === MediaFileKind.directory) {
return dirOpen ? "dir-open" : "dir";
}
if (file.basename === "readme.txt") {
@ -712,7 +704,7 @@ function TextView({
dangerouslySetInnerHTML={{
__html: highlightLinksInTextView(
contents,
siblingFiles.filter((f) => f.kind === MediaFile.Kind.file),
siblingFiles.filter((f) => f.kind === MediaFileKind.file),
),
}}
></pre>
@ -752,7 +744,7 @@ function CodeView({ 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 };
return (
<>
@ -768,7 +760,7 @@ function VideoView({ file }: { file: MediaFile }) {
>
<source
src={"/file" + file.path}
type={contentTypeFromExt(file.path)}
type={mime.contentTypeFor(file.path)}
/>
</video>
</>
@ -791,7 +783,7 @@ function AudioView({
// - there is lyrics.txt in the same directory
const audioFiles = siblings.filter(
(f) =>
f.kind === MediaFile.Kind.file &&
f.kind === MediaFileKind.file &&
extensionToViewer[path.extname(f.basename)] === AudioView,
);
if (
@ -967,12 +959,12 @@ function ReadmeView({
extra?: any;
}) {
// 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)] === VideoView)
// );
return (
<div class="content readme" data-testid={TestID.Readme}>
<div class="content readme">
{/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */}
<TextView file={file} siblingFiles={siblingFiles} />
{extra}
@ -986,18 +978,6 @@ function simplifyFraction(n: number, d: number) {
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
AudioView.class = "audio";
ImageView.class = "image";
@ -1005,3 +985,19 @@ VideoView.class = "video";
TextView.class = "text";
DownloadView.class = "download";
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 { serveAsset } from "#sitegen/assets";
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>
<p>incorrect or outdated password</p>
<p>please contact clover</p>

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { EditorState } from "@codemirror/state";
import { basicSetup, EditorView } from "codemirror";
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";
declare const payload: ScriptPayload;

View file

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

View file

@ -13,4 +13,4 @@ export interface Input {
<question question=input.question />
</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,
items,
}: PhotoGridProps) {
return;
if (!base.endsWith("/")) base += "/";
let rows: boolean[][] = [];
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 { addScript } from "#sitegen";
import { PrecomputedBlurhash } from "./blurhash.tsx";
import "./Video.css";
export namespace Video {
export interface Props {
title: string;
@ -14,11 +15,12 @@ export namespace Video {
borderless?: boolean;
}
}
export function Video(
{ title, sources, height, poster, posterHash, width, borderless }:
Video.Props,
) {
addScript("./video.client.ts");
addScript("./hls-polyfill.client.ts");
return (
<figure class={`video ${borderless ? "borderless" : ""}`}>
<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;
}