file viewer work
This commit is contained in:
parent
a367dfdb29
commit
71a072b0be
19 changed files with 343 additions and 88 deletions
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -145,6 +145,7 @@ function loadEsbuildCode(
|
|||
target: "esnext",
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "#ssr",
|
||||
jsxDev: true,
|
||||
sourcefile: filepath,
|
||||
}).code;
|
||||
return module._compile(src, filepath, "commonjs");
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
34
src/file-viewer/pages/file.404.tsx
Normal file
34
src/file-viewer/pages/file.404.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
58
src/file-viewer/sort.ts
Normal 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";
|
|
@ -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>
|
17
src/file-viewer/views/canvas.marko
Normal file
17
src/file-viewer/views/canvas.marko
Normal 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';
|
|
@ -687,6 +687,15 @@ h3 {
|
|||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.notfound {
|
||||
p {
|
||||
text-align: center;
|
||||
}
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* MOBILE */
|
||||
@media (max-width: 1000px) {
|
||||
html, body {
|
||||
|
|
|
@ -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";
|
||||
|
|
43
src/file-viewer/views/lofi.css
Normal file
43
src/file-viewer/views/lofi.css
Normal 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;
|
||||
}
|
71
src/file-viewer/views/lofi.marko
Normal file
71
src/file-viewer/views/lofi.marko
Normal 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";
|
22
src/file-viewer/views/lofideon.marko
Normal file
22
src/file-viewer/views/lofideon.marko
Normal 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>
|
||||
|
||||
</>
|
|
@ -3,7 +3,7 @@
|
|||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"incremental": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "react-jsxdev",
|
||||
"jsxImportSource": "#ssr",
|
||||
"lib": ["dom", "esnext", "esnext.iterator"],
|
||||
"module": "nodenext",
|
||||
|
|
Loading…
Reference in a new issue