sitegen/src/file-viewer/views/clofi.tsx

1003 lines
25 KiB
TypeScript

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;
siblingFiles?: MediaFile[];
}) => any;
} = {
".html": IframeView,
".txt": TextView,
".md": TextView,
".mp4": VideoView,
".mkv": VideoView,
".webm": VideoView,
".avi": VideoView,
".mov": VideoView,
".mp3": AudioView,
".flac": AudioView,
".wav": AudioView,
".ogg": AudioView,
".m4a": AudioView,
".jpg": ImageView,
".jpeg": ImageView,
".png": ImageView,
".gif": ImageView,
".webp": ImageView,
".avif": ImageView,
".heic": ImageView,
".svg": ImageView,
".chat": ChatView,
".json": CodeView,
".jsonc": CodeView,
".toml": CodeView,
".ts": CodeView,
".js": CodeView,
".tsx": CodeView,
".jsx": CodeView,
".css": CodeView,
".py": CodeView,
".lua": CodeView,
".sh": CodeView,
".bat": CodeView,
".ps1": CodeView,
".cmd": CodeView,
".yaml": CodeView,
".yml": CodeView,
".xml": CodeView,
".zig": CodeView,
".astro": CodeView,
".mdx": CodeView,
".php": CodeView,
".diff": CodeView,
".patch": CodeView,
};
export default function MediaList({
file,
hasCotyledonCookie,
}: {
file: MediaFile;
hasCotyledonCookie: boolean;
}) {
addScript("./clofi.client.ts");
const dirs: MediaFile[] = [];
let dir: MediaFile | null = file;
assert(dir);
do {
dirs.unshift(dir);
dir = dir?.getParent();
} while (dir);
const parts = file.path.split("/");
const isCotyledon = parseInt(parts[1]) < 2025;
return (
<div class={"files" + (isCotyledon ? " ctld ctld-" + parts[1] : "")}>
{dirs.map((entry, i) => {
const isLast = i === dirs.length - 1;
return (
<MediaPanel
file={entry}
isLast={isLast}
activeFilename={parts[i + 1]}
hasCotyledonCookie={hasCotyledonCookie}
/>
);
})}
</div>
);
}
const specialCaseViewers: Record<string, any> = {
"/2024/for everyone": ForEveryone,
};
export function MediaPanel({
file,
isLast,
activeFilename,
hasCotyledonCookie,
}: {
file: MediaFile;
isLast: boolean;
activeFilename: string | null;
hasCotyledonCookie: boolean;
}) {
const showsActions = file.path !== "/2024/for everyone";
const label = file.path === "/"
? (
"clo's files"
)
: (
<>
{file.kind === MediaFileKind.directory
? (
<span>
{file.basename}
<span class="bold-slash">/</span>
</span>
)
: (
<>
{file.basename}
<div style="flex:1" />
{showsActions && (
<div class="actions">
<a
class="custom full-screen"
href={`/file${escapeUri(file.path)}?view=embed`}
aria-label="full screen"
>
</a>
<a
class="custom download"
href={`/file${escapeUri(file.path)}?dl`}
aria-label="download"
>
</a>
</div>
)}
</>
)}
</>
);
let View = specialCaseViewers[file.path] ??
(extensionToViewer[file.extension.toLowerCase()] as any) ??
DownloadView;
if (View === VideoView && file.size > 500_000_000) {
View = DownloadViewTooBig;
}
const mobileBtn = file.path !== "/" && (
<a
href={`/file${escapeUri(path.dirname(file.path).replace(/\/$/, ""))}`}
className="custom mobile-back"
/>
);
const canvases: Record<string, string> = {
"/2017": "2017",
"/2018": "2018",
"/2019": "2019",
"/2020": "2020",
"/2021": "2021",
"/2022": "2022",
"/2023": "2023",
"/2024": "2024",
};
if (canvases[file.path]) {
addScript(`file-viewer/canvas_${canvases[file.path]}.client.ts`);
}
return (
<div
class={(isLast ? "panel last" : "panel") +
(file.path === "/" ? " first" : "")}
>
{canvases[file.path] && (
<canvas
class="fullscreen-canvas"
data-canvas={canvases[file.path]}
>
</canvas>
)}
{file.kind === MediaFileKind.directory && (
<a class="custom header" href={`/file${escapeUri(file.path)}`}>
<div class="ico ico-dir-open"></div>
{label}
</a>
)}
<div class="header">
{mobileBtn}
{<div class={`ico ico-${fileIcon(file, true)}`}></div>}
{label}
</div>
{file.kind === MediaFileKind.directory
? (
<DirView
dir={file}
activeFilename={activeFilename}
isLast={isLast}
hasCotyledonCookie={hasCotyledonCookie}
/>
)
: (
<div
class={`content file-view${
View.class ? " file-view-" + View.class : ""
}`}
>
<View file={file} />
</div>
)}
</div>
);
}
const readmeFile = "readme.txt";
const priorityFiles = ["readme.txt", "index.html"];
function sorterFromDirSort(dirsort: string) {
const array: string[] = JSON.parse(dirsort);
if (array.length === 1 && array[0] === "a-z") {
return sortAlphabetically;
}
return (a: MediaFile, b: MediaFile) => {
const aIndex = array.indexOf(a.basename);
const bIndex = array.indexOf(b.basename);
if (bIndex == -1 && aIndex >= 0) return -1;
if (aIndex == -1 && bIndex >= 0) return 1;
if (aIndex >= 0 && bIndex >= 0) {
return aIndex - bIndex;
}
return sortDefault(a, b);
};
}
function sortNumerically(a: MediaFile, b: MediaFile) {
const n1 = parseInt(a.basenameWithoutExt);
const n2 = parseInt(a.basenameWithoutExt);
return n1 - n2;
}
function sortDefault(a: MediaFile, b: MediaFile) {
// First check if either file is in the priority list
const aIndex = priorityFiles.indexOf(a.basename.toLowerCase());
const bIndex = priorityFiles.indexOf(b.basename.toLowerCase());
if (aIndex !== -1 && bIndex !== -1) {
// Both are priority files, sort by priority order
return aIndex - bIndex;
} else if (aIndex !== -1) {
// Only a is a priority file
return -1;
} else if (bIndex !== -1) {
// Only b is a priority file
return 1;
}
// Then sort directories before files
if (a.kind !== b.kind) {
return a.kind === MediaFileKind.directory ? -1 : 1;
}
// Finally sort by date (newest first), then by name (a-z) if dates are the same
const dateComparison = b.date.getTime() - a.date.getTime();
if (dateComparison !== 0) {
return dateComparison;
}
// If dates are the same, sort alphabetically by name
return a.basename.localeCompare(b.basename);
}
function sortAlphabetically(a: MediaFile, b: MediaFile) {
// First check if either file is in the priority list
const aIndex = priorityFiles.indexOf(a.basename.toLowerCase());
const bIndex = priorityFiles.indexOf(b.basename.toLowerCase());
if (aIndex !== -1 && bIndex !== -1) {
// Both are priority files, sort by priority order
return aIndex - bIndex;
} else if (aIndex !== -1) {
// Only a is a priority file
return -1;
} else if (bIndex !== -1) {
// Only b is a priority file
return 1;
}
// If dates are the same, sort alphabetically by name
return a.basename.localeCompare(b.basename);
}
function isNumericallyOrdered(files: MediaFile[]) {
return !files.some((x) => Number.isNaN(parseInt(x.basenameWithoutExt)));
}
function sortChronologicalStartToEnd(a: MediaFile, b: MediaFile) {
const aIndex = priorityFiles.indexOf(a.basename.toLowerCase());
const bIndex = priorityFiles.indexOf(b.basename.toLowerCase());
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
} else if (aIndex !== -1) {
return -1;
} else if (bIndex !== -1) {
return 1;
}
const dateComparison = a.date.getTime() - b.date.getTime();
if (dateComparison !== 0) {
return dateComparison;
}
return a.basename.localeCompare(b.basename);
}
const sortOverrides: Record<string, (a: MediaFile, b: MediaFile) => number> = {
"/2024": sortChronologicalStartToEnd,
"/2023": sortChronologicalStartToEnd,
"/2022": sortChronologicalStartToEnd,
"/2021": sortChronologicalStartToEnd,
"/2020": sortChronologicalStartToEnd,
"/2019": sortChronologicalStartToEnd,
"/2018": sortChronologicalStartToEnd,
"/2017": sortChronologicalStartToEnd,
};
function DirView({
dir,
activeFilename,
isLast,
hasCotyledonCookie,
}: {
dir: MediaFile;
activeFilename: string | null;
isLast: boolean;
hasCotyledonCookie: boolean;
}) {
if (dir.path === "/") {
return (
<RootDirView
dir={dir}
activeFilename={activeFilename}
isLast={isLast}
hasCotyledonCookie={hasCotyledonCookie}
/>
);
}
const isCotyledon = parseInt(dir.path.split("/")?.[1]) < 2025;
const unsortedFiles = dir
.getPublicChildren()
.filter((f) => !f.basenameWithoutExt.startsWith("_unlisted"));
const sorter = dir.dirsort
? sorterFromDirSort(dir.dirsort)
: isNumericallyOrdered(unsortedFiles)
? sortNumerically
: isCotyledon
? sortChronologicalStartToEnd
: (sortOverrides[dir.path] ?? sortDefault);
const sortedFiles = unsortedFiles.sort(sorter);
const readme = sortedFiles.find((f) => f.basename === readmeFile);
if (readme && isLast) activeFilename ||= readmeFile;
const main = (
<div class="content primary">
<ul>
{sortedFiles.map((file) => {
return (
<ListItem file={file} active={activeFilename === file.basename} />
);
})}
</ul>
</div>
);
if (readme && isLast) {
return (
<div class="hsplit">
<ReadmeView file={readme} siblingFiles={sortedFiles} />
{main}
</div>
);
}
return main;
}
const unknownDate = new Date("1970-01-03");
const unknownDateWithKnownYear = new Date("1970-02-20");
function ListItem({
file,
active,
noDate,
}: {
file: MediaFile;
active: boolean;
noDate?: boolean;
}) {
const dateTime = file.date;
let shortDate = 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)}`
);
let basenameWithoutExt = file.basenameWithoutExt;
let meta = file.kind === MediaFileKind.directory
? formatSize(file.size)
: (file.duration ?? 0) > 0
? formatDuration(file.duration!)
: null;
if (meta && dirname(file.path) !== "/") {
meta = `(${meta})`;
}
const isReadme = file.basename === readmeFile;
return (
<li>
<a
class={(active ? "active " : "") + (isReadme ? "readme " : "") +
"li custom"}
href={file.extension === ".lnk"
? file.contents
: isReadme
? `/file${escapeUri(path.dirname(file.path).replace(/\/$/, ""))}`
: `/file${escapeUri(file.path)}`}
>
<span>
{file.basename === readmeFile
? <>{!noDate && <div class="line"></div>}</>
: <>{!noDate && <date>{shortDate}</date>}</>}
<span class="size mobile">{meta}</span>
</span>
<span>
{path.dirname(file.path) !== "/" && (
<span className={`ico ico-${fileIcon(file)}`}></span>
)}
{basenameWithoutExt}
<span class="ext">{file.extension}</span>
{file.kind === MediaFileKind.directory
? <span class="bold-slash">/</span>
: (
""
)}
<span class="size desktop">{meta}</span>
</span>
</a>
</li>
);
}
function fileIcon(file: MediaFile, dirOpen?: boolean) {
if (file.kind === MediaFileKind.directory) {
return dirOpen ? "dir-open" : "dir";
}
if (file.basename === "readme.txt") {
return "readme";
}
if (file.path === "/2024/for everyone") {
return "snow";
}
const ext = path.extname(file.basename).toLowerCase();
if (
ext === ".mp4" ||
ext === ".mkv" ||
ext === ".webm" ||
ext === ".avi" ||
ext === ".mov"
) {
return "video";
}
if (
ext === ".mp3" ||
ext === ".flac" ||
ext === ".wav" ||
ext === ".ogg" ||
ext === ".m4a"
) {
return "audio";
}
if (
ext === ".jpg" ||
ext === ".jpeg" ||
ext === ".png" ||
ext === ".gif" ||
ext === ".webp" ||
ext === ".avif" ||
ext === ".heic" ||
ext === ".svg"
) {
return "image";
}
if (ext === ".comp" || ext === ".fuse" || ext === ".setting") return "fusion";
if (ext === ".txt" || ext === ".md") return "text";
if (ext === ".html") return "webpage";
if (ext === ".blend") return "blend";
if (
ext === ".zip" ||
ext === ".rar" ||
ext === ".7z" ||
ext === ".tar" ||
ext === ".gz" ||
ext === ".bz2" ||
ext === ".xz"
) {
return "archive";
}
if (ext === ".lnk") return "link";
if (ext === ".chat") return "chat";
if (
ext === ".ts" ||
ext === ".js" ||
ext === ".tsx" ||
ext === ".jsx" ||
ext === ".css" ||
ext === ".py" ||
ext === ".lua" ||
ext === ".sh" ||
ext === ".bat" ||
ext === ".ps1" ||
ext === ".cmd" ||
ext === ".php"
) {
return "code";
}
if (ext === ".json" || ext === ".toml" || ext === ".yaml" || ext === ".yml") {
return "json";
}
return "file";
}
function RootDirView({
dir,
activeFilename,
isLast,
hasCotyledonCookie,
}: {
dir: MediaFile;
activeFilename: string | null;
isLast: boolean;
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);
}
}
if (readme && isLast) activeFilename ||= readmeFile;
const main = (
<div class="content primary">
{readme && (
<ul>
<ListItem
file={readme}
active={activeFilename === readmeFile}
noDate
/>
</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);
});
}
return (
<div class="group" data-group={key}>
<h3 style={`color:${colorMap[key as keyof typeof colorMap]};`}>
{key}
</h3>
<ul>
{files.map((file) => (
<ListItem
file={file}
active={activeFilename === file.basename}
noDate={key === "cotyledon"}
/>
))}
</ul>
</div>
);
})}
</div>
);
if (readme && isLast) {
return (
<div class="hsplit">
<ReadmeView
file={readme}
siblingFiles={children}
extra={!hasCotyledonCookie && (
<>
<a href="/file/cotyledon" class="custom cotyledon-link desktop">
cotyledon
</a>
</>
)}
/>
{main}
{!hasCotyledonCookie && (
<>
<a href="/file/cotyledon" class="custom cotyledon-link mobile">
cotyledon
</a>
</>
)}
</div>
);
}
return main;
}
function TextView({
file,
siblingFiles = [],
}: {
file: MediaFile;
siblingFiles?: MediaFile[];
}) {
const contents = file.contents;
if (file.path.startsWith("/2021/phoenix-write/maps")) {
const basename = file.basename;
if (basename.includes("map") && basename.endsWith(".txt")) {
return (
<pre
style="white-space:normal;max-width:73ch"
dangerouslySetInnerHTML={{
__html: highlightHashComments(contents),
}}
></pre>
);
}
}
return (
<pre
style="white-space:pre-wrap;max-width:70ch"
dangerouslySetInnerHTML={{
__html: highlightLinksInTextView(
contents,
siblingFiles.filter((f) => f.kind === MediaFileKind.file),
),
}}
></pre>
);
}
function ChatView({ file }: { file: MediaFile }) {
const contents = file.contents;
return (
<div
style="max-width:70ch"
class="convo"
dangerouslySetInnerHTML={{
__html: highlightConvo(contents),
}}
>
</div>
);
}
function CodeView({ file }: { file: MediaFile }) {
const highlightedContents = file.contents;
if (!highlightedContents) {
if (file.size > 1_000_000) {
return <DownloadViewTooBig file={file} />;
}
return <DownloadViewCodeNotComputed file={file} />;
}
return (
<pre
style="white-space:pre-wrap"
dangerouslySetInnerHTML={{
__html: highlightedContents,
}}
/>
);
}
function VideoView({ file }: { file: MediaFile }) {
addScript("@/tags/hls-polyfill.client.ts");
const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
return (
<>
<video
controls
preload
style={`background:transparent;aspect-ratio:${
simplifyFraction(
dimensions.width,
dimensions.height,
)
}`}
>
<source
src={"/file" + file.path}
type={mime.contentTypeFor(file.path)}
/>
</video>
</>
);
}
function AudioView({
file,
onlyAudio = false,
}: {
file: MediaFile;
onlyAudio?: boolean;
}) {
const parent = file.getParent();
let lyricsFile: MediaFile | null = null;
if (parent && !onlyAudio) {
const siblings = parent.getPublicChildren();
// show lyrics only if
// - this media file is the only audio file
// - there is lyrics.txt in the same directory
const audioFiles = siblings.filter(
(f) =>
f.kind === MediaFileKind.file &&
extensionToViewer[path.extname(f.basename)] === AudioView,
);
if (
audioFiles.length === 1 &&
siblings.find((f) => f.basename === "lyrics.txt")
) {
lyricsFile = siblings.find((f) => f.basename === "lyrics.txt")!;
}
}
return (
<>
<audio controls preload="none" style="width:100%;">
<source
src={"/file" + file.path}
type={`audio/${path.extname(file.path).substring(1)}`}
/>
</audio>
{lyricsFile && (
<div class="lyrics">
<h2 style="margin-left:1rem;margin-bottom:-0.2rem;margin-top:0.5rem;">
lyrics
</h2>
<TextView file={lyricsFile} siblingFiles={[]} />
</div>
)}
</>
);
}
function ImageView({ file }: { file: MediaFile }) {
return (
<img
src={"/file" + file.path}
alt={file.basename}
style="max-width:100%;max-height:100%;object-fit:contain;margin:auto;display:block;"
/>
);
}
function IframeView({ file }: { file: MediaFile }) {
return (
<>
<iframe
style="flex:1;height:100%;border:none"
src={"/file" + file.path + "?view=embed"}
/>
</>
);
}
const helptexts: Record<string, any> = {
".blend": (
<p>
<code>.blend</code> files can be open in{" "}
<a href="https://www.blender.org" target="_blank">
Blender
</a>
.
</p>
),
".comp": <FusionHelp ext="comp" name="composition files (project/scene)" />,
".setting": <FusionHelp ext="setting" name="macro/template" />,
".fuse": <FusionHelp ext="setting" name="FUSE Plugin (lua source code)" />,
};
function FusionHelp({ ext, name }: { ext: string; name: string }) {
return (
<>
<p>
<code>.{ext}</code> files are{" "}
<a
href="https://www.blackmagicdesign.com/products/fusion"
target="_blank"
>
Fusion
</a>{" "}
{name}.
</p>
<p>
Blackmagic Fusion is paid-only since mid-2019 (the "Studio" version).
The last free version is Fusion 9.0.2, which exists for MacOS, Linux,
and Microsoft Windows on{" "}
<a href="https://www.blackmagicdesign.com/support" target="_blank">
Blackmagic Design's support page
</a>
.
</p>
<p>
Alternatively, you can use DaVinci Resolve, however I do not recommend
it as it is very unstable, at least as of when I last tried version 18.
</p>
</>
);
}
function DownloadView({ file }: { file: MediaFile }) {
const help = helptexts[path.extname(file.basename)];
return help
? (
<>
{help}
<p>
File download:{" "}
<a href={"/file" + file.path + "?dl"} aria-label="download">
{file.basename}
</a>
.
</p>
</>
)
: (
<>
<p>
No built-in viewer for <code>{path.extname(file.basename)}</code>{" "}
files.
</p>
<p>
Instead, you may download it:{" "}
<a href={"/file" + file.path + "?dl"} aria-label="download">
{file.basename}
</a>
.
</p>
</>
);
}
function DownloadViewTooBig({ file }: { file: MediaFile }) {
return (
<div style="padding:0.5rem">
<p>
This file is too large to view in the browser right now (
<code>{formatSize(file.size)}</code>). Later, this file viewer will
properly process video files for web streaming.
</p>
<p>
Instead, you may download it:{" "}
<a href={"/file" + file.path + "?dl"} aria-label="download">
{file.basename}
</a>
.
</p>
</div>
);
}
function DownloadViewCodeNotComputed({ file }: { file: MediaFile }) {
return (
<div style="padding:0.5rem">
<p>
This file has not been pre-processed for code highlighting. This either
means it is not a valid UTF-8 text file (<code>.ts</code>{" "}
can be TypeScript or an MPEG "Transport Stream"), or there is a bug in
the file indexing logic.
</p>
<p>
Instead, you may download it:{" "}
<a href={"/file" + file.path + "?dl"} aria-label="download">
{file.basename}
</a>
.
</p>
</div>
);
}
function ReadmeView({
file,
siblingFiles,
extra,
}: {
file: MediaFile;
siblingFiles: MediaFile[];
extra?: any;
}) {
// let showFileCandidates = siblingFiles.filter(f =>
// f.kind === MediaFileKind.file
// && (extensionToViewer[path.extname(f.basename)] === AudioView
// || extensionToViewer[path.extname(f.basename)] === VideoView)
// );
return (
<div class="content readme">
{/* {showFileCandidates.length === 1 && <AudioView file={showFileCandidates[0]} onlyAudio />} */}
<TextView file={file} siblingFiles={siblingFiles} />
{extra}
</div>
);
}
const gcd = (a: number, b: number): number => (b ? gcd(b, a % b) : a);
function simplifyFraction(n: number, d: number) {
const divisor = gcd(n, d);
return `${n / divisor}/${d / divisor}`;
}
// Add class properties to the components
AudioView.class = "audio";
ImageView.class = "image";
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";