1003 lines
25 KiB
TypeScript
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";
|