authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-22 14:38:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
loge258365f5bba27170707594f3548c6a9753532a5
tree3542a526d12afc339b137f2dd1fc80f6b86678c1
parent579f44c68f4fd91813fbb5adcac82a4736b6c12d
signature Commit is signed but in an unrecognized format.

file viewer work


19 files changed, 343 insertions(+), 88 deletions(-)

framework/backend/entry-node.ts+3-1
......@@ -3,7 +3,9 @@ import "#debug";
33
44const protocol = "http";
55
6const server = serve(app, ({ address, port }) => {
6const server = serve({
7 fetch: app.fetch,
8}, ({ address, port }) => {
79 if (address === "::") address = "::1";
810 console.info(url.format({
911 protocol,
framework/engine/jsx-runtime.ts+13-5
......@@ -17,13 +17,21 @@ export function jsxDEV(
1717 _key: string,
1818 // Unused with the clover engine
1919 _isStaticChildren: boolean,
20 // Unused with the clover engine
21 _source: unknown,
20 source: engine.SrcLoc,
2221): engine.Element {
22 const { fileName, lineNumber, columnNumber } = source;
23
24 // Assert the component type is valid to render.
2325 if (typeof type !== "function" && typeof type !== "string") {
24 throw new Error("Invalid component type: " + engine.inspect(type));
26 throw new Error(
27 `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
28 engine.inspect(type) +
29 ". Clover SSR element must be a function or string",
30 );
2531 }
26 return [engine.kElement, type, props];
32
33 // Construct an `ssr.Element`
34 return [engine.kElement, type, props, "", source];
2735}
2836
2937// jsxs
......@@ -37,7 +45,7 @@ declare global {
3745 interface ElementChildrenAttribute {
3846 children: Node;
3947 }
40 type Element = engine.Node;
48 type Element = engine.Element;
4149 type ElementType = keyof IntrinsicElements | engine.Component;
4250 type ElementClass = ReturnType<engine.Component>;
4351 }
framework/engine/marko-runtime.ts+4-3
......@@ -123,14 +123,15 @@ export function escapeXML(input: unknown) {
123123 // The rationale of this check is that the default toString method
124124 // creating `[object Object]` is universally useless to any end user.
125125 if (
126 input == null ||
126127 (typeof input === "object" && input &&
127128 // only block this if it's the default `toString`
128129 input.toString === Object.prototype.toString)
129130 ) {
130131 throw new Error(
131 `Unexpected object in template placeholder: '` +
132 engine.inspect({ name: "clover" }) + "'. " +
133 `To emit a literal '[object Object]', use \${String(value)}`,
132 `Unexpected value in template placeholder: '` +
133 engine.inspect(input) + "'. " +
134 `To emit a literal '${input}', use \${String(value)}`,
134135 );
135136 }
136137 return marko.escapeXML(input);
framework/engine/ssr.ts+17-3
......@@ -79,6 +79,8 @@ export type Element = [
7979 tag: typeof kElement,
8080 type: string | Component,
8181 props: Record<string, unknown>,
82 _?: "",
83 source?: SrcLoc,
8284];
8385export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
8486/**
......@@ -88,6 +90,12 @@ export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
8890export type Component = (
8991 props: Record<any, any>,
9092) => Exclude<Node, undefined>;
93/** Emitted by JSX runtime */
94export interface SrcLoc {
95 fileName: string;
96 lineNumber: number;
97 columnNumber: number;
98}
9199
92100/**
93101 * Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
......@@ -130,9 +138,15 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
130138 const { 1: tag, 2: props } = node;
131139 if (typeof tag === "function") {
132140 currentRender = r;
133 const result = tag(props);
134 currentRender = null;
135 return resolveNode(r, result);
141 try {
142 return resolveNode(r, tag(props));
143 } catch (e) {
144 const { 4: src } = node;
145 if (e && typeof e === "object") {
146 }
147 } finally {
148 currentRender = null;
149 }
136150 }
137151 if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
138152 const children = props?.children;
framework/hot.ts+1
......@@ -145,6 +145,7 @@ function loadEsbuildCode(
145145 target: "esnext",
146146 jsx: "automatic",
147147 jsxImportSource: "#ssr",
148 jsxDev: true,
148149 sourcefile: filepath,
149150 }).code;
150151 return module._compile(src, filepath, "commonjs");
src/backend.ts-1
......@@ -11,7 +11,6 @@ app.use(admin.middleware);
1111
1212app.route("", require("./q+a/backend.ts").app);
1313app.route("", require("./file-viewer/backend.tsx").app);
14// app.route("", require("./friends/backend.tsx").app);
1514
1615app.use(assets.middleware);
1716
src/file-viewer/backend.tsx+13-3
......@@ -42,9 +42,15 @@ app.post("/file/cotyledon", async (c) => {
4242});
4343
4444app.get("/file/*", async (c, next) => {
45 if (c.req.header("User-Agent")?.toLowerCase()?.includes("discordbot")) {
45 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
46 const lofi = ua.includes("msie") || ua.includes("rv:") || false;
47
48 // Discord ignores 'robots.txt' which violates the license agreement.
49 if (ua.includes("discordbot")) {
4650 return next();
4751 }
52 console.log(ua, lofi);
53
4854 let rawFilePath = c.req.path.slice(5) || "/";
4955 if (rawFilePath.endsWith("$partial")) {
5056 return getPartialPage(c, rawFilePath.slice(0, -"$partial".length));
......@@ -88,7 +94,7 @@ app.get("/file/*", async (c, next) => {
8894 } satisfies APIDirectoryList;
8995 return c.json(json);
9096 }
91 c.res = await renderView(c, "file-viewer/clofi", {
97 c.res = await renderView(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, {
9298 file,
9399 hasCotyledonCookie,
94100 });
......@@ -100,7 +106,11 @@ app.get("/file/*", async (c, next) => {
100106 if (c.req.query("dl") !== undefined) {
101107 viewMode = "download";
102108 }
103 if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {
109 if (
110 viewMode == undefined &&
111 c.req.header("Accept")?.includes("text/html") &&
112 !lofi
113 ) {
104114 prefetchFile(file.path);
105115 c.res = await renderView(c, "file-viewer/clofi", {
106116 file,
src/file-viewer/format.ts+31-9
......@@ -9,15 +9,15 @@ export function formatSize(bytes: number) {
99 return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
1010}
1111
12export function formatDate(date: Date) {
13 // YYYY-MM-DD, format in PST timezone
14 return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });
15}
16
17export function formatShortDate(date: Date) {
18 // YY-MM-DD, format in PST timezone
19 return formatDate(date).slice(2);
20}
12// export function formatDateDefined(date: Date) {
13// // YYYY-MM-DD, format in PST timezone
14// return date.toLocaleDateString("sv", { timeZone: "America/Los_Angeles" });
15// }
16//
17// export function formatShortDate(date: Date) {
18// // YY-MM-DD, format in PST timezone
19// return formatDate(date).slice(2);
20// }
2121
2222export function formatDuration(seconds: number) {
2323 const minutes = Math.floor(seconds / 60);
......@@ -255,5 +255,27 @@ export function highlightHashComments(text: string) {
255255 .join("\n");
256256}
257257
258const unknownDate = new Date("1970-01-03");
259const unknownDateWithKnownYear = new Date("1970-02-20");
260
261export function formatDate(dateTime: Date) {
262 return dateTime < unknownDateWithKnownYear
263 ? (
264 dateTime < unknownDate
265 ? (
266 "??.??.??"
267 )
268 : `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
269 )
270 : (
271 `${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
272 dateTime
273 .getDate()
274 .toString()
275 .padStart(2, "0")
276 }.${dateTime.getFullYear().toString().slice(2)}`
277 );
278}
279
258280import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
259281import { escapeHtml } from "#ssr";
src/file-viewer/pages/file.404.tsx created+34
......@@ -0,0 +1,34 @@
1import { MediaFile } from "../models/MediaFile.ts";
2import { MediaPanel } from "../views/clofi.tsx";
3import { addScript } from "#sitegen";
4
5export const theme = {
6 bg: "#312652",
7 fg: "#f0f0ff",
8 primary: "#fabe32",
9};
10
11export const meta = { title: "file not found" };
12
13export default function CotyledonPage() {
14 addScript("../scripts/canvas_cotyledon.client.ts");
15 return (
16 <div class="files ctld ctld-sb">
17 <MediaPanel
18 file={MediaFile.getByPath("/")!}
19 isLast={false}
20 activeFilename={null}
21 hasCotyledonCookie={false}
22 />
23 <div class="panel last">
24 <div className="header"></div>
25 <div className="content file-view notfound">
26 <p>this file does not exist ...</p>
27 <p>
28 <a href="/file">return</a>
29 </p>
30 </div>
31 </div>
32 </div>
33 );
34}
src/file-viewer/pages/file.cotyledon_speedbump.tsx+1-1
......@@ -1,7 +1,7 @@
11import { MediaFile } from "../models/MediaFile.ts";
2import { Speedbump } from "../cotyledon.tsx";
32import { MediaPanel } from "../views/clofi.tsx";
43import { addScript } from "#sitegen";
4import { Speedbump } from "../cotyledon.tsx";
55
66export const theme = {
77 bg: "#312652",
src/file-viewer/sort.ts created+58
......@@ -0,0 +1,58 @@
1export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
2 const children = dir.getPublicChildren();
3 let readme: MediaFile | null = null;
4
5 const groups = {
6 // years 2025 and onwards
7 years: [] as MediaFile[],
8 // named categories
9 categories: [] as MediaFile[],
10 // years 2017 to 2024
11 cotyledon: [] as MediaFile[],
12 };
13 const colorMap: Record<string, string> = {
14 years: "#a2ff91",
15 categories: "#9c91ff",
16 cotyledon: "#ff91ca",
17 };
18 for (const child of children) {
19 const basename = child.basename;
20 if (basename === "readme.txt") {
21 readme = child;
22 continue;
23 }
24
25 const year = basename.match(/^(\d{4})/);
26 if (year) {
27 const n = parseInt(year[1]);
28 if (n >= 2025) {
29 groups.years.push(child);
30 } else {
31 groups.cotyledon.push(child);
32 }
33 } else {
34 groups.categories.push(child);
35 }
36 }
37
38 let sections = [];
39 for (const [key, files] of Object.entries(groups)) {
40 if (key === "cotyledon" && !hasCotyledonCookie) {
41 continue;
42 }
43 if (key === "years" || key === "cotyledon") {
44 files.sort((a, b) => {
45 return b.basename.localeCompare(a.basename);
46 });
47 } else {
48 files.sort((a, b) => {
49 return a.basename.localeCompare(b.basename);
50 });
51 }
52 sections.push({ key, titleColor: colorMap[key], files });
53 }
54
55 return { readme, sections };
56}
57
58import { MediaFile } from "./models/MediaFile.ts";
src/file-viewer/views/canvas.astro deleted-12
......@@ -1,12 +0,0 @@
1---
2import { useInlineScript } from "../framework/page-resources.ts";
3
4const { script } = Astro.props;
5useInlineScript('canvas_' + script as any);
6useInlineScript('canvas_demo');
7---
8<canvas id="canvas"
9 data-canvas={script}
10 data-standalone="true"
11 style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
12></canvas>
src/file-viewer/views/canvas.marko created+17
......@@ -0,0 +1,17 @@
1export interface Input {
2 script: string;
3}
4export const meta = { title: "canvas" };
5
6<const/{ script } = input />
7
8<canvas id="canvas"
9 data-canvas=script
10 data-standalone="true"
11 style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
12></canvas>
13<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts" />
14
15client import "./canvas.client.ts";
16
17import { addScript as AddScript } from '#sitegen';
src/file-viewer/views/clofi.css+9
......@@ -687,6 +687,15 @@ h3 {
687687 transform: translateY(0);
688688 }
689689}
690.notfound {
691 p {
692 text-align: center;
693 }
694 a {
695 text-decoration: underline;
696 }
697}
698
690699/* MOBILE */
691700@media (max-width: 1000px) {
692701 html, body {
src/file-viewer/views/clofi.tsx+5-49
......@@ -406,7 +406,7 @@ function ListItem({
406406 noDate?: boolean;
407407}) {
408408 const dateTime = file.date;
409 let shortDate = dateTime < unknownDateWithKnownYear
409 const shortDate = dateTime < unknownDateWithKnownYear
410410 ? (
411411 dateTime < unknownDate
412412 ? (
......@@ -565,40 +565,7 @@ function RootDirView({
565565 hasCotyledonCookie: boolean;
566566}) {
567567 const children = dir.getPublicChildren();
568 let readme: MediaFile | null = null;
569
570 const groups = {
571 // years 2025 and onwards
572 years: [] as MediaFile[],
573 // named categories
574 categories: [] as MediaFile[],
575 // years 2017 to 2024
576 cotyledon: [] as MediaFile[],
577 };
578 const colorMap = {
579 years: "#a2ff91",
580 categories: "#9c91ff",
581 cotyledon: "#ff91ca",
582 };
583 for (const child of children) {
584 const basename = child.basename;
585 if (basename === readmeFile) {
586 readme = child;
587 continue;
588 }
589
590 const year = basename.match(/^(\d{4})/);
591 if (year) {
592 const n = parseInt(year[1]);
593 if (n >= 2025) {
594 groups.years.push(child);
595 } else {
596 groups.cotyledon.push(child);
597 }
598 } else {
599 groups.categories.push(child);
600 }
601 }
568 const { readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie);
602569
603570 if (readme && isLast) activeFilename ||= readmeFile;
604571
......@@ -613,22 +580,10 @@ function RootDirView({
613580 />
614581 </ul>
615582 )}
616 {Object.entries(groups).map(([key, files]) => {
617 if (key === "cotyledon" && !hasCotyledonCookie) {
618 return null;
619 }
620 if (key === "years" || key === "cotyledon") {
621 files.sort((a, b) => {
622 return b.basename.localeCompare(a.basename);
623 });
624 } else {
625 files.sort((a, b) => {
626 return a.basename.localeCompare(b.basename);
627 });
628 }
583 {sections.map(({ key, titleColor, files }) => {
629584 return (
630585 <div class="group" data-group={key}>
631 <h3 style={`color:${colorMap[key as keyof typeof colorMap]};`}>
586 <h3 style={{ color: titleColor }}>
632587 {key}
633588 </h3>
634589 <ul>
......@@ -1001,3 +956,4 @@ import {
1001956} from "@/file-viewer/format.ts";
1002957import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
1003958import * as mime from "#sitegen/mime";
959import * as sort from "../sort.ts";
src/file-viewer/views/lofi.css created+43
......@@ -0,0 +1,43 @@
1body {
2 margin: 0;
3 padding: 0;
4}
5#lofi {
6 padding: 32px;
7}
8h1 {
9 margin-top: 0;
10 font-size: 3em;
11 color: var(--primary);
12 font-family: monospace;
13}
14ul, li {
15 margin: 0;
16 padding: 0;
17 list-style-type: none;
18}
19ul {
20 padding-right: 4em;
21}
22li a {
23 display: block;
24 color: white;
25 line-height: 2em;
26 padding: 0 1em;
27 border-radius: 4px;
28}
29li a:hover {
30 background-color: rgba(255,255,255,0.2);
31 font-weight: bold;
32 text-decoration: none!important;
33}
34.dir a {
35 color: #99eeFF
36}
37.ext {
38 opacity: 0.5;
39}
40.meta {
41 margin-left: 1em;
42 opacity: 0.75;
43}
src/file-viewer/views/lofi.marko created+71
......@@ -0,0 +1,71 @@
1import "./lofi.css";
2export interface Input {
3 file: MediaFile;
4 hasCotyledonCookie: boolean;
5}
6export { meta, theme } from "./clofi.tsx";
7
8<const/{ file: dir, hasCotyledonCookie } = input />
9<const/{ path: fullPath, dirname, basename, kind } = dir />
10<const/isRoot = fullPath == '/'>
11
12<div#lofi>
13
14<define/ListItem|{ value: file }|>
15 <const/dir=file.kind === MediaFileKind.directory/>
16 <const/meta=(
17 dir
18 ? formatSize(file.size)
19 : (file.duration ?? 0) > 0
20 ? formatDuration(file.duration!)
21 : null
22 )/>
23 <li class={ dir }>
24 <a href=`/file${escapeUri(file.path)}` >
25 <code>${formatDate(file.date)}</>${" "}
26 ${file.basenameWithoutExt}<span class="ext">${file.extension}</>${dir ? '/' : ''}
27 <if=meta><span class='meta'>(${meta})</></>
28 </a>
29 </li>
30</define>
31
32<h1>
33 <if=isRoot>clo's files</>
34 <else>${fullPath}</>
35</h1>
36
37<if=isRoot>
38 <const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />
39 <if=readme><ul><ListItem=readme/></ul></>
40 <for|{key, files, titleColor }| of=sections>
41 <h2 style={color: titleColor }>${key}</>
42 <ul>
43 <for|item| of=files><ListItem=item /></>
44 </ul>
45 </>
46 <if=!hasCotyledonCookie>
47 <br><br><br><br><br><br>
48 <p style={ opacity: 0.3 }>
49 would you like to
50 <a
51 href="/file/cotyledon"
52 style={
53 color: 'white',
54 'text-decoration': 'underline',
55 }
56 >dive deeper?</a>
57 </p>
58 </>
59</><else>
60 <ul>
61 <li><a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>[up one...]</a></li>
62 <for|item| of=dir.getChildren()> <ListItem=item /> </>
63 </ul>
64</>
65
66</div>
67
68import * as path from "node:path";
69import { escapeUri, formatDuration, formatSize, formatDate } from "@/file-viewer/format.ts";
70import { MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
71import * as sort from "@/file-viewer/sort.ts";
src/file-viewer/views/lofideon.marko created+22
......@@ -0,0 +1,22 @@
1export interface Input {
2 stage: number
3}
4export const meta = { title: 'C O T Y L E D O N' };
5export const theme = {
6 bg: '#ff00ff',
7 fg: '#000000',
8};
9
10<h1>co<br>ty<br>le<br>don</h1>
11
12<if=input.stage==0>
13
14<p>
15this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.
16</p><p>
17by entering our museum, you agree not to use your camera. flash off isn't enough; the bits and bytes are alergic even to a camera's sensor
18</p><p>
19<sub>(in english: please do not store downloads after you're done viewing them)</sub>
20</p>
21
22</>
tsconfig.json+1-1
......@@ -3,7 +3,7 @@
33 "allowImportingTsExtensions": true,
44 "baseUrl": ".",
55 "incremental": true,
6 "jsx": "react-jsx",
6 "jsx": "react-jsxdev",
77 "jsxImportSource": "#ssr",
88 "lib": ["dom", "esnext", "esnext.iterator"],
99 "module": "nodenext",