feat(files): share a folder by link — its page lists what is in it, plays a video above the list
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id, exactly one set; a folder's links go with it). The page shows what is in the folder when it is opened, newest first: a video or a song plays in the same player a file link has, above the list, each keeping its own resume point; a document only downloads, never opens as a page. A file moved out stops opening through the link at its next request. Password, expiry and stream-only work as on a file link; the unfurl names the folder and counts its files, never their names. Source folders and the Hub cannot be shared. The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail marks a folder a live link opens. Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once, and the new-link form goes back to downloadable after making a link.
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
Download,
|
||||
File as FileIcon,
|
||||
Film,
|
||||
Link2,
|
||||
Lock,
|
||||
Music,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import RemoteImage from "./ui/RemoteImage";
|
||||
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
|
||||
import { formatBytes, formatDuration } from "../lib/format";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
@@ -11,15 +25,17 @@ import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { send } from "../lib/http";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { LinkButton } from "./ui/LinkButton";
|
||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
||||
import { T, lang } from "./watchStrings";
|
||||
|
||||
// Standalone, login-free video page for a shared /watch/<token> link. Its own entry (watchMain.tsx,
|
||||
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
|
||||
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
|
||||
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
|
||||
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
|
||||
// language preference).
|
||||
|
||||
type Meta = {
|
||||
needs_password: boolean;
|
||||
// One shared file: a file link's page, or one row of a folder link's.
|
||||
type FileMeta = {
|
||||
kind?: "file";
|
||||
title?: string | null;
|
||||
uploader?: string | null;
|
||||
uploader_url?: string | null;
|
||||
@@ -35,6 +51,21 @@ type Meta = {
|
||||
poster_url?: string | null;
|
||||
};
|
||||
|
||||
// A folder link's page: what is in the folder now, newest first.
|
||||
type FolderMeta = {
|
||||
kind: "folder";
|
||||
name: string;
|
||||
allow_download: boolean;
|
||||
files: (FileMeta & { id: number })[];
|
||||
};
|
||||
|
||||
type Meta = FileMeta | FolderMeta;
|
||||
|
||||
// What the link answers before it is opened: a password link says only which kind it is.
|
||||
type Answer =
|
||||
| (Meta & { needs_password?: false })
|
||||
| { needs_password: true; kind?: "file" | "folder" };
|
||||
|
||||
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
|
||||
function displayUrl(url: string): string {
|
||||
try {
|
||||
@@ -60,37 +91,12 @@ export default function WatchPage() {
|
||||
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
||||
>(token ? "loading" : "gone");
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
// A password link's kind, for its title, until it is unlocked.
|
||||
const [locked, setLocked] = useState<"file" | "folder">("file");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = useId();
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||
// the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
useMediaSession(
|
||||
status === "ready" ? media : null,
|
||||
meta && {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url,
|
||||
},
|
||||
);
|
||||
useMediaKeys(media);
|
||||
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
|
||||
// Read once per link, at render: the point a later checkpoint writes must not move the start.
|
||||
const [start] = useState(() => (token ? linkPoint(token) : 0));
|
||||
useResume(media, token || null, start, (position, duration) =>
|
||||
saveLinkPoint(token, position, duration),
|
||||
);
|
||||
|
||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||
// (this page has no account); an unreadable storage just means the default.
|
||||
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
|
||||
const toggleRepeat = () => {
|
||||
const next = !repeat;
|
||||
setRepeat(next);
|
||||
writeJSON(LS.watchRepeat, next);
|
||||
};
|
||||
|
||||
// Bumped by Retry, which asks again.
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -101,18 +107,20 @@ export default function WatchPage() {
|
||||
useEffect(() => {
|
||||
if (!token) return; // the seed above already says "gone"
|
||||
send(`/api/public/watch/${encodeURIComponent(token)}`)
|
||||
.then((r): Promise<Meta | "preparing" | "gone"> => {
|
||||
.then((r): Promise<Answer | "preparing" | "gone"> => {
|
||||
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed) — or that
|
||||
// its video is being made again (`notFoundState`). Anything else — a 5xx while the server
|
||||
// restarts, a dropped connection — is a failure worth retrying, not a dead link.
|
||||
if (r.status === 404) return notFoundState(r);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json() as Promise<Meta>;
|
||||
return r.json() as Promise<Answer>;
|
||||
})
|
||||
.then((m) => {
|
||||
if (typeof m === "string") setStatus(m);
|
||||
else if (m.needs_password) setStatus("password");
|
||||
else {
|
||||
else if (m.needs_password) {
|
||||
setLocked(m.kind ?? "file");
|
||||
setStatus("password");
|
||||
} else {
|
||||
setMeta(m);
|
||||
setStatus("ready");
|
||||
}
|
||||
@@ -231,7 +239,8 @@ export default function WatchPage() {
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-accent" /> {T.passwordTitle}
|
||||
<Lock className="w-4 h-4 text-accent" />{" "}
|
||||
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -263,137 +272,290 @@ export default function WatchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && meta.media_kind === "file" && (
|
||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. It is the page's one action, so it is the filled
|
||||
primary, as the unlock card's Watch is. */
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</h1>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<LinkButton
|
||||
href={meta.file_url}
|
||||
variant="primary"
|
||||
icon={<Download />}
|
||||
className="mt-5"
|
||||
>
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status === "ready" && meta?.kind === "folder" && (
|
||||
<FolderView folder={meta} token={token} />
|
||||
)}
|
||||
|
||||
{status === "ready" && meta && meta.media_kind !== "file" && (
|
||||
<div>
|
||||
{meta.audio ? (
|
||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||
the surface (not the black video box) so it looks intentional without artwork. */
|
||||
<div className={`${SURFACE.card} p-4`}>
|
||||
<audio
|
||||
ref={setMedia}
|
||||
controls
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
<video
|
||||
ref={(el) => {
|
||||
setMedia(el);
|
||||
// So Brave iOS's background play keeps it going when the app is left.
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className="max-h-[80dvh] max-w-full mx-auto block"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
|
||||
than squeezing the title into a column a word wide. */}
|
||||
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<h1 className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || "Video"}
|
||||
</h1>
|
||||
{meta.uploader &&
|
||||
(meta.uploader_url ? (
|
||||
<a
|
||||
href={meta.uploader_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted hover:text-accent hover:underline"
|
||||
>
|
||||
{meta.uploader}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{meta.uploader}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
|
||||
Button, whose variants differ only in colour. */}
|
||||
<Chip
|
||||
size="md"
|
||||
pressed={repeat}
|
||||
icon={<Repeat className="size-3.5" aria-hidden />}
|
||||
onClick={toggleRepeat}
|
||||
className="font-medium"
|
||||
>
|
||||
{T.repeat}
|
||||
</Chip>
|
||||
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
|
||||
{meta.allow_download && (
|
||||
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{[meta.source_url, ...(meta.extra_links ?? [])]
|
||||
.filter((u): u is string => !!u)
|
||||
.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={url}
|
||||
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayUrl(url)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status === "ready" && meta && meta.kind !== "folder" && (
|
||||
<FileView meta={meta} pointKey={token} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted py-4">{T.brand}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One shared file: its player (or, for a document, its card), title and actions. `pointKey` is where
|
||||
* this browser keeps the point it stopped at: the link's token, or the token and the file's id in a
|
||||
* folder. `nested` is a file shown inside a folder's page, whose heading is the folder's. */
|
||||
function FileView({
|
||||
meta,
|
||||
pointKey,
|
||||
nested = false,
|
||||
}: {
|
||||
meta: FileMeta;
|
||||
pointKey: string;
|
||||
nested?: boolean;
|
||||
}) {
|
||||
const Heading = nested ? "h2" : "h1";
|
||||
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||
// the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
useMediaSession(media, {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url,
|
||||
});
|
||||
useMediaKeys(media);
|
||||
// It opens where this browser stopped in this file (the viewer has no account to keep it in).
|
||||
// Read once per file, at render: the point a later checkpoint writes must not move the start.
|
||||
const [start] = useState(() => linkPoint(pointKey));
|
||||
useResume(media, pointKey, start, (position, duration) =>
|
||||
saveLinkPoint(pointKey, position, duration),
|
||||
);
|
||||
|
||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||
// (this page has no account); an unreadable storage just means the default.
|
||||
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
|
||||
const toggleRepeat = () => {
|
||||
const next = !repeat;
|
||||
setRepeat(next);
|
||||
writeJSON(LS.watchRepeat, next);
|
||||
};
|
||||
|
||||
if (meta.media_kind === "file")
|
||||
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the
|
||||
link allows it, a Download. The backend serves the file force-download regardless, so
|
||||
gating the button on allow_download only mirrors the owner's ACL, matching the media
|
||||
branch's download affordance below. It is the page's one action, so it is the filled
|
||||
primary, as the unlock card's Watch is. */
|
||||
return (
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<div className={`${SURFACE.panel} w-full max-w-md p-6 text-center`}>
|
||||
<div className="mx-auto w-16 h-16 rounded-xl bg-surface grid place-items-center mb-4">
|
||||
<FileIcon className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
<Heading className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || T.file}
|
||||
</Heading>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
{meta.allow_download && (
|
||||
<LinkButton href={meta.file_url} variant="primary" icon={<Download />} className="mt-5">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{meta.audio ? (
|
||||
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
|
||||
<audio>. No poster exists for these, so present it as a deliberate padded bar on
|
||||
the surface (not the black video box) so it looks intentional without artwork. */
|
||||
<div className={`${SURFACE.card} p-4`}>
|
||||
<audio
|
||||
ref={setMedia}
|
||||
controls
|
||||
autoPlay={nested}
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
className="w-full block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
|
||||
player instead of a wide box with black bars; a landscape clip fills the width. */
|
||||
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
|
||||
<video
|
||||
ref={(el) => {
|
||||
setMedia(el);
|
||||
// So Brave iOS's background play keeps it going when the app is left.
|
||||
exposeVideoToBackgroundPlay(el);
|
||||
}}
|
||||
controls
|
||||
playsInline
|
||||
// In a folder the viewer picked it from the list just now: it starts.
|
||||
autoPlay={nested}
|
||||
loop={repeat}
|
||||
src={meta.file_url}
|
||||
poster={meta.poster_url || undefined}
|
||||
className={clsx("max-w-full mx-auto block", nested ? "max-h-[60dvh]" : "max-h-[80dvh]")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
|
||||
than squeezing the title into a column a word wide. */}
|
||||
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
|
||||
<div className="min-w-0 flex-1 basis-60">
|
||||
<Heading className="text-lg font-semibold leading-snug break-words">
|
||||
{meta.title || "Video"}
|
||||
</Heading>
|
||||
{meta.uploader &&
|
||||
(meta.uploader_url ? (
|
||||
<a
|
||||
href={meta.uploader_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted hover:text-accent hover:underline"
|
||||
>
|
||||
{meta.uploader}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{meta.uploader}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
|
||||
Button, whose variants differ only in colour. */}
|
||||
<Chip
|
||||
size="md"
|
||||
pressed={repeat}
|
||||
icon={<Repeat className="size-3.5" aria-hidden />}
|
||||
onClick={toggleRepeat}
|
||||
className="font-medium"
|
||||
>
|
||||
{T.repeat}
|
||||
</Chip>
|
||||
{/* The toolbar rung, level with the Repeat chip beside it (one row, one height). */}
|
||||
{meta.allow_download && (
|
||||
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
|
||||
{T.download}
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{[meta.source_url, ...(meta.extra_links ?? [])]
|
||||
.filter((u): u is string => !!u)
|
||||
.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={url}
|
||||
className="inline-flex min-h-6 items-center gap-1.5 text-sm text-muted hover:text-accent hover:underline min-w-0 touch-target"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayUrl(url)}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a
|
||||
* song carries its length. */
|
||||
function RowThumb({ file }: { file: FileMeta }) {
|
||||
const Glyph = file.media_kind === "file" ? FileIcon : file.audio ? Music : Film;
|
||||
const length = file.media_kind !== "file" && file.duration_s ? file.duration_s : null;
|
||||
// Above the length badge when there is one, so the badge never covers it.
|
||||
const glyph = (
|
||||
<Glyph
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"absolute inset-x-0 mx-auto text-muted",
|
||||
length ? "top-1 size-4" : "inset-y-0 my-auto size-5",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-10 w-16 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
<RemoteImage
|
||||
src={file.poster_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
fallback={glyph}
|
||||
/>
|
||||
{length ? (
|
||||
<span className={clsx(MEDIA_BADGE, "bottom-0.5 right-0.5")}>{formatDuration(length)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A folder link's page: the folder's name, its files, and the one playing above them. Play opens the
|
||||
* same player a file link has; a document only downloads. */
|
||||
function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
||||
const [playing, setPlaying] = useState<number | null>(null);
|
||||
const current = folder.files.find((f) => f.id === playing);
|
||||
const total = folder.files.reduce((n, f) => n + (f.size_bytes ?? 0), 0);
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold leading-snug break-words">{folder.name}</h1>
|
||||
<div className="text-sm text-muted">
|
||||
{[T.files(folder.files.length), folder.files.length > 0 && formatBytes(total, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
{current && (
|
||||
// Keyed by the file, so a new pick is a new player (its own resume point, its own media
|
||||
// session) and brings the player into view — on a phone it is above the row just tapped.
|
||||
<div key={current.id} ref={(el) => el?.scrollIntoView({ block: "start" })}>
|
||||
<FileView meta={current} pointKey={`${token}/${current.id}`} nested />
|
||||
</div>
|
||||
)}
|
||||
{folder.files.length === 0 ? (
|
||||
<p className="text-sm text-muted">{T.folderEmpty}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col">
|
||||
{folder.files.map((f) => {
|
||||
const title = f.title || T.file;
|
||||
const body = (
|
||||
<>
|
||||
<RowThumb file={f} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{title}</span>
|
||||
<span className="block text-xs text-muted">
|
||||
{formatBytes(f.size_bytes, lang)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={f.id}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 rounded-lg",
|
||||
f.id === playing && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
{f.media_kind === "file" ? (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
|
||||
) : (
|
||||
<Pressable
|
||||
layer
|
||||
selectable
|
||||
aria-current={f.id === playing ? "true" : undefined}
|
||||
aria-label={`${T.play}: ${title}`}
|
||||
onClick={() => setPlaying(f.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
||||
>
|
||||
{body}
|
||||
<Play aria-hidden className="size-4 shrink-0 text-muted" />
|
||||
</Pressable>
|
||||
)}
|
||||
{folder.allow_download && (
|
||||
<IconLink href={f.file_url} label={`${T.download}: ${title}`}>
|
||||
<Download />
|
||||
</IconLink>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user