file viewer work

This commit is contained in:
chloe caruso 2025-06-22 14:38:36 -07:00
parent a367dfdb29
commit 71a072b0be
19 changed files with 343 additions and 88 deletions

View file

@ -3,7 +3,9 @@ import "#debug";
const protocol = "http";
const server = serve(app, ({ address, port }) => {
const server = serve({
fetch: app.fetch,
}, ({ address, port }) => {
if (address === "::") address = "::1";
console.info(url.format({
protocol,

View file

@ -17,13 +17,21 @@ export function jsxDEV(
_key: string,
// Unused with the clover engine
_isStaticChildren: boolean,
// Unused with the clover engine
_source: unknown,
source: engine.SrcLoc,
): engine.Element {
const { fileName, lineNumber, columnNumber } = source;
// Assert the component type is valid to render.
if (typeof type !== "function" && typeof type !== "string") {
throw new Error("Invalid component type: " + engine.inspect(type));
throw new Error(
`Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` +
engine.inspect(type) +
". Clover SSR element must be a function or string",
);
}
return [engine.kElement, type, props];
// Construct an `ssr.Element`
return [engine.kElement, type, props, "", source];
}
// jsxs
@ -37,7 +45,7 @@ declare global {
interface ElementChildrenAttribute {
children: Node;
}
type Element = engine.Node;
type Element = engine.Element;
type ElementType = keyof IntrinsicElements | engine.Component;
type ElementClass = ReturnType<engine.Component>;
}

View file

@ -123,14 +123,15 @@ export function escapeXML(input: unknown) {
// The rationale of this check is that the default toString method
// creating `[object Object]` is universally useless to any end user.
if (
input == null ||
(typeof input === "object" && input &&
// only block this if it's the default `toString`
input.toString === Object.prototype.toString)
) {
throw new Error(
`Unexpected object in template placeholder: '` +
engine.inspect({ name: "clover" }) + "'. " +
`To emit a literal '[object Object]', use \${String(value)}`,
`Unexpected value in template placeholder: '` +
engine.inspect(input) + "'. " +
`To emit a literal '${input}', use \${String(value)}`,
);
}
return marko.escapeXML(input);

View file

@ -79,6 +79,8 @@ export type Element = [
tag: typeof kElement,
type: string | Component,
props: Record<string, unknown>,
_?: "",
source?: SrcLoc,
];
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
/**
@ -88,6 +90,12 @@ export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
export type Component = (
props: Record<any, any>,
) => Exclude<Node, undefined>;
/** Emitted by JSX runtime */
export interface SrcLoc {
fileName: string;
lineNumber: number;
columnNumber: number;
}
/**
* Resolution narrows the type 'Node' into 'ResolvedNode'. Async trees are
@ -130,9 +138,15 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
const { 1: tag, 2: props } = node;
if (typeof tag === "function") {
currentRender = r;
const result = tag(props);
currentRender = null;
return resolveNode(r, result);
try {
return resolveNode(r, tag(props));
} catch (e) {
const { 4: src } = node;
if (e && typeof e === "object") {
}
} finally {
currentRender = null;
}
}
if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
const children = props?.children;

View file

@ -145,6 +145,7 @@ function loadEsbuildCode(
target: "esnext",
jsx: "automatic",
jsxImportSource: "#ssr",
jsxDev: true,
sourcefile: filepath,
}).code;
return module._compile(src, filepath, "commonjs");

View file

@ -11,7 +11,6 @@ app.use(admin.middleware);
app.route("", require("./q+a/backend.ts").app);
app.route("", require("./file-viewer/backend.tsx").app);
// app.route("", require("./friends/backend.tsx").app);
app.use(assets.middleware);

View file

@ -42,9 +42,15 @@ app.post("/file/cotyledon", async (c) => {
});
app.get("/file/*", async (c, next) => {
if (c.req.header("User-Agent")?.toLowerCase()?.includes("discordbot")) {
const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
const lofi = ua.includes("msie") || ua.includes("rv:") || false;
// Discord ignores 'robots.txt' which violates the license agreement.
if (ua.includes("discordbot")) {
return next();
}
console.log(ua, lofi);
let rawFilePath = c.req.path.slice(5) || "/";
if (rawFilePath.endsWith("$partial")) {
return getPartialPage(c, rawFilePath.slice(0, -"$partial".length));
@ -88,7 +94,7 @@ app.get("/file/*", async (c, next) => {
} satisfies APIDirectoryList;
return c.json(json);
}
c.res = await renderView(c, "file-viewer/clofi", {
c.res = await renderView(c, `file-viewer/${lofi ? "lofi" : "clofi"}`, {
file,
hasCotyledonCookie,
});
@ -100,7 +106,11 @@ app.get("/file/*", async (c, next) => {
if (c.req.query("dl") !== undefined) {
viewMode = "download";
}
if (viewMode == undefined && c.req.header("Accept")?.includes("text/html")) {
if (
viewMode == undefined &&
c.req.header("Accept")?.includes("text/html") &&
!lofi
) {
prefetchFile(file.path);
c.res = await renderView(c, "file-viewer/clofi", {
file,

View file

@ -9,15 +9,15 @@ 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 formatDateDefined(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);
@ -255,5 +255,27 @@ export function highlightHashComments(text: string) {
.join("\n");
}
const unknownDate = new Date("1970-01-03");
const unknownDateWithKnownYear = new Date("1970-02-20");
export function formatDate(dateTime: Date) {
return dateTime < unknownDateWithKnownYear
? (
dateTime < unknownDate
? (
"??.??.??"
)
: `xx.xx.${21 + Math.floor(dateTime.getTime() / 86400000)}`
)
: (
`${(dateTime.getMonth() + 1).toString().padStart(2, "0")}.${
dateTime
.getDate()
.toString()
.padStart(2, "0")
}.${dateTime.getFullYear().toString().slice(2)}`
);
}
import type { MediaFile } from "@/file-viewer/models/MediaFile.ts";
import { escapeHtml } from "#ssr";

View file

@ -0,0 +1,34 @@
import { MediaFile } from "../models/MediaFile.ts";
import { MediaPanel } from "../views/clofi.tsx";
import { addScript } from "#sitegen";
export const theme = {
bg: "#312652",
fg: "#f0f0ff",
primary: "#fabe32",
};
export const meta = { title: "file not found" };
export default function CotyledonPage() {
addScript("../scripts/canvas_cotyledon.client.ts");
return (
<div class="files ctld ctld-sb">
<MediaPanel
file={MediaFile.getByPath("/")!}
isLast={false}
activeFilename={null}
hasCotyledonCookie={false}
/>
<div class="panel last">
<div className="header"></div>
<div className="content file-view notfound">
<p>this file does not exist ...</p>
<p>
<a href="/file">return</a>
</p>
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { MediaFile } from "../models/MediaFile.ts";
import { Speedbump } from "../cotyledon.tsx";
import { MediaPanel } from "../views/clofi.tsx";
import { addScript } from "#sitegen";
import { Speedbump } from "../cotyledon.tsx";
export const theme = {
bg: "#312652",

58
src/file-viewer/sort.ts Normal file
View file

@ -0,0 +1,58 @@
export function splitRootDirFiles(dir: MediaFile, hasCotyledonCookie: boolean) {
const children = dir.getPublicChildren();
let readme: MediaFile | null = null;
const groups = {
// years 2025 and onwards
years: [] as MediaFile[],
// named categories
categories: [] as MediaFile[],
// years 2017 to 2024
cotyledon: [] as MediaFile[],
};
const colorMap: Record<string, string> = {
years: "#a2ff91",
categories: "#9c91ff",
cotyledon: "#ff91ca",
};
for (const child of children) {
const basename = child.basename;
if (basename === "readme.txt") {
readme = child;
continue;
}
const year = basename.match(/^(\d{4})/);
if (year) {
const n = parseInt(year[1]);
if (n >= 2025) {
groups.years.push(child);
} else {
groups.cotyledon.push(child);
}
} else {
groups.categories.push(child);
}
}
let sections = [];
for (const [key, files] of Object.entries(groups)) {
if (key === "cotyledon" && !hasCotyledonCookie) {
continue;
}
if (key === "years" || key === "cotyledon") {
files.sort((a, b) => {
return b.basename.localeCompare(a.basename);
});
} else {
files.sort((a, b) => {
return a.basename.localeCompare(b.basename);
});
}
sections.push({ key, titleColor: colorMap[key], files });
}
return { readme, sections };
}
import { MediaFile } from "./models/MediaFile.ts";

View file

@ -1,12 +0,0 @@
---
import { useInlineScript } from "../framework/page-resources.ts";
const { script } = Astro.props;
useInlineScript('canvas_' + script as any);
useInlineScript('canvas_demo');
---
<canvas id="canvas"
data-canvas={script}
data-standalone="true"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
></canvas>

View file

@ -0,0 +1,17 @@
export interface Input {
script: string;
}
export const meta = { title: "canvas" };
<const/{ script } = input />
<canvas id="canvas"
data-canvas=script
data-standalone="true"
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;"
></canvas>
<AddScript="file-viewer/scripts/canvas_" + script + ".client.ts" />
client import "./canvas.client.ts";
import { addScript as AddScript } from '#sitegen';

View file

@ -687,6 +687,15 @@ h3 {
transform: translateY(0);
}
}
.notfound {
p {
text-align: center;
}
a {
text-decoration: underline;
}
}
/* MOBILE */
@media (max-width: 1000px) {
html, body {

View file

@ -406,7 +406,7 @@ function ListItem({
noDate?: boolean;
}) {
const dateTime = file.date;
let shortDate = dateTime < unknownDateWithKnownYear
const shortDate = dateTime < unknownDateWithKnownYear
? (
dateTime < unknownDate
? (
@ -565,40 +565,7 @@ function RootDirView({
hasCotyledonCookie: boolean;
}) {
const children = dir.getPublicChildren();
let readme: MediaFile | null = null;
const groups = {
// years 2025 and onwards
years: [] as MediaFile[],
// named categories
categories: [] as MediaFile[],
// years 2017 to 2024
cotyledon: [] as MediaFile[],
};
const colorMap = {
years: "#a2ff91",
categories: "#9c91ff",
cotyledon: "#ff91ca",
};
for (const child of children) {
const basename = child.basename;
if (basename === readmeFile) {
readme = child;
continue;
}
const year = basename.match(/^(\d{4})/);
if (year) {
const n = parseInt(year[1]);
if (n >= 2025) {
groups.years.push(child);
} else {
groups.cotyledon.push(child);
}
} else {
groups.categories.push(child);
}
}
const { readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie);
if (readme && isLast) activeFilename ||= readmeFile;
@ -613,22 +580,10 @@ function RootDirView({
/>
</ul>
)}
{Object.entries(groups).map(([key, files]) => {
if (key === "cotyledon" && !hasCotyledonCookie) {
return null;
}
if (key === "years" || key === "cotyledon") {
files.sort((a, b) => {
return b.basename.localeCompare(a.basename);
});
} else {
files.sort((a, b) => {
return a.basename.localeCompare(b.basename);
});
}
{sections.map(({ key, titleColor, files }) => {
return (
<div class="group" data-group={key}>
<h3 style={`color:${colorMap[key as keyof typeof colorMap]};`}>
<h3 style={{ color: titleColor }}>
{key}
</h3>
<ul>
@ -1001,3 +956,4 @@ import {
} from "@/file-viewer/format.ts";
import { ForEveryone } from "@/file-viewer/cotyledon.tsx";
import * as mime from "#sitegen/mime";
import * as sort from "../sort.ts";

View file

@ -0,0 +1,43 @@
body {
margin: 0;
padding: 0;
}
#lofi {
padding: 32px;
}
h1 {
margin-top: 0;
font-size: 3em;
color: var(--primary);
font-family: monospace;
}
ul, li {
margin: 0;
padding: 0;
list-style-type: none;
}
ul {
padding-right: 4em;
}
li a {
display: block;
color: white;
line-height: 2em;
padding: 0 1em;
border-radius: 4px;
}
li a:hover {
background-color: rgba(255,255,255,0.2);
font-weight: bold;
text-decoration: none!important;
}
.dir a {
color: #99eeFF
}
.ext {
opacity: 0.5;
}
.meta {
margin-left: 1em;
opacity: 0.75;
}

View file

@ -0,0 +1,71 @@
import "./lofi.css";
export interface Input {
file: MediaFile;
hasCotyledonCookie: boolean;
}
export { meta, theme } from "./clofi.tsx";
<const/{ file: dir, hasCotyledonCookie } = input />
<const/{ path: fullPath, dirname, basename, kind } = dir />
<const/isRoot = fullPath == '/'>
<div#lofi>
<define/ListItem|{ value: file }|>
<const/dir=file.kind === MediaFileKind.directory/>
<const/meta=(
dir
? formatSize(file.size)
: (file.duration ?? 0) > 0
? formatDuration(file.duration!)
: null
)/>
<li class={ dir }>
<a href=`/file${escapeUri(file.path)}` >
<code>${formatDate(file.date)}</>${" "}
${file.basenameWithoutExt}<span class="ext">${file.extension}</>${dir ? '/' : ''}
<if=meta><span class='meta'>(${meta})</></>
</a>
</li>
</define>
<h1>
<if=isRoot>clo's files</>
<else>${fullPath}</>
</h1>
<if=isRoot>
<const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />
<if=readme><ul><ListItem=readme/></ul></>
<for|{key, files, titleColor }| of=sections>
<h2 style={color: titleColor }>${key}</>
<ul>
<for|item| of=files><ListItem=item /></>
</ul>
</>
<if=!hasCotyledonCookie>
<br><br><br><br><br><br>
<p style={ opacity: 0.3 }>
would you like to
<a
href="/file/cotyledon"
style={
color: 'white',
'text-decoration': 'underline',
}
>dive deeper?</a>
</p>
</>
</><else>
<ul>
<li><a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>[up one...]</a></li>
<for|item| of=dir.getChildren()> <ListItem=item /> </>
</ul>
</>
</div>
import * as path from "node:path";
import { escapeUri, formatDuration, formatSize, formatDate } from "@/file-viewer/format.ts";
import { MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
import * as sort from "@/file-viewer/sort.ts";

View file

@ -0,0 +1,22 @@
export interface Input {
stage: number
}
export const meta = { title: 'C O T Y L E D O N' };
export const theme = {
bg: '#ff00ff',
fg: '#000000',
};
<h1>co<br>ty<br>le<br>don</h1>
<if=input.stage==0>
<p>
this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.
</p><p>
by 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
</p><p>
<sub>(in english: please do not store downloads after you're done viewing them)</sub>
</p>
</>

View file

@ -3,7 +3,7 @@
"allowImportingTsExtensions": true,
"baseUrl": ".",
"incremental": true,
"jsx": "react-jsx",
"jsx": "react-jsxdev",
"jsxImportSource": "#ssr",
"lib": ["dom", "esnext", "esnext.iterator"],
"module": "nodenext",