diff --git a/framework/bundle.ts b/framework/bundle.ts index df9025d..65273ee 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -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(" !x.startsWith("@paperclover")), }); @@ -204,6 +213,7 @@ export async function bundleServerJavaScript( fileWithMagicWord, }, sources: Object.keys(metafile.inputs).filter((x) => + !x.includes(", + props: Record, ) => Exclude; /** diff --git a/framework/lib/mime.ts b/framework/lib/mime.ts index 3560a8c..af270bd 100644 --- a/framework/lib/mime.ts +++ b/framework/lib/mime.ts @@ -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("."))); diff --git a/framework/lib/sqlite.ts b/framework/lib/sqlite.ts index 4018c18..59fd63c 100644 --- a/framework/lib/sqlite.ts +++ b/framework/lib/sqlite.ts @@ -48,7 +48,14 @@ export class WrappedDatabase { prepare( query: string, ): Stmt { - 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); } } diff --git a/meow.txt b/meow.txt deleted file mode 100644 index 65b5969..0000000 Binary files a/meow.txt and /dev/null differ diff --git a/package.json b/package.json index c7679c1..838562f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/repl.js b/repl.js index f23342d..1deb62b 100644 --- a/repl.js +++ b/repl.js @@ -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"); diff --git a/src/backend.ts b/src/backend.ts index 3cba15e..eb4d29f 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -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); diff --git a/src/file-viewer/models/FilePermission.ts b/src/blog/helpers.ts similarity index 100% rename from src/file-viewer/models/FilePermission.ts rename to src/blog/helpers.ts diff --git a/src/blog/layout.tsx b/src/blog/layout.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/blog/pages/25/marko-intro.markodown b/src/blog/pages/25/marko-intro.markodown index 3804ca6..14a0983 100644 --- a/src/blog/pages/25/marko-intro.markodown +++ b/src/blog/pages/25/marko-intro.markodown @@ -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 diff --git a/src/file-viewer/backend.tsx b/src/file-viewer/backend.tsx index aa383bc..c5b72d8 100644 --- a/src/file-viewer/backend.tsx +++ b/src/file-viewer/backend.tsx @@ -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"; diff --git a/src/file-viewer/bin/scan3.ts b/src/file-viewer/bin/scan3.ts new file mode 100644 index 0000000..9725acb --- /dev/null +++ b/src/file-viewer/bin/scan3.ts @@ -0,0 +1,2 @@ +import "@/file-viewer/models/MediaFile.ts"; +import "@/file-viewer/models/BlobAsset.ts"; diff --git a/src/file-viewer/cache.ts b/src/file-viewer/cache.ts index c2865a8..fd85eeb 100644 --- a/src/file-viewer/cache.ts +++ b/src/file-viewer/cache.ts @@ -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 diff --git a/src/file-viewer/cotyledon.tsx b/src/file-viewer/cotyledon.tsx index 85b4669..d76ed63 100644 --- a/src/file-viewer/cotyledon.tsx +++ b/src/file-viewer/cotyledon.tsx @@ -137,155 +137,215 @@ export function Readme() { } export function ForEveryone() { - // deno-fmt-ignore - return <>
-

today is my 21st birthday. april 30th, 2025.

-

it's been nearly six months starting hormones.

-

sometimes i feel great,

-

sometimes i get dysphoria.

-

with the walls around me gone

-

that shit hits way harder than it did before.

-

ugh..

-

i'm glad the pain i felt is now explained,

-

but now rendered in high definition.

-

the smallest strands of hair on my face and belly act

-

as sharpened nails to pierce my soul.

-

-

it's all a pathway to better days; the sun had risen.

-

one little step at a time for both of us.

-

today i quit my job. free falling, it feels so weird.

-

like sky diving.

-

the only thing i feel is cold wind.

-

the only thing i see is everything,

-

and it's beautiful.

-

i have a month of falling before the parachute activates,

-

gonna spend as much time of it on art as i can.

-

that was, after all, my life plan:

-

i wanted to make art, all the time,

-

for everyone.

-

-

then you see what happened

-

to the world and the internet.

-

i never really got to live through that golden age,

-

it probably sucked back then too.

-

but now the big sites definitely stopped being fun.

-

they slide their cold hands up my body

-

and feel me around. it's unwelcoming, and

-

inconsiderate to how sensitive my skin is.

-

i'm so fucking glad i broke up with YouTube

-

and their devilish friends.

-

my NAS is at 5 / 24 TB

-

and probably wont fill for the next decade.

-

-

it took 2 months for me to notice my body changed.

-

that day was really nice, but it hurt a lot.

-

a sharp, satisfying pain in my chest gave me life.

-

learned new instincts for my arms

-

so they'd stop poking my new shape.

-

when i look at my face

-

it's like a different person.

-

she was the same as before, but completely new.

-

something changed

-

or i'm now used to seeing what makes me smile.

-

regardless, whatever i see in the mirror, i smile.

-

and, i don't hear that old name much anymore

-

aside from nightmares. and you'll never repeat it, ok?

-

okay.

-

-

been playing 'new canaan' by 'bill wurtz' on loop

-

in the background.

-

it kinda just feels right.

-

especially when that verse near the end comes on.

-

-

more people have been allowed to visit me.

-

my apartment used to be just for me,

-

but the more i felt like a person

-

the more i felt like having others over.

-

still have to decorate and clean it a little,

-

but it isn't a job to do alone.

-

we dragged a giant a rug across the city one day,

-

and it felt was like anything was possible.

-

sometimes i have ten people visit in a day,

-

or sometimes i focus my little eyes on just one.

-

i never really know what i want to do

-

until the time actually comes.

-

-{/* FILIP */} -

i think about the times i was by the water with you.

-

the sun setting warmly, icy air fell on our shoulders.

-{/* NATALIE */} -

and how we walked up to the top of that hill,

-

you picked up and disposed a nail on the ground,

-

walking the city thru places i've never been.

-{/* BEN */} -

or hiking through the park talking about compilers,

-

tiring me out until i'd fall asleep in your arms.

-{/* ELENA */} -

and the way you held on to my hand as i woke up,

-

noticing how i was trying to hide nightmare's tears.

-

-{/* HIGH SCHOOL */} -

i remember we were yelling lyrics loudly,

-

out of key yet cheered on because it was fun.

-{/* ADVAITH/NATALIE */} -

and when we all toured the big corporate office,

-{/* AYU/HARRIS */} -

then snuck in to some startup's office after hours;

-

i don't remember what movie we watched.

-{/* COLLEGE, DAY 1 IN EV's ROOM */} -

i remember laying on the bunk bed,

-

while the rest played a card game.

-{/* MEGHAN/MORE */} -

with us all laying on the rug, staring at the TV

-

as the ending twist to {/* SEVERANCE */'that show'} was revealed.

-

-

all the moments i cherish,

-

i love because it was always me.

-

i didn't have to pretend,

-

even if i didn't know who i was at the time.

-

you all were there. for me.

-

-

i don't want to pretend any more

-

i want to be myself. for everyone.

-

-

oh, the song ended. i thought it was on loop?

-

it's late... can hear the crickets...

-

and i can almost see the moon... mmmm...

-

...nah, too much light pollution.

-

-

one day. one day.

-

-

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.

-
-
-
-

journal - 2024-09-14

-

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.
-okay.. oof... its still clouding my mind
-i cant shake that feeling away

-

hold on...

-

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.

-

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.

-

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.

-

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.

-

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.

-

waiting on a reply from someone from healthcare. it'll be slow, but it will be okay.

-
-
-
-

-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. -

+ return ( + <> +
+

today is my 21st birthday. april 30th, 2025.

+

it's been nearly six months starting hormones.

+

sometimes i feel great,

+

sometimes i get dysphoria.

+

with the walls around me gone

+

that shit hits way harder than it did before.

+

ugh..

+

i'm glad the pain i felt is now explained,

+

but now rendered in high definition.

+

the smallest strands of hair on my face and belly act

+

as sharpened nails to pierce my soul.

+

+

it's all a pathway to better days; the sun had risen.

+

one little step at a time for both of us.

+

today i quit my job. free falling, it feels so weird.

+

like sky diving.

+

the only thing i feel is cold wind.

+

the only thing i see is everything,

+

and it's beautiful.

+

i have a month of falling before the parachute activates,

+

gonna spend as much time of it on art as i can.

+

that was, after all, my life plan:

+

i wanted to make art, all the time,

+

for everyone.

+

+

then you see what happened

+

to the world and the internet.

+

i never really got to live through that golden age,

+

it probably sucked back then too.

+

but now the big sites definitely stopped being fun.

+

they slide their cold hands up my body

+

and feel me around. it's unwelcoming, and

+

inconsiderate to how sensitive my skin is.

+

i'm so fucking glad i broke up with YouTube

+

and their devilish friends.

+

my NAS is at 5 / 24 TB

+

and probably wont fill for the next decade.

+

+

it took 2 months for me to notice my body changed.

+

that day was really nice, but it hurt a lot.

+

a sharp, satisfying pain in my chest gave me life.

+

learned new instincts for my arms

+

so they'd stop poking my new shape.

+

when i look at my face

+

it's like a different person.

+

she was the same as before, but completely new.

+

something changed

+

or i'm now used to seeing what makes me smile.

+

regardless, whatever i see in the mirror, i smile.

+

and, i don't hear that old name much anymore

+

aside from nightmares. and you'll never repeat it, ok?

+

okay.

+

+

been playing 'new canaan' by 'bill wurtz' on loop

+

in the background.

+

it kinda just feels right.

+

especially when that verse near the end comes on.

+

+

more people have been allowed to visit me.

+

my apartment used to be just for me,

+

but the more i felt like a person

+

the more i felt like having others over.

+

still have to decorate and clean it a little,

+

but it isn't a job to do alone.

+

we dragged a giant a rug across the city one day,

+

and it felt was like anything was possible.

+

sometimes i have ten people visit in a day,

+

or sometimes i focus my little eyes on just one.

+

i never really know what i want to do

+

until the time actually comes.

+

+ {/* FILIP */} +

i think about the times i was by the water with you.

+

the sun setting warmly, icy air fell on our shoulders.

+ {/* NATALIE */} +

and how we walked up to the top of that hill,

+

you picked up and disposed a nail on the ground,

+

walking the city thru places i've never been.

+ {/* BEN */} +

or hiking through the park talking about compilers,

+

tiring me out until i'd fall asleep in your arms.

+ {/* ELENA */} +

and the way you held on to my hand as i woke up,

+

noticing how i was trying to hide nightmare's tears.

+

+ {/* HIGH SCHOOL */} +

i remember we were yelling lyrics loudly,

+

out of key yet cheered on because it was fun.

+ {/* ADVAITH/NATALIE */} +

and when we all toured the big corporate office,

+ {/* AYU/HARRIS */} +

then snuck in to some startup's office after hours;

+

i don't remember what movie we watched.

+ {/* COLLEGE, DAY 1 IN EV's ROOM */} +

i remember laying on the bunk bed,

+

while the rest played a card game.

+ {/* MEGHAN/MORE */} +

with us all laying on the rug, staring at the TV

+

+ as the ending twist to {/* SEVERANCE */ "that show"} was revealed. +

+

+

all the moments i cherish,

+

i love because it was always me.

+

i didn't have to pretend,

+

even if i didn't know who i was at the time.

+

you all were there. for me.

+

+

i don't want to pretend any more

+

i want to be myself. for everyone.

+

+

oh, the song ended. i thought it was on loop?

+

it's late... can hear the crickets...

+

and i can almost see the moon... mmmm...

+

...nah, too much light pollution.

+

+

one day. one day.

+

+

+ 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. +

+
+
+
+

journal - 2024-09-14

+

+ 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.
+ okay.. oof... its still clouding my mind
+ i cant shake that feeling away +

+

hold on...

+

+ 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. +

+

+ 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. +

+

+ 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. +

+

+ 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. +

+

+ 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. +

+

+ waiting on a reply from someone from healthcare. it'll be slow, but + it will be okay. +

+
+
+
+

+ 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. +

-

- and then we knew,
- just like paper airplanes: that we could fly... -

-
-

- fin. -

-
- +

+ and then we knew,
+ just like paper airplanes: that we could fly... +

+
+

+ + fin. + +

+
+ + ); } ForEveryone.class = "text"; diff --git a/src/file-viewer/format.ts b/src/file-viewer/format.ts index 65fc14b..23b2e52 100644 --- a/src/file-viewer/format.ts +++ b/src/file-viewer/format.ts @@ -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 `${match}`; } - // 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"; diff --git a/src/file-viewer/models/BlobAsset.ts b/src/file-viewer/models/BlobAsset.ts index e69de29..359a516 100644 --- a/src/file-viewer/models/BlobAsset.ts +++ b/src/file-viewer/models/BlobAsset.ts @@ -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 `//` 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"; diff --git a/src/file-viewer/models/FilePermissions.ts b/src/file-viewer/models/FilePermissions.ts new file mode 100644 index 0000000..e9574d1 --- /dev/null +++ b/src/file-viewer/models/FilePermissions.ts @@ -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 +>(/* SQL */ ` + SELECT allow + FROM permissions + WHERE ? GLOB prefix || '*' + ORDER BY LENGTH(prefix) DESC + LIMIT 1; +`); +const getExactQuery = db.prepare< + [file: string], + Pick +>(/* 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"; diff --git a/src/file-viewer/models/MediaFile.ts b/src/file-viewer/models/MediaFile.ts index e69de29..00e3453 100644 --- a/src/file-viewer/models/MediaFile.ts +++ b/src/file-viewer/models/MediaFile.ts @@ -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/.{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"; diff --git a/src/file-viewer/pages/file.cotyledon_enterance.tsx b/src/file-viewer/pages/file.cotyledon_enterance.tsx index 6f9c148..ea3a63c 100644 --- a/src/file-viewer/pages/file.cotyledon_enterance.tsx +++ b/src/file-viewer/pages/file.cotyledon_enterance.tsx @@ -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 (
(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, + 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(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"; diff --git a/src/file-viewer/scripts/canvas_2017.ts b/src/file-viewer/scripts/canvas_2017.client.ts similarity index 100% rename from src/file-viewer/scripts/canvas_2017.ts rename to src/file-viewer/scripts/canvas_2017.client.ts diff --git a/src/file-viewer/scripts/canvas_2018.ts b/src/file-viewer/scripts/canvas_2018.client.ts similarity index 100% rename from src/file-viewer/scripts/canvas_2018.ts rename to src/file-viewer/scripts/canvas_2018.client.ts diff --git a/src/file-viewer/scripts/canvas_2019.ts b/src/file-viewer/scripts/canvas_2019.client.ts similarity index 100% rename from src/file-viewer/scripts/canvas_2019.ts rename to src/file-viewer/scripts/canvas_2019.client.ts diff --git a/src/file-viewer/scripts/canvas_2020.ts b/src/file-viewer/scripts/canvas_2020.client.ts similarity index 99% rename from src/file-viewer/scripts/canvas_2020.ts rename to src/file-viewer/scripts/canvas_2020.client.ts index 16170c4..a4c0b72 100644 --- a/src/file-viewer/scripts/canvas_2020.ts +++ b/src/file-viewer/scripts/canvas_2020.client.ts @@ -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 diff --git a/src/file-viewer/scripts/canvas_2021.ts b/src/file-viewer/scripts/canvas_2021.client.ts similarity index 97% rename from src/file-viewer/scripts/canvas_2021.ts rename to src/file-viewer/scripts/canvas_2021.client.ts index 3c319cf..cb87701 100644 --- a/src/file-viewer/scripts/canvas_2021.ts +++ b/src/file-viewer/scripts/canvas_2021.client.ts @@ -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 diff --git a/src/file-viewer/scripts/canvas_2022.ts b/src/file-viewer/scripts/canvas_2022.client.ts similarity index 99% rename from src/file-viewer/scripts/canvas_2022.ts rename to src/file-viewer/scripts/canvas_2022.client.ts index 4f03027..fe48544 100644 --- a/src/file-viewer/scripts/canvas_2022.ts +++ b/src/file-viewer/scripts/canvas_2022.client.ts @@ -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 diff --git a/src/file-viewer/scripts/canvas_2023.ts b/src/file-viewer/scripts/canvas_2023.client.ts similarity index 98% rename from src/file-viewer/scripts/canvas_2023.ts rename to src/file-viewer/scripts/canvas_2023.client.ts index ce8bf1c..222f192 100644 --- a/src/file-viewer/scripts/canvas_2023.ts +++ b/src/file-viewer/scripts/canvas_2023.client.ts @@ -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 = { diff --git a/src/file-viewer/scripts/canvas_2024.ts b/src/file-viewer/scripts/canvas_2024.client.ts similarity index 99% rename from src/file-viewer/scripts/canvas_2024.ts rename to src/file-viewer/scripts/canvas_2024.client.ts index 6bab57f..c09951a 100644 --- a/src/file-viewer/scripts/canvas_2024.ts +++ b/src/file-viewer/scripts/canvas_2024.client.ts @@ -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) { diff --git a/src/file-viewer/scripts/canvas_cotyledon.ts b/src/file-viewer/scripts/canvas_cotyledon.client.ts similarity index 99% rename from src/file-viewer/scripts/canvas_cotyledon.ts rename to src/file-viewer/scripts/canvas_cotyledon.client.ts index b5e2ef0..d328439 100644 --- a/src/file-viewer/scripts/canvas_cotyledon.ts +++ b/src/file-viewer/scripts/canvas_cotyledon.client.ts @@ -1,3 +1,4 @@ +// This canvas was written partially by AI // @ts-ignore globalThis.canvas_cotyledon = function ( canvas: HTMLCanvasElement, diff --git a/src/file-viewer/views/clofi.tsx b/src/file-viewer/views/clofi.tsx index 4f4b7f6..b42ef54 100644 --- a/src/file-viewer/views/clofi.tsx +++ b/src/file-viewer/views/clofi.tsx @@ -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 ? ( {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 (
)} - {file.kind === MediaFile.Kind.directory && ( + {file.kind === MediaFileKind.directory && (
{label} @@ -210,7 +202,7 @@ export function MediaPanel({ {label}
- {file.kind === MediaFile.Kind.directory + {file.kind === MediaFileKind.directory ? ( 0 ? formatDuration(file.duration!) @@ -468,7 +460,7 @@ function ListItem({ )} {basenameWithoutExt} {file.extension} - {file.kind === MediaFile.Kind.directory + {file.kind === MediaFileKind.directory ? / : ( "" @@ -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), ), }} > @@ -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 }) { > @@ -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 ( -
+
{/* {showFileCandidates.length === 1 && } */} {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"; diff --git a/src/friend-auth.ts b/src/friend-auth.ts index 82eb377..e0c362b 100644 --- a/src/friend-auth.ts +++ b/src/friend-auth.ts @@ -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"; diff --git a/src/pages/friends/auth.fail.marko b/src/pages/friends/auth.fail.marko index 2c539dd..d03b9a2 100644 --- a/src/pages/friends/auth.fail.marko +++ b/src/pages/friends/auth.fail.marko @@ -1,3 +1,5 @@ +export const meta = { title: "password required" }; +

incorrect or outdated password

please contact clover

diff --git a/src/pages/friends/auth.marko b/src/pages/friends/auth.marko index 15435a1..bea6ed3 100644 --- a/src/pages/friends/auth.marko +++ b/src/pages/friends/auth.marko @@ -1,3 +1,5 @@ +export const meta = { title: "password required" }; +

what's the password

diff --git a/src/pages/waterfalls.tsx b/src/pages/waterfalls.tsx index aba224e..e3f3aa8 100644 --- a/src/pages/waterfalls.tsx +++ b/src/pages/waterfalls.tsx @@ -1,4 +1,7 @@ import { Video } from "@/tags/Video.tsx"; + +export const meta = { title: "waterfalls by paper clover" }; + export default () => (
diff --git a/src/q+a/scripts/editor.client.tsx b/src/q+a/scripts/editor.client.tsx index 139ba0e..9d9f9bf 100644 --- a/src/q+a/scripts/editor.client.tsx +++ b/src/q+a/scripts/editor.client.tsx @@ -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; diff --git a/src/q+a/views/editor.marko b/src/q+a/views/editor.marko index 6b27743..a11f736 100644 --- a/src/q+a/views/editor.marko +++ b/src/q+a/views/editor.marko @@ -18,7 +18,7 @@ export interface ScriptPayload { -
-import { Question } from '@/q+a/models/Question'; +import { Question } from '@/q+a/models/Question.ts'; diff --git a/src/tags/PhotoGrid.tsx b/src/tags/PhotoGrid.tsx index 824b0c7..7ebec64 100644 --- a/src/tags/PhotoGrid.tsx +++ b/src/tags/PhotoGrid.tsx @@ -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)); diff --git a/src/tags/Video.tsx b/src/tags/Video.tsx index 9796d8b..a0d6a19 100644 --- a/src/tags/Video.tsx +++ b/src/tags/Video.tsx @@ -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 (
{title}
diff --git a/src/tags/video.client.ts b/src/tags/hls-polyfill.client.ts similarity index 100% rename from src/tags/video.client.ts rename to src/tags/hls-polyfill.client.ts diff --git a/src/tags/video.css b/src/tags/video.css new file mode 100644 index 0000000..b168df2 --- /dev/null +++ b/src/tags/video.css @@ -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; +} +