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:
@@ -0,0 +1,40 @@
|
||||
"""A share link may open a folder
|
||||
|
||||
Revision ID: 0108_folder_links
|
||||
Revises: 0107_link_token_at_rest
|
||||
Create Date: 2026-10-15
|
||||
|
||||
A `/watch` link opened one file. Now it may open one of its owner's own folders instead: the link's
|
||||
page lists what is in the folder when it is opened. `folder_id` sits beside `job_id`, exactly one of
|
||||
the two set; a folder's links go with the folder.
|
||||
|
||||
The downgrade has nowhere to keep a folder link, so it deletes them: they stop opening.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0108_folder_links"
|
||||
down_revision: str | None = "0107_link_token_at_rest"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"download_links",
|
||||
sa.Column("folder_id", sa.Integer(), sa.ForeignKey("file_folders.id", ondelete="CASCADE")),
|
||||
)
|
||||
op.create_index("ix_download_links_folder_id", "download_links", ["folder_id"])
|
||||
op.alter_column("download_links", "job_id", nullable=True)
|
||||
op.create_check_constraint(
|
||||
"ck_download_link_target", "download_links", "(job_id IS NULL) <> (folder_id IS NULL)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM download_links WHERE folder_id IS NOT NULL")
|
||||
op.drop_constraint("ck_download_link_target", "download_links", type_="check")
|
||||
op.alter_column("download_links", "job_id", nullable=False)
|
||||
op.drop_index("ix_download_links_folder_id", table_name="download_links")
|
||||
op.drop_column("download_links", "folder_id")
|
||||
@@ -11,12 +11,14 @@ import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.models import DownloadJob, MediaAsset
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset
|
||||
|
||||
_OG_BLOCK = re.compile(r"<!--OG:START-->.*?<!--OG:END-->", re.DOTALL)
|
||||
|
||||
@@ -77,18 +79,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
url = f"{settings.app_base}/watch/{token}"
|
||||
folder = link.folder_id is not None
|
||||
if link.password_hash:
|
||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||
return "\n ".join(
|
||||
[
|
||||
"<title>Siftlode</title>",
|
||||
_tag("og:title", "Password-protected video"),
|
||||
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
|
||||
_tag("og:description", "Shared via Siftlode"),
|
||||
_tag("og:type", "video.other"),
|
||||
_tag("og:type", "website" if folder else "video.other"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
]
|
||||
)
|
||||
if folder:
|
||||
return _folder_tags(db, link, url)
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
if job is None or asset is None or asset.status != "ready":
|
||||
@@ -146,6 +151,36 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
return "\n ".join(tags)
|
||||
|
||||
|
||||
def _folder_tags(db: Session, link: DownloadLink, url: str) -> str | None:
|
||||
"""A folder link's card: the folder's name and how many files it holds — never their names,
|
||||
which the page shows only to whoever opens it."""
|
||||
folder = db.get(FileFolder, link.folder_id)
|
||||
if folder is None or not folder.name:
|
||||
return None
|
||||
count = db.execute(
|
||||
select(func.count(DownloadJob.id)).where(
|
||||
DownloadJob.folder_id == folder.id,
|
||||
DownloadJob.user_id == folder.user_id,
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
)
|
||||
).scalar_one()
|
||||
title = folder.name.strip()
|
||||
about = f"{count} file{'' if count == 1 else 's'} · Shared via Siftlode"
|
||||
return "\n ".join(
|
||||
[
|
||||
f"<title>{html.escape(title)}</title>",
|
||||
_tag("og:title", title),
|
||||
_tag("og:description", about),
|
||||
_tag("og:type", "website"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
_tag("twitter:title", title, attr="name"),
|
||||
_tag("twitter:description", about, attr="name"),
|
||||
_tag("twitter:card", "summary", attr="name"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def render_watch_html(token: str, page_html: Path, db: Session) -> str | None:
|
||||
"""Return the watch page (watch.html) with the OG block replaced for this token, or None to
|
||||
serve the generic page unchanged (invalid/expired/not-ready link, or a template without the
|
||||
|
||||
+17
-8
@@ -1129,22 +1129,31 @@ class DownloadQuota(Base, UpdatedAtMixin):
|
||||
|
||||
|
||||
class DownloadLink(Base, TimestampMixin):
|
||||
"""A public capability link to one download — the "share by link" surface.
|
||||
"""A public capability link to one download, or to one of its owner's own folders — the "share
|
||||
by link" surface.
|
||||
|
||||
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
|
||||
player page (Google-Drive style). The token itself is never stored as written: a link is found
|
||||
by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner copies the
|
||||
link from again — absent without a key (`downloads/links.py`). Optional per-link controls: `expires_at`, `allow_download`
|
||||
(stream-only vs downloadable), and a `password_hash` (argon2, like a user password). Revoke =
|
||||
delete the row. Distinct from `DownloadShare`, which grants a *registered* user access via the
|
||||
in-app "Shared with me" list."""
|
||||
player page (Google-Drive style); a folder link opens the folder's list there, with what is in
|
||||
the folder at the time it is opened. The token itself is never stored as written: a link is
|
||||
found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
|
||||
copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
|
||||
controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
|
||||
(argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
|
||||
grants a *registered* user access via the in-app "Shared with me" list."""
|
||||
|
||||
__tablename__ = "download_links"
|
||||
__table_args__ = (
|
||||
# A link opens exactly one thing: a file or a folder.
|
||||
CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
job_id: Mapped[int] = mapped_column(
|
||||
job_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
folder_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
||||
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
||||
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
|
||||
@@ -30,10 +30,12 @@ from app.downloads import edit as editmod
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import quota, service, storage
|
||||
from app.downloads import urlguard
|
||||
from app.downloads.folders import FolderKind
|
||||
from app.downloads.status import FINISHED, NOT_REFETCHABLE, JobStatus
|
||||
from app.models import (
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
FileFolder,
|
||||
DownloadProfile,
|
||||
DownloadQuota,
|
||||
DownloadShare,
|
||||
@@ -717,8 +719,12 @@ def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
|
||||
link = db.get(DownloadLink, link_id)
|
||||
if link is None:
|
||||
raise not_found("link")
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
if job is None or job.user_id != user.id:
|
||||
target = (
|
||||
db.get(DownloadJob, link.job_id)
|
||||
if link.job_id is not None
|
||||
else db.get(FileFolder, link.folder_id)
|
||||
)
|
||||
if target is None or target.user_id != user.id:
|
||||
raise not_found("link")
|
||||
return link
|
||||
|
||||
@@ -731,21 +737,17 @@ def _link_expiry(days: int | None) -> datetime | None:
|
||||
return datetime.now(timezone.utc) + timedelta(days=min(days, 3650))
|
||||
|
||||
|
||||
def _links_of(db: Session, *where) -> list[dict]:
|
||||
rows = db.execute(select(DownloadLink).where(*where).order_by(DownloadLink.id.desc()))
|
||||
return [linksmod.owner_view(row) for row in rows.scalars()]
|
||||
|
||||
|
||||
@router.get("/{job_id}/links")
|
||||
def list_links(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
rows = (
|
||||
db.execute(
|
||||
select(DownloadLink)
|
||||
.where(DownloadLink.job_id == job_id)
|
||||
.order_by(DownloadLink.id.desc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [linksmod.owner_view(row) for row in rows]
|
||||
return _links_of(db, DownloadLink.job_id == job_id)
|
||||
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
@@ -759,20 +761,15 @@ class CreateLinkIn(BaseModel):
|
||||
expires_days: int | None = None
|
||||
|
||||
|
||||
@router.post("/{job_id}/links")
|
||||
def create_link(
|
||||
job_id: int,
|
||||
body: CreateLinkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != JobStatus.DONE or job.asset_id is None:
|
||||
raise refused(409, "share_unfinished")
|
||||
def _new_link(
|
||||
db: Session, user: User, body: CreateLinkIn, **target: int
|
||||
) -> tuple[DownloadLink, str]:
|
||||
"""Make a link to `target` (`job_id=` or `folder_id=`, already checked to be the user's), and
|
||||
the token it is opened by — which is in hand only now."""
|
||||
pw = body.password.strip()
|
||||
token = linksmod.new_token()
|
||||
link = DownloadLink(
|
||||
job_id=job_id,
|
||||
**target,
|
||||
created_by=user.id,
|
||||
allow_download=body.allow_download,
|
||||
password_hash=hash_password(pw) if pw else None,
|
||||
@@ -787,10 +784,53 @@ def create_link(
|
||||
audit.AuditAction.DOWNLOAD_LINK_CREATE,
|
||||
target_type="download_link",
|
||||
target_id=link.id,
|
||||
after={"job_id": job_id, **_link_audit(link)},
|
||||
after={**target, **_link_audit(link)},
|
||||
)
|
||||
return link, token
|
||||
|
||||
|
||||
@router.post("/{job_id}/links")
|
||||
def create_link(
|
||||
job_id: int,
|
||||
body: CreateLinkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
|
||||
if job.status != JobStatus.DONE or job.asset_id is None:
|
||||
raise refused(409, "share_unfinished")
|
||||
link, token = _new_link(db, user, body, job_id=job_id)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link, token=token)
|
||||
|
||||
|
||||
def _shareable_folder(db: Session, user: User, folder_id: int) -> FileFolder:
|
||||
"""One of the user's own folders. A source folder lets its files go on its own clock, and the Hub
|
||||
is the user's channel to a machine of theirs: neither is a shelf to hand out."""
|
||||
folder = owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||
if folder.kind != FolderKind.OWN:
|
||||
raise refused(409, "share_folder_kind")
|
||||
return folder
|
||||
|
||||
|
||||
@router.get("/folders/{folder_id}/links")
|
||||
def list_folder_links(
|
||||
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
owned_or_404(db, FileFolder, folder_id, user, what="folder")
|
||||
return _links_of(db, DownloadLink.folder_id == folder_id)
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/links")
|
||||
def create_folder_link(
|
||||
folder_id: int,
|
||||
body: CreateLinkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
_shareable_folder(db, user, folder_id)
|
||||
link, token = _new_link(db, user, body, folder_id=folder_id)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return linksmod.owner_view(link, token=token)
|
||||
|
||||
|
||||
@@ -856,7 +896,7 @@ def revoke_link(
|
||||
audit.AuditAction.DOWNLOAD_LINK_REVOKE,
|
||||
target_type="download_link",
|
||||
target_id=link_id,
|
||||
after={"job_id": link.job_id},
|
||||
after={"job_id": link.job_id} if link.job_id is not None else {"folder_id": link.folder_id},
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": link_id}
|
||||
|
||||
@@ -47,6 +47,7 @@ from app.mailtext import lang_for_user
|
||||
from app.models import (
|
||||
DISPLAY_TEXT_MAX,
|
||||
DownloadJob,
|
||||
DownloadLink,
|
||||
DownloadShare,
|
||||
FileFolder,
|
||||
LibraryState,
|
||||
@@ -352,7 +353,7 @@ def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob:
|
||||
# --- folders -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
|
||||
def _serialize_folder(folder: FileFolder, count: int, size: int, linked: bool = False) -> dict:
|
||||
return {
|
||||
"id": folder.id,
|
||||
"kind": folder.kind,
|
||||
@@ -363,9 +364,23 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict:
|
||||
"count": count,
|
||||
"size_bytes": size,
|
||||
"created_at": iso(folder.created_at),
|
||||
# A live public link opens it: whatever is moved in is shown to whoever holds the link.
|
||||
"has_link": linked,
|
||||
}
|
||||
|
||||
|
||||
def _linked(db: Session, folder_ids: list[int]) -> set[int]:
|
||||
"""Which of these folders a live public link opens (one not past its expiry)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = db.execute(
|
||||
select(DownloadLink.folder_id).where(
|
||||
DownloadLink.folder_id.in_(folder_ids),
|
||||
or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
|
||||
)
|
||||
).scalars()
|
||||
return {folder_id for folder_id in rows if folder_id is not None}
|
||||
|
||||
|
||||
@router.get("/folders")
|
||||
def list_folders(
|
||||
caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db)
|
||||
@@ -388,7 +403,8 @@ def list_folders(
|
||||
.group_by(FileFolder.id)
|
||||
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
||||
).all()
|
||||
return [_serialize_folder(f, count, int(size)) for f, count, size in rows]
|
||||
linked = _linked(db, [f.id for f, _, _ in rows])
|
||||
return [_serialize_folder(f, count, int(size), f.id in linked) for f, count, size in rows]
|
||||
|
||||
|
||||
class FolderIn(BaseModel):
|
||||
@@ -471,7 +487,7 @@ def update_folder(
|
||||
)
|
||||
db.commit()
|
||||
count, size = _folder_totals(db, folder.id)
|
||||
return _serialize_folder(folder, count, size)
|
||||
return _serialize_folder(folder, count, size, folder.id in _linked(db, [folder.id]))
|
||||
|
||||
|
||||
def _folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
|
||||
|
||||
+165
-51
@@ -9,6 +9,7 @@ a short-lived signed grant that the media URL carries (a `<video src>` can't sen
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
@@ -16,11 +17,12 @@ from app.db import get_db
|
||||
from app.downloads import links as linksmod
|
||||
from app.downloads import storage
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset, User
|
||||
from app.models import DownloadJob, DownloadLink, FileFolder, MediaAsset, User
|
||||
from app.ratelimit import RateLimiter, client_key
|
||||
from app.security import verify_password
|
||||
from app.refusal import refused
|
||||
from app.routes._common import not_found
|
||||
from app.routes.files import MAX_LIST
|
||||
|
||||
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||
|
||||
@@ -35,6 +37,9 @@ _unlock_limit = RateLimiter(max_events=10, window_seconds=300)
|
||||
# already running when it fills are still checked — as many as the route's threadpool runs at once.
|
||||
_unlock_failures = RateLimiter(max_events=50, window_seconds=300)
|
||||
|
||||
# The most files a folder link lists: as many as the owner's own Files list shows.
|
||||
_FOLDER_MAX = MAX_LIST
|
||||
|
||||
|
||||
def _link_or_404(db: Session, token: str) -> DownloadLink:
|
||||
link = linksmod.find(db, token)
|
||||
@@ -54,8 +59,7 @@ def _being_made(db: Session, job: DownloadJob) -> bool:
|
||||
return owner is not None and not owner.is_suspended
|
||||
|
||||
|
||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
def _ready_job(db: Session, job: DownloadJob | None) -> tuple[DownloadJob, MediaAsset]:
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
if (
|
||||
job is not None
|
||||
@@ -78,36 +82,127 @@ def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAs
|
||||
return job, asset
|
||||
|
||||
|
||||
def _ready_asset(db: Session, link: DownloadLink) -> MediaAsset:
|
||||
return _ready_target(db, link)[1]
|
||||
def _ready_target(db: Session, link: DownloadLink) -> tuple[DownloadJob, MediaAsset]:
|
||||
"""A file link's file. A folder link names no one file: its files are asked for by id."""
|
||||
if link.job_id is None:
|
||||
raise refused(404, "video_gone")
|
||||
return _ready_job(db, db.get(DownloadJob, link.job_id))
|
||||
|
||||
|
||||
def _file_url(token: str, grant: str | None = None) -> str:
|
||||
base = f"/api/public/watch/{token}/file"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
def _in_folder(link: DownloadLink, folder: FileFolder) -> tuple:
|
||||
"""What a folder link shows: the done files in its folder NOW, the folder owner's. A file moved
|
||||
out stops opening through the link at its next request; one moved in starts."""
|
||||
return (
|
||||
DownloadJob.folder_id == link.folder_id,
|
||||
DownloadJob.user_id == folder.user_id,
|
||||
DownloadJob.status == JobStatus.DONE,
|
||||
)
|
||||
|
||||
|
||||
def _poster_url(token: str, asset, grant: str | None = None) -> str | None:
|
||||
def _link_folder(db: Session, link: DownloadLink) -> FileFolder:
|
||||
folder = db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
|
||||
if folder is None:
|
||||
raise refused(404, "link_gone")
|
||||
return folder
|
||||
|
||||
|
||||
def _folder_job(db: Session, link: DownloadLink, job_id: int) -> tuple[DownloadJob, MediaAsset]:
|
||||
folder = _link_folder(db, link)
|
||||
job = db.execute(
|
||||
select(DownloadJob).where(DownloadJob.id == job_id, *_in_folder(link, folder))
|
||||
).scalar_one_or_none()
|
||||
if job is None:
|
||||
raise refused(404, "video_gone")
|
||||
return _ready_job(db, job)
|
||||
|
||||
|
||||
def _base(token: str, job_id: int | None) -> str:
|
||||
"""A file link's file lives under its token; a folder link's files, each under its id."""
|
||||
return f"/api/public/watch/{token}" + (f"/files/{job_id}" if job_id is not None else "")
|
||||
|
||||
|
||||
def _file_url(token: str, grant: str | None = None, job_id: int | None = None) -> str:
|
||||
url = f"{_base(token, job_id)}/file"
|
||||
return f"{url}?g={grant}" if grant else url
|
||||
|
||||
|
||||
def _poster_url(
|
||||
token: str, asset: MediaAsset, grant: str | None = None, job_id: int | None = None
|
||||
) -> str | None:
|
||||
if not asset.poster_path:
|
||||
return None
|
||||
base = f"/api/public/watch/{token}/poster.jpg"
|
||||
return f"{base}?g={grant}" if grant else base
|
||||
url = f"{_base(token, job_id)}/poster.jpg"
|
||||
return f"{url}?g={grant}" if grant else url
|
||||
|
||||
|
||||
def _folder_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
|
||||
"""A folder link's page: the folder's name and each ready file's own watch metadata, newest
|
||||
first. A file whose bytes are not on disk is left out, so the list never offers a dead row."""
|
||||
folder = _link_folder(db, link)
|
||||
rows = db.execute(
|
||||
select(DownloadJob, MediaAsset)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(*_in_folder(link, folder), MediaAsset.status == "ready")
|
||||
.order_by(DownloadJob.ready_at.desc().nulls_last(), DownloadJob.id.desc())
|
||||
.limit(_FOLDER_MAX)
|
||||
).all()
|
||||
files = [
|
||||
{
|
||||
"id": job.id,
|
||||
**linksmod.public_meta(
|
||||
link,
|
||||
job,
|
||||
asset,
|
||||
_file_url(token, grant, job.id),
|
||||
_poster_url(token, asset, grant, job.id),
|
||||
),
|
||||
}
|
||||
for job, asset in rows
|
||||
if storage.safe_abs_path(settings.files_root, asset.rel_path) is not None
|
||||
]
|
||||
return {
|
||||
"needs_password": False,
|
||||
"kind": "folder",
|
||||
"name": folder.name,
|
||||
"allow_download": link.allow_download,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _file_meta(db: Session, link: DownloadLink, token: str, grant: str | None) -> dict:
|
||||
job, asset = _ready_target(db, link)
|
||||
return {
|
||||
"needs_password": False,
|
||||
"kind": "file",
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _opened(db: Session, link: DownloadLink, token: str, grant: str | None = None) -> dict:
|
||||
"""The page an open link shows, counted as one view (the handler commits the count)."""
|
||||
meta = (
|
||||
_folder_meta(db, link, token, grant)
|
||||
if link.folder_id is not None
|
||||
else _file_meta(db, link, token, grant)
|
||||
)
|
||||
link.view_count += 1
|
||||
db.flush()
|
||||
return meta
|
||||
|
||||
|
||||
@router.get("/watch/{token}")
|
||||
def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
"""Public metadata for the watch page. For a password link, returns only `needs_password`
|
||||
until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
"""Public metadata for the watch page: one file's, or a folder's list. For a password link,
|
||||
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash:
|
||||
return {"needs_password": True}
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
# Which kind it opens, for the password prompt's title — never what is in it.
|
||||
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
|
||||
meta = _opened(db, link, token)
|
||||
db.commit()
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
class WatchUnlockIn(BaseModel):
|
||||
@@ -118,7 +213,7 @@ class WatchUnlockIn(BaseModel):
|
||||
def watch_unlock(
|
||||
token: str, body: WatchUnlockIn, request: Request, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Exchange the link password for a signed grant carried by the media URL."""
|
||||
"""Exchange the link password for a signed grant carried by the media URLs."""
|
||||
# The link first: a token naming none must leave no limiter entry behind, or posting made-up tokens
|
||||
# would grow the limiter without bound.
|
||||
link = _link_or_404(db, token)
|
||||
@@ -127,36 +222,27 @@ def watch_unlock(
|
||||
raise refused(429, "too_many_attempts")
|
||||
if not link.password_hash:
|
||||
# Not a password link — nothing to unlock; just hand back the plain meta.
|
||||
job, asset = _ready_target(db, link)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(link, job, asset, _file_url(token), _poster_url(token, asset)),
|
||||
}
|
||||
meta = _opened(db, link, token)
|
||||
db.commit()
|
||||
return {"ok": True, **meta}
|
||||
if not verify_password(body.password, link.password_hash):
|
||||
_unlock_failures.allow(token)
|
||||
raise refused(403, "wrong_share_password")
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token, link.password_version)
|
||||
return {
|
||||
"ok": True,
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(
|
||||
link, job, asset, _file_url(token, grant), _poster_url(token, asset, grant)
|
||||
),
|
||||
}
|
||||
meta = _opened(db, link, token, grant)
|
||||
db.commit()
|
||||
return {"ok": True, **meta}
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
def _granted(link: DownloadLink, token: str, g: str | None) -> None:
|
||||
"""A password link's files want the grant its unlock handed out."""
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise refused(403, "share_password_required")
|
||||
asset = _ready_asset(db, link)
|
||||
|
||||
|
||||
def _serve(link: DownloadLink, asset: MediaAsset) -> FileResponse:
|
||||
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment."""
|
||||
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
|
||||
if path is None:
|
||||
raise refused(404, "video_gone")
|
||||
@@ -183,17 +269,45 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise refused(403, "share_password_required")
|
||||
job, asset = _ready_target(db, link)
|
||||
def _poster(asset: MediaAsset) -> FileResponse:
|
||||
if not asset.poster_path:
|
||||
raise not_found("poster")
|
||||
path = storage.safe_abs_path(settings.files_root, asset.poster_path)
|
||||
if path is None:
|
||||
raise not_found("poster")
|
||||
return FileResponse(path, media_type="image/jpeg")
|
||||
|
||||
|
||||
@router.get("/watch/{token}/file")
|
||||
def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""A file link's file. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _serve(link, _ready_target(db, link)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/poster.jpg")
|
||||
def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _poster(_ready_target(db, link)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/files/{job_id}/file")
|
||||
def watch_folder_file(token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""One file of a folder link's folder, while it is in it. Password links require a grant."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _serve(link, _folder_job(db, link, job_id)[1])
|
||||
|
||||
|
||||
@router.get("/watch/{token}/files/{job_id}/poster.jpg")
|
||||
def watch_folder_poster(
|
||||
token: str, job_id: int, g: str | None = None, db: Session = Depends(get_db)
|
||||
):
|
||||
"""The poster frame of one file of a folder link's folder, under the same terms as the file."""
|
||||
link = _link_or_404(db, token)
|
||||
_granted(link, token, g)
|
||||
return _poster(_folder_job(db, link, job_id)[1])
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""A `/watch` link to one of its owner's own folders (routes/downloads.py, routes/public.py), DB-lane.
|
||||
|
||||
The link shows what is in the folder when it is opened: a file moved in appears, one moved out stops
|
||||
opening at its next request. Only an own folder can be shared — a source folder lets its files go on
|
||||
its own clock, the Hub is the channel to a machine. A password, an expiry and stream-only work as on
|
||||
a file link; a document is never served to open as a page.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.downloads import og
|
||||
from app.downloads import probe as probemod
|
||||
from app.models import AuditLog, DownloadJob, DownloadLink, FileFolder
|
||||
|
||||
_VIDEO = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
"format": {"format_name": "mov,mp4", "duration": "5"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe(monkeypatch):
|
||||
"""What ffprobe says the next upload is: a phone-safe video, or (None) a file it cannot read."""
|
||||
said = {"data": _VIDEO}
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: said["data"])
|
||||
return said
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shelf(as_user, make_user, tmp_path, monkeypatch, upload_headers, probe):
|
||||
"""An owner with an own folder "Friends" holding two videos, a third video in their Uploads, and
|
||||
a function to upload more."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
user = make_user()
|
||||
owner = as_user(user)
|
||||
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
|
||||
|
||||
def upload(name: str, folder_id: int | None = None, body: bytes = b"fake-video") -> int:
|
||||
r = owner.post(
|
||||
"/api/files/upload", content=body, headers=upload_headers(name, folder=folder_id)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
inside = [upload("a.mp4", folder["id"]), upload("b.mp4", folder["id"])]
|
||||
outside = upload("c.mp4")
|
||||
return {
|
||||
"user": user,
|
||||
"owner": owner,
|
||||
"folder": folder["id"],
|
||||
"inside": inside,
|
||||
"outside": outside,
|
||||
"upload": upload,
|
||||
}
|
||||
|
||||
|
||||
def _link(shelf, **body) -> dict:
|
||||
r = shelf["owner"].post(f"/api/downloads/folders/{shelf['folder']}/links", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _ids(client, token: str) -> list[int]:
|
||||
meta = client.get(f"/api/public/watch/{token}").json()
|
||||
return [f["id"] for f in meta["files"]]
|
||||
|
||||
|
||||
def test_a_folder_link_lists_what_is_in_the_folder(shelf, client):
|
||||
token = _link(shelf)["token"]
|
||||
meta = client.get(f"/api/public/watch/{token}").json()
|
||||
assert meta["kind"] == "folder"
|
||||
assert meta["name"] == "Friends"
|
||||
assert sorted(f["id"] for f in meta["files"]) == sorted(shelf["inside"])
|
||||
# Each one plays and downloads through the link, under its own id.
|
||||
for f in meta["files"]:
|
||||
assert f["file_url"] == f"/api/public/watch/{token}/files/{f['id']}/file"
|
||||
served = client.get(f["file_url"])
|
||||
assert served.status_code == 200
|
||||
assert served.content == b"fake-video"
|
||||
|
||||
|
||||
def test_a_file_outside_the_folder_does_not_open_through_it(shelf, client):
|
||||
token = _link(shelf)["token"]
|
||||
r = client.get(f"/api/public/watch/{token}/files/{shelf['outside']}/file")
|
||||
assert r.status_code == 404
|
||||
# Nor does the folder link open as a file link.
|
||||
assert client.get(f"/api/public/watch/{token}/file").status_code == 404
|
||||
|
||||
|
||||
def test_the_link_follows_the_folder_as_files_move(shelf, client):
|
||||
token = _link(shelf)["token"]
|
||||
owner, moved_out = shelf["owner"], shelf["inside"][0]
|
||||
other = owner.post("/api/files/folders", json={"name": "Other"}).json()["id"]
|
||||
|
||||
def move(job_id: int, folder_id: int) -> None:
|
||||
r = owner.patch(f"/api/files/{job_id}", json={"folder_id": folder_id})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
move(shelf["outside"], shelf["folder"])
|
||||
move(moved_out, other)
|
||||
|
||||
assert sorted(_ids(client, token)) == sorted([shelf["inside"][1], shelf["outside"]])
|
||||
assert client.get(f"/api/public/watch/{token}/files/{moved_out}/file").status_code == 404
|
||||
url = f"/api/public/watch/{token}/files/{shelf['outside']}/file"
|
||||
assert client.get(url).status_code == 200
|
||||
|
||||
|
||||
def test_only_a_folder_of_ones_own_can_be_shared(shelf, db, as_user, make_user):
|
||||
owner = shelf["owner"]
|
||||
source = db.execute(
|
||||
select(FileFolder.id).where(
|
||||
FileFolder.user_id == shelf["user"].id, FileFolder.kind == "source"
|
||||
)
|
||||
).scalar_one()
|
||||
r = owner.post(f"/api/downloads/folders/{source}/links", json={})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"]["reason"] == "share_folder_kind"
|
||||
|
||||
stranger = as_user(make_user())
|
||||
assert (
|
||||
stranger.post(f"/api/downloads/folders/{shelf['folder']}/links", json={}).status_code == 404
|
||||
)
|
||||
assert stranger.get(f"/api/downloads/folders/{shelf['folder']}/links").status_code == 404
|
||||
link_id = _link(shelf)["id"]
|
||||
patch = stranger.patch(f"/api/downloads/links/{link_id}", json={"allow_download": False})
|
||||
assert patch.status_code == 404
|
||||
assert stranger.delete(f"/api/downloads/links/{link_id}").status_code == 404
|
||||
|
||||
|
||||
def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
|
||||
token = _link(shelf, password="open sesame")["token"]
|
||||
assert client.get(f"/api/public/watch/{token}").json() == {
|
||||
"needs_password": True,
|
||||
"kind": "folder",
|
||||
}
|
||||
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
|
||||
assert client.get(bare).status_code == 403
|
||||
|
||||
unlocked = client.post(f"/api/public/watch/{token}/unlock", json={"password": "open sesame"})
|
||||
assert unlocked.status_code == 200
|
||||
files = unlocked.json()["files"]
|
||||
assert len(files) == 2
|
||||
assert all("?g=" in f["file_url"] for f in files)
|
||||
assert client.get(files[0]["file_url"]).status_code == 200
|
||||
|
||||
|
||||
def test_a_document_in_a_shared_folder_only_downloads(shelf, client, probe):
|
||||
probe["data"] = None
|
||||
doc = shelf["upload"]("notes.html", shelf["folder"], b"<script>1</script>")
|
||||
token = _link(shelf, allow_download=False)["token"]
|
||||
assert doc in _ids(client, token)
|
||||
served = client.get(f"/api/public/watch/{token}/files/{doc}/file")
|
||||
assert served.status_code == 200
|
||||
assert served.headers["content-type"].startswith("application/octet-stream")
|
||||
assert "attachment" in served.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_a_stream_only_folder_link_plays_inline(shelf, client):
|
||||
token = _link(shelf, allow_download=False)["token"]
|
||||
meta = client.get(f"/api/public/watch/{token}").json()
|
||||
assert meta["allow_download"] is False
|
||||
served = client.get(meta["files"][0]["file_url"])
|
||||
assert "inline" in served.headers["content-disposition"].lower()
|
||||
|
||||
|
||||
def test_an_expired_or_deleted_folder_link_is_gone(shelf, client, db):
|
||||
made = _link(shelf)
|
||||
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db.commit()
|
||||
assert client.get(f"/api/public/watch/{made['token']}").status_code == 404
|
||||
|
||||
token = _link(shelf)["token"]
|
||||
assert shelf["owner"].delete(f"/api/files/folders/{shelf['folder']}").status_code == 200
|
||||
assert client.get(f"/api/public/watch/{token}").status_code == 404
|
||||
db.expire_all()
|
||||
assert db.execute(select(DownloadLink)).scalars().all() == []
|
||||
|
||||
|
||||
def test_the_folder_list_marks_a_folder_a_live_link_opens(shelf, db):
|
||||
owner = shelf["owner"]
|
||||
|
||||
def marked() -> dict[int, bool]:
|
||||
return {f["id"]: f["has_link"] for f in owner.get("/api/files/folders").json()}
|
||||
|
||||
assert marked()[shelf["folder"]] is False
|
||||
made = _link(shelf)
|
||||
assert marked()[shelf["folder"]] is True
|
||||
# A rename answers with the mark too, so the rail does not lose it.
|
||||
renamed = owner.patch(f"/api/files/folders/{shelf['folder']}", json={"name": "Pals"}).json()
|
||||
assert renamed["has_link"] is True
|
||||
|
||||
db.get(DownloadLink, made["id"]).expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
db.commit()
|
||||
assert marked()[shelf["folder"]] is False
|
||||
live = _link(shelf)
|
||||
assert owner.delete(f"/api/downloads/links/{live['id']}").status_code == 200
|
||||
assert marked()[shelf["folder"]] is False
|
||||
assert not any(marked()[f] for f in marked() if f != shelf["folder"])
|
||||
|
||||
|
||||
def test_making_and_revoking_a_folder_link_is_audited(shelf, db):
|
||||
made = _link(shelf)
|
||||
assert shelf["owner"].delete(f"/api/downloads/links/{made['id']}").status_code == 200
|
||||
db.expire_all()
|
||||
rows = {
|
||||
a.action: a.after
|
||||
for a in db.execute(
|
||||
select(AuditLog).where(AuditLog.action.like("download_link_%"))
|
||||
).scalars()
|
||||
}
|
||||
assert rows["download_link_create"]["folder_id"] == shelf["folder"]
|
||||
assert "job_id" not in rows["download_link_create"]
|
||||
assert rows["download_link_revoke"] == {"folder_id": shelf["folder"]}
|
||||
|
||||
|
||||
def test_the_unfurl_names_the_folder_and_counts_its_files_only(shelf, db):
|
||||
token = _link(shelf)["token"]
|
||||
tags = og._watch_tags(token, db)
|
||||
assert "Friends" in tags
|
||||
assert "2 files" in tags
|
||||
names = db.execute(
|
||||
select(DownloadJob.display_name).where(DownloadJob.id.in_(shelf["inside"]))
|
||||
).scalars()
|
||||
for name in names:
|
||||
assert not name or name not in tags
|
||||
|
||||
locked = _link(shelf, password="pw")["token"]
|
||||
hidden = og._watch_tags(locked, db)
|
||||
assert "Friends" not in hidden and "Password-protected folder" in hidden
|
||||
assert 'content="website"' in hidden and "video.other" not in hidden
|
||||
|
||||
|
||||
def test_the_downgrade_drops_folder_links_and_keeps_file_links(shelf, db, client):
|
||||
_link(shelf)
|
||||
file_link = shelf["owner"].post(f"/api/downloads/{shelf['outside']}/links", json={}).json()
|
||||
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0107_link_token_at_rest")
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
left = conn.execute(text("SELECT job_id FROM download_links")).scalars().all()
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
assert left == [shelf["outside"]]
|
||||
assert client.get(f"/api/public/watch/{file_link['token']}").status_code == 200
|
||||
|
||||
|
||||
def test_a_password_file_link_says_it_opens_a_file(shelf, client):
|
||||
made = (
|
||||
shelf["owner"]
|
||||
.post(f"/api/downloads/{shelf['outside']}/links", json={"password": "pw"})
|
||||
.json()
|
||||
)
|
||||
meta = client.get(f"/api/public/watch/{made['token']}").json()
|
||||
assert meta == {"needs_password": True, "kind": "file"}
|
||||
|
||||
|
||||
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
|
||||
from pathlib import Path
|
||||
|
||||
from app.models import MediaAsset
|
||||
|
||||
token = _link(shelf)["token"]
|
||||
gone = shelf["inside"][0]
|
||||
asset = db.get(MediaAsset, db.get(DownloadJob, gone).asset_id)
|
||||
(Path(settings.files_root) / asset.rel_path).unlink()
|
||||
assert _ids(client, token) == [shelf["inside"][1]]
|
||||
assert client.get(f"/api/public/watch/{token}/files/{gone}/file").status_code == 404
|
||||
@@ -50,6 +50,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
...extra,
|
||||
});
|
||||
|
||||
|
||||
@@ -195,7 +195,10 @@ export default function DownloadTray() {
|
||||
title,
|
||||
}),
|
||||
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
|
||||
action: { label: t("tray.done.copyLink"), onClick: () => void quickShareLink(qc, job, t) },
|
||||
action: {
|
||||
label: t("tray.done.copyLink"),
|
||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
||||
},
|
||||
secondaryAction: {
|
||||
label: t("tray.done.open"),
|
||||
onClick: () => openFile(job.id, job.folder_id),
|
||||
@@ -295,7 +298,7 @@ export default function DownloadTray() {
|
||||
id: "copyLink",
|
||||
label: t("tray.done.copyLink"),
|
||||
icon: <Link className="w-4 h-4" />,
|
||||
onSelect: () => void quickShareLink(qc, job, t),
|
||||
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
||||
},
|
||||
]);
|
||||
const menuItems = menu.target
|
||||
|
||||
@@ -151,6 +151,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
...extra,
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
@@ -853,12 +854,14 @@ describe("Files · the grid", () => {
|
||||
describe("Files · the open folder's menu", () => {
|
||||
const folderMenu = () => screen.getByRole("button", { name: "files.folder.menu" });
|
||||
|
||||
it("an own folder is renamed, kept for a while, or deleted", async () => {
|
||||
it("an own folder is shared by link, renamed, kept for a while, or deleted", async () => {
|
||||
mocks.files = [];
|
||||
mocks.folders = [YT, OWN];
|
||||
mount({ kind: "folder", id: 2 });
|
||||
await openMenu(() => fireEvent.click(folderMenu()));
|
||||
expect(menuRows()).toEqual([
|
||||
"files.actions.copyLink",
|
||||
"files.folder.shareLink",
|
||||
"files.folder.rename",
|
||||
"files.folder.retention",
|
||||
"files.folder.delete",
|
||||
|
||||
@@ -751,6 +751,7 @@ export default function Files() {
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={folderMenu.target ? folderActions.items(folderMenu.target) : []}
|
||||
/>
|
||||
{folderActions.dialog}
|
||||
<Suspense fallback={null}>
|
||||
{upload && (
|
||||
<UploadDialog
|
||||
|
||||
@@ -62,6 +62,7 @@ const folder = (id: number, extra: Partial<FileFolder>): FileFolder => ({
|
||||
count: 1,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
...extra,
|
||||
});
|
||||
const YT = folder(1, { source_key: "youtube" });
|
||||
@@ -124,6 +125,8 @@ describe("FilesRail · folders", () => {
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(menuRows()).toEqual([
|
||||
"files.actions.copyLink",
|
||||
"files.folder.shareLink",
|
||||
"files.folder.rename",
|
||||
"files.folder.retention",
|
||||
"files.folder.delete",
|
||||
@@ -196,3 +199,62 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
|
||||
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesRail · a folder shared by link", () => {
|
||||
it("marks the folder a live link opens, and only that one", async () => {
|
||||
const work = folder(5, { kind: "own", name: "Munka", retention_days: null });
|
||||
await mount([YT, { ...OWN, has_link: true }, work]);
|
||||
expect(entry("Receptek").textContent).toContain("files.folder.shared");
|
||||
expect(entry("Munka").textContent).not.toContain("files.folder.shared");
|
||||
expect(entry("files.source.youtube").textContent).not.toContain("files.folder.shared");
|
||||
});
|
||||
|
||||
it("copies the folder's link in one click, making it the first time", async () => {
|
||||
await mount();
|
||||
const list = vi.spyOn(api, "folderLinks").mockResolvedValue([]);
|
||||
const make = vi
|
||||
.spyOn(api, "createFolderLink")
|
||||
.mockResolvedValue({ id: 1, url: "/watch/f" } as never);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.actions.copyLink"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.actions.copyLink")!,
|
||||
);
|
||||
await waitFor(() => expect(make).toHaveBeenCalledWith(2, {}));
|
||||
expect(list).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("opens the folder's Share dialog", async () => {
|
||||
await mount();
|
||||
vi.spyOn(api, "folderLinks").mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
token: "f",
|
||||
url: "/watch/f",
|
||||
allow_download: true,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
},
|
||||
]);
|
||||
act(() => {
|
||||
fireEvent.contextMenu(entry("Receptek"));
|
||||
});
|
||||
await waitFor(() => expect(menuRows()).toContain("files.folder.shareLink"));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "files.folder.shareLink")!,
|
||||
);
|
||||
// The dialog is its own chunk, fetched on first use: slower than a re-render under a full run.
|
||||
expect(
|
||||
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
|
||||
).toBeTruthy();
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Meter } from "./ui/Meter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { FolderOpen, FolderPlus, HardDrive, Users } from "lucide-react";
|
||||
import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
|
||||
import { api, type FileFolder } from "../lib/api";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { formatBytes } from "../lib/format";
|
||||
@@ -36,16 +36,20 @@ function Entry({
|
||||
view,
|
||||
trigger,
|
||||
folderId,
|
||||
shared = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count?: number;
|
||||
/** A live public link opens the folder: what is moved in is shown to whoever holds the link. */
|
||||
shared?: boolean;
|
||||
view: FilesView;
|
||||
/** The folder's menu triggers (right click, long press, Shift+F10). */
|
||||
trigger?: Record<string, unknown>;
|
||||
/** A folder's entry is found by it, to hand the keyboard back after a rename. */
|
||||
folderId?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { view: current } = useFiles();
|
||||
const { setView } = useFilesActions();
|
||||
const active = sameView(view, current);
|
||||
@@ -64,6 +68,12 @@ function Entry({
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
{shared && (
|
||||
<span title={t("files.folder.shared")} className="shrink-0">
|
||||
<Link2 aria-hidden className="size-3.5 text-accent" />
|
||||
<span className="sr-only">{t("files.folder.shared")}</span>
|
||||
</span>
|
||||
)}
|
||||
{count !== undefined && <span className="text-xs tabular-nums text-muted">{count}</span>}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -176,6 +186,7 @@ export default function FilesRail({
|
||||
icon={folderIcon(f)}
|
||||
label={folderLabel(f, t)}
|
||||
count={f.count}
|
||||
shared={f.has_link}
|
||||
view={{ kind: "folder", id: f.id }}
|
||||
trigger={menu.triggerProps(f)}
|
||||
folderId={f.id}
|
||||
@@ -267,6 +278,7 @@ export default function FilesRail({
|
||||
ariaLabel={t("files.folder.menu")}
|
||||
items={menu.target ? folderActions.items(menu.target) : []}
|
||||
/>
|
||||
{folderActions.dialog}
|
||||
</FilterRail>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// colour class outranks them (clsx does not merge Tailwind), so a password-protected link looked
|
||||
// unprotected and Revoke hovered neutral. They live on the ICON now; this pins them there.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -15,12 +15,30 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./ui/Modal", () => ({ default: ({ children }: { children: ReactNode }) => children }));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => false }));
|
||||
const h = vi.hoisted(() => ({ links: null as unknown[] | null }));
|
||||
vi.mock("./ui/Modal", () => ({
|
||||
default: ({ title, children }: { title: string; children: ReactNode }) => (
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
|
||||
const h = vi.hoisted(() => ({
|
||||
confirm: false,
|
||||
revokeDownloadLink: vi.fn(),
|
||||
links: null as unknown[] | null,
|
||||
folderLinks: vi.fn(),
|
||||
createFolderLink: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
}));
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
shareRecipients: async () => [],
|
||||
folderLinks: h.folderLinks,
|
||||
createFolderLink: h.createFolderLink,
|
||||
createDownloadLink: h.createDownloadLink,
|
||||
revokeDownloadLink: h.revokeDownloadLink,
|
||||
downloadLinks: async () =>
|
||||
h.links ?? [
|
||||
{
|
||||
@@ -53,6 +71,7 @@ import type { DownloadJob } from "../lib/api";
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
h.links = null;
|
||||
h.confirm = false;
|
||||
});
|
||||
|
||||
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
||||
@@ -106,3 +125,86 @@ describe("ShareDialog · a link whose address cannot be shown again", () => {
|
||||
expect(screen.getAllByRole("button", { name: "downloads.share.revoke" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShareDialog · a folder", () => {
|
||||
const link = (id: number) => ({
|
||||
id,
|
||||
token: `f${id}`,
|
||||
url: `/watch/f${id}`,
|
||||
allow_download: true,
|
||||
has_password: false,
|
||||
expires_at: null,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
});
|
||||
|
||||
it("shares by link only, says what a folder link shows, and makes it for the folder", async () => {
|
||||
h.folderLinks.mockReset().mockResolvedValue([link(4)]);
|
||||
h.createFolderLink.mockReset().mockResolvedValue(link(5));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f4`)).toBeTruthy();
|
||||
expect(h.folderLinks).toHaveBeenCalledWith(7);
|
||||
expect(screen.getByRole("heading").textContent).toBe("downloads.share.folderTitle");
|
||||
expect(screen.getByText("downloads.share.folderLinkHint")).toBeTruthy();
|
||||
// No sharing with a user: "Shared with me" lists files, not folders.
|
||||
expect(screen.queryByText("downloads.share.userSection")).toBeNull();
|
||||
|
||||
// A stream-only link, then the form is back at its default for the next one: downloadable.
|
||||
fireEvent.click(screen.getByRole("button", { name: "downloads.share.newLink" }));
|
||||
// The form's own checkbox, under the link rows' (each row has its own).
|
||||
const allow = () => {
|
||||
const boxes = screen.getAllByRole("checkbox", { name: "downloads.share.allowDownload" });
|
||||
return boxes[boxes.length - 1]!;
|
||||
};
|
||||
fireEvent.click(allow());
|
||||
expect((allow() as HTMLInputElement).checked).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "downloads.share.createLink" }));
|
||||
await waitFor(() =>
|
||||
expect(h.createFolderLink).toHaveBeenCalledWith(
|
||||
7,
|
||||
expect.objectContaining({
|
||||
allow_download: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(h.createDownloadLink).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.newLink" }));
|
||||
expect((allow() as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("makes a link on opening with none, but never right after the last one was revoked", async () => {
|
||||
h.folderLinks.mockReset().mockResolvedValue([]);
|
||||
h.createFolderLink.mockReset().mockResolvedValue(link(6));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(h.createFolderLink).toHaveBeenCalledTimes(1));
|
||||
view.unmount();
|
||||
|
||||
h.confirm = true;
|
||||
h.folderLinks
|
||||
.mockReset()
|
||||
.mockResolvedValueOnce([link(4)])
|
||||
.mockResolvedValue([]);
|
||||
h.createFolderLink.mockReset();
|
||||
h.revokeDownloadLink.mockReset().mockResolvedValue({ ok: true, deleted: 4 });
|
||||
const qc2 = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc2}>
|
||||
<ShareDialog folder={{ id: 7, name: "Friends" }} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "downloads.share.revoke" }));
|
||||
await waitFor(() => expect(h.revokeDownloadLink).toHaveBeenCalledWith(4));
|
||||
await screen.findByText("downloads.share.noLinks");
|
||||
expect(h.createFolderLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
|
||||
import { fetchLinks, invalidateLinks, linksKey, makeLink, type LinkTarget } from "../lib/downloads";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input, InnerInput } from "./ui/Input";
|
||||
@@ -237,12 +238,12 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
function LinkShare({ job }: { job: DownloadJob }) {
|
||||
function LinkShare({ target }: { target: LinkTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const links = useQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
const [creating, setCreating] = useState(false);
|
||||
// Downloadable by default — matches the one-click Share (which relies on the backend
|
||||
@@ -250,11 +251,11 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [expiryDays, setExpiryDays] = useState<number | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const invalidate = () => void qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
|
||||
const invalidate = () => invalidateLinks(qc, target);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
api.createDownloadLink(job.id, {
|
||||
makeLink(target, {
|
||||
allow_download: allowDownload,
|
||||
expires_days: expiryDays,
|
||||
password: password.trim() || undefined,
|
||||
@@ -262,7 +263,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
setAllowDownload(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
@@ -276,21 +278,26 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
const autoTried = useRef(false);
|
||||
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
|
||||
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
|
||||
// The autoTried ref is what makes it fire exactly once.
|
||||
const { mutate: createLink, isPending: createPending } = create;
|
||||
// The autoTried ref is what makes it fire exactly once — and only on the list the dialog OPENED
|
||||
// with: revoking the last link empties the list too, and minting a fresh public link right after
|
||||
// the owner revoked one is the opposite of what they asked for.
|
||||
const { mutate: createLink } = create;
|
||||
useEffect(() => {
|
||||
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
|
||||
autoTried.current = true;
|
||||
createLink();
|
||||
}
|
||||
}, [links.data, createLink, createPending]);
|
||||
if (!links.data || autoTried.current) return;
|
||||
autoTried.current = true;
|
||||
if (links.data.length === 0) createLink();
|
||||
}, [links.data, createLink]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||
</div>
|
||||
<p className="text-xs text-muted mb-2">{t("downloads.share.linkHint")}</p>
|
||||
<p className="text-xs text-muted mb-2">
|
||||
{t(
|
||||
target.kind === "folder" ? "downloads.share.folderLinkHint" : "downloads.share.linkHint",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(links.data ?? []).map((l) => (
|
||||
@@ -359,14 +366,31 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
||||
// A file is shared with a registered user and by link; a folder by link only — "Shared with me" lists
|
||||
// files, and a folder link already shows its files to whoever has it.
|
||||
type ShareDialogProps = { onClose: () => void } & (
|
||||
| { job: DownloadJob; folder?: never }
|
||||
| { folder: { id: number; name: string }; job?: never }
|
||||
);
|
||||
|
||||
export default function ShareDialog({ job, folder, onClose }: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
if (folder)
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={t("downloads.share.folderTitle", { name: folder.name })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<LinkShare target={{ kind: "folder", id: folder.id }} />
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal initialFocus="close" title={t("downloads.share.title")} onClose={onClose}>
|
||||
<div className="space-y-5">
|
||||
<UserShare job={job} />
|
||||
<div className="border-t border-border" />
|
||||
<LinkShare job={job} />
|
||||
<LinkShare target={{ kind: "file", id: job.id }} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -324,3 +324,106 @@ describe("WatchPage resume", () => {
|
||||
expect(loaded(other.container.querySelector("video")!).currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// A folder link opens the folder's list: a video or a song plays in the same player a file link has,
|
||||
// above the list; a document only downloads; Download follows the link's own setting.
|
||||
describe("WatchPage folder", () => {
|
||||
const file = (id: number, extra: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
title: `Clip ${id}`,
|
||||
audio: false,
|
||||
media_kind: "video",
|
||||
size_bytes: 1000,
|
||||
duration_s: 71,
|
||||
file_url: `/api/public/watch/tok-folder/files/${id}/file`,
|
||||
poster_url: null,
|
||||
...extra,
|
||||
});
|
||||
const folder = (extra: Record<string, unknown> = {}) => ({
|
||||
needs_password: false,
|
||||
kind: "folder",
|
||||
name: "Friends",
|
||||
allow_download: true,
|
||||
files: [file(1), file(2), file(3, { title: "Plan.pdf", media_kind: "file", duration_s: null })],
|
||||
...extra,
|
||||
});
|
||||
const scrolled = vi.fn();
|
||||
Element.prototype.scrollIntoView = scrolled;
|
||||
|
||||
it("lists the folder's files and plays the one picked above the list", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
expect((await page.findByRole("heading", { level: 1 })).textContent).toBe("Friends");
|
||||
expect(page.getByText(/^3 files/)).toBeTruthy();
|
||||
expect(page.container.querySelector("video")).toBeNull();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 2" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
expect(video.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/2/file");
|
||||
expect(video.autoplay).toBe(true);
|
||||
expect(page.getByRole("button", { name: "Play: Clip 2" }).getAttribute("aria-current")).toBe(
|
||||
"true",
|
||||
);
|
||||
// The file's title sits under the folder's, one level down.
|
||||
expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Clip 2");
|
||||
expect(scrolled).toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(page.getByRole("button", { name: "Play: Clip 1" }));
|
||||
expect(page.container.querySelector("video")!.getAttribute("src")).toBe(
|
||||
"/api/public/watch/tok-folder/files/1/file",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a document for download only, and Download only where the link allows it", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText("Plan.pdf");
|
||||
expect(page.queryByRole("button", { name: "Play: Plan.pdf" })).toBeNull();
|
||||
const pdf = page.getByRole("link", { name: "Download: Plan.pdf" });
|
||||
expect(pdf.getAttribute("href")).toBe("/api/public/watch/tok-folder/files/3/file");
|
||||
cleanup();
|
||||
|
||||
mockMeta(folder({ allow_download: false }));
|
||||
const streamOnly = render(<WatchPage />);
|
||||
await streamOnly.findByText("Plan.pdf");
|
||||
expect(streamOnly.queryAllByRole("link", { name: /^Download/ })).toEqual([]);
|
||||
});
|
||||
|
||||
it("each file keeps its own place in a folder", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-resume");
|
||||
mockMeta(folder());
|
||||
const page = render(<WatchPage />);
|
||||
fireEvent.click(await page.findByRole("button", { name: "Play: Clip 1" }));
|
||||
const video = page.container.querySelector("video")!;
|
||||
let t = 0;
|
||||
Object.defineProperties(video, {
|
||||
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
|
||||
duration: { get: () => 600, configurable: true },
|
||||
readyState: { get: () => 1, configurable: true },
|
||||
});
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
video.dispatchEvent(new Event("playing"));
|
||||
video.currentTime = 250;
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
const points = JSON.stringify(localStorage);
|
||||
expect(points).toContain("tok-folder-resume/1");
|
||||
expect(points).not.toContain('"tok-folder-resume"');
|
||||
});
|
||||
|
||||
it("says when the folder is empty", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder({ files: [] }));
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is empty.")).toBeTruthy();
|
||||
expect(page.getByText("0 files")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a password folder link asks for the folder's password", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder-locked");
|
||||
mockMeta({ needs_password: true, kind: "folder" });
|
||||
const page = render(<WatchPage />);
|
||||
expect(await page.findByText("This folder is password-protected")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useFileActions({
|
||||
const quickShare = async (file: LibraryFile) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(file.id);
|
||||
await quickShareLink(qc, file, t).finally(() => setSharingId(null));
|
||||
await quickShareLink(qc, { kind: "file", id: file.id }, t).finally(() => setSharingId(null));
|
||||
};
|
||||
|
||||
const icon = (I: typeof PlayCircle) => <I className="w-4 h-4" />;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Clock, Pencil, Trash2 } from "lucide-react";
|
||||
import { Clock, Link, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { api, type FileFolder, type FolderPatch } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
|
||||
// A library folder's ONE menu — the rail entry's right click and the Files page's "…" beside an open
|
||||
// folder's name. An own folder is renamed (inline, where it is named), kept for a while and deleted; a
|
||||
// source folder is named by its origin and cannot go, so only how long it keeps its files is its
|
||||
// user's to set (the server holds the same rules: `routes/files.py`).
|
||||
// folder's name. An own folder is shared by link (its link opens whatever is in it), renamed (inline,
|
||||
// where it is named), kept for a while and deleted; a source folder is named by its origin and cannot
|
||||
// go, so only how long it keeps its files is its user's to set (the server holds the same rules:
|
||||
// `routes/files.py`, `routes/downloads.py`).
|
||||
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
|
||||
/** The choices the Retention submenu offers (days; null = until the user deletes the file). */
|
||||
const RETENTION_CHOICES: readonly (number | null)[] = [7, 30, 90, null];
|
||||
@@ -25,6 +29,9 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
// The own folder whose Share dialog is open (the hook's caller renders `dialog`).
|
||||
const [sharing, setSharing] = useState<FileFolder | null>(null);
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
// Every write lands in the listings too: a retention change moves its files' expiry, a delete moves
|
||||
// the files themselves.
|
||||
@@ -83,10 +90,34 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
|
||||
if (ok) remove.mutate(folder.id);
|
||||
};
|
||||
|
||||
// The same two a file row has, in its order: one click copies the folder's link (made the first
|
||||
// time), the dialog is for a password, an expiry or revoking it.
|
||||
const share = (folder: FileFolder): ActionMenuItem[] => [
|
||||
{
|
||||
id: "copy-link",
|
||||
label: t("files.actions.copyLink"),
|
||||
icon: icon(Link),
|
||||
disabled: copying,
|
||||
onSelect: () => {
|
||||
setCopying(true);
|
||||
void quickShareLink(qc, { kind: "folder", id: folder.id }, t).finally(() =>
|
||||
setCopying(false),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "share-link",
|
||||
label: t("files.folder.shareLink"),
|
||||
icon: icon(Share2),
|
||||
onSelect: () => setSharing(folder),
|
||||
},
|
||||
];
|
||||
|
||||
/** The folder's menu rows. */
|
||||
const items = (folder: FileFolder): ActionMenuItem[] =>
|
||||
folder.kind === "own"
|
||||
? menuGroups(
|
||||
share(folder),
|
||||
[
|
||||
{
|
||||
id: "rename",
|
||||
@@ -126,5 +157,14 @@ export function useFolderActions({ onRename }: { onRename: (folder: FileFolder)
|
||||
}
|
||||
};
|
||||
|
||||
return { items, rename, add };
|
||||
const dialog = sharing && (
|
||||
<Suspense fallback={null}>
|
||||
<ShareDialog
|
||||
folder={{ id: sharing.id, name: folderLabel(sharing, t) }}
|
||||
onClose={() => setSharing(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return { items, rename, add, dialog };
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const STR = {
|
||||
crashTitle: "Something went wrong.",
|
||||
crashHint: "Reload the page to try again.",
|
||||
reload: "Reload",
|
||||
passwordTitleFolder: "This folder is password-protected",
|
||||
folderEmpty: "This folder is empty.",
|
||||
play: "Play",
|
||||
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
|
||||
},
|
||||
hu: {
|
||||
loading: "Betöltés…",
|
||||
@@ -50,6 +54,10 @@ const STR = {
|
||||
crashTitle: "Valami hiba történt.",
|
||||
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
|
||||
reload: "Újratöltés",
|
||||
passwordTitleFolder: "Ez a mappa jelszóval védett",
|
||||
folderEmpty: "Ez a mappa üres.",
|
||||
play: "Lejátszás",
|
||||
files: (n: number) => `${n} fájl`,
|
||||
},
|
||||
de: {
|
||||
loading: "Wird geladen…",
|
||||
@@ -74,6 +82,10 @@ const STR = {
|
||||
crashTitle: "Etwas ist schiefgelaufen.",
|
||||
crashHint: "Lade die Seite neu und versuche es erneut.",
|
||||
reload: "Neu laden",
|
||||
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
|
||||
folderEmpty: "Dieser Ordner ist leer.",
|
||||
play: "Abspielen",
|
||||
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BatchC } from "./batchC";
|
||||
import { BatchD } from "./batchD";
|
||||
import { FilesRound } from "./filesRound";
|
||||
import { TokensRound } from "./tokensRound";
|
||||
import { LinksRound } from "./linksRound";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { Slider } from "../components/ui/NativeControls";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
@@ -105,6 +106,14 @@ export default function DesignLab() {
|
||||
Resize the window to tablet size to see them there too.
|
||||
</p>
|
||||
</header>
|
||||
<header id="open-links" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · Folder links</h2>
|
||||
<p className="text-sm text-muted">
|
||||
A /watch link that opens a whole folder. Picked: A, A, A — the variants with the accent
|
||||
edge (built; on the public list a picture-less row keeps its glyph above the length).
|
||||
</p>
|
||||
</header>
|
||||
<LinksRound />
|
||||
<header id="open-tokens" className="mt-6 scroll-mt-20">
|
||||
<h2 className="text-xl font-bold">Decided · API tokens, a machine's key to Files</h2>
|
||||
<p className="text-sm text-muted">
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import clsx from "clsx";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Link2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Decision, Sample, Variant } from "./labKit";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { buttonClass, iconButtonClass } from "../components/ui/buttonStyles";
|
||||
import { MEDIA_BADGE } from "../components/ui/mediaCardStyles";
|
||||
|
||||
// The folder links round: a /watch link that opens a whole folder, not one file. Static markup on
|
||||
// purpose (the lab's rule); the folder and its files are the owner's own kind of content.
|
||||
|
||||
const ICON = iconButtonClass({ variant: "ghost", size: "sm" });
|
||||
|
||||
type File = { name: string; size: string; length?: string; doc?: boolean; hue: string };
|
||||
const FILES: File[] = [
|
||||
{ name: "Balaton 2026 — a móló", size: "48,1 MB", length: "2:14", hue: "from-accent/45" },
|
||||
{ name: "Szülinapi torta, gyertyafújás", size: "19,9 MB", length: "1:11", hue: "from-rating/45" },
|
||||
{ name: "Útvonal és szállás.pdf", size: "640 KB", doc: true, hue: "from-fg/20" },
|
||||
{ name: "Esti tábortűz", size: "7,3 MB", length: "0:38", hue: "from-success/40" },
|
||||
];
|
||||
|
||||
function Thumb({ file, className }: { file: File; className: string }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"relative shrink-0 overflow-hidden rounded-lg bg-linear-to-br to-card",
|
||||
file.hue,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{file.doc && <FileText className="absolute inset-0 m-auto size-5 text-muted" />}
|
||||
{file.length && <span className={clsx(MEDIA_BADGE, "bottom-1 right-1")}>{file.length}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── L1: making a folder link ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function RailEntry({
|
||||
label,
|
||||
count,
|
||||
shared,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
shared?: boolean;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-8 items-center gap-2 rounded-lg px-2 text-sm",
|
||||
active ? "bg-accent/12 font-medium text-accent" : "text-muted",
|
||||
)}
|
||||
>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{shared && <Link2 className="size-3.5 shrink-0 text-accent" aria-label="Linkkel megosztva" />}
|
||||
<span className="text-xs tabular-nums">{count}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ marked }: { marked: boolean }) {
|
||||
return (
|
||||
<aside className="flex w-52 shrink-0 flex-col gap-0.5">
|
||||
<span className="flex items-center px-2 text-2xs uppercase tracking-wider text-muted">
|
||||
<span className="flex-1">Saját mappák</span>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</span>
|
||||
<RailEntry label="Cimboráknak" count={4} shared={marked} active />
|
||||
<RailEntry label="Receptek" count={1} />
|
||||
<RailEntry label="Munka" count={7} shared={marked} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderMenu({ quickCopy }: { quickCopy: boolean }) {
|
||||
const row = "flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm";
|
||||
const groups: [typeof Folder, string, boolean?][][] = [
|
||||
quickCopy
|
||||
? [
|
||||
[Link2, "Link másolása"],
|
||||
[Users, "Megosztás linkkel…"],
|
||||
]
|
||||
: [[Users, "Megosztás linkkel…"]],
|
||||
[
|
||||
[Clock, "Megőrzés"],
|
||||
[Pencil, "Átnevezés"],
|
||||
],
|
||||
[[Trash2, "Törlés", true]],
|
||||
];
|
||||
return (
|
||||
<div className={clsx(SURFACE.menu, "w-56 shadow-float")}>
|
||||
{groups.map((g, gi) => (
|
||||
<div key={gi}>
|
||||
{gi > 0 && <div className="my-1 h-px bg-border" />}
|
||||
{g.map(([Icon, label, danger], i) => (
|
||||
<span
|
||||
key={label}
|
||||
className={clsx(row, danger && "text-danger", gi === 0 && i === 0 && "bg-fg/8")}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", danger ? "text-danger" : "text-muted")} />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ views, pw }: { views: number; pw?: boolean }) {
|
||||
return (
|
||||
<div className={clsx(SURFACE.card, "flex flex-col gap-2 p-2.5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="size-4 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate text-xs text-muted">
|
||||
https://siftlode.example/watch/Qk7vN2x…
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Copy className="size-4" />
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Lock className={clsx("size-4", pw && "text-accent")} />
|
||||
</span>
|
||||
<span className={ICON}>
|
||||
<Trash2 className="size-4 text-danger" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Eye className="size-3" /> {views} megtekintés
|
||||
{pw && <span className="rounded-sm bg-surface px-1.5 py-0.5">jelszóval</span>}
|
||||
<span className="rounded-sm bg-surface px-1.5 py-0.5">letölthető</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderShareDialog() {
|
||||
return (
|
||||
<div className={clsx(SURFACE.panel, "flex w-full max-w-md flex-col gap-3 p-4")}>
|
||||
<div className="flex items-center">
|
||||
<h4 className="flex-1 font-semibold">Mappa megosztása · Cimboráknak</h4>
|
||||
<X className="size-4 text-muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Link2 className="size-4 text-muted" /> Megosztás linkkel
|
||||
</div>
|
||||
<p className="-mt-2 text-xs text-muted">
|
||||
A link birtokában bárki megnézheti a mappát — fiók nélkül is. Mindig a mappa aktuális
|
||||
tartalmát látja: az ide később tett fájlokat is, a kivetteket már nem.
|
||||
</p>
|
||||
<LinkRow views={3} />
|
||||
<span className={clsx(buttonClass({ variant: "outline", size: "sm" }), "self-start")}>
|
||||
<Link2 className="size-4" /> Link létrehozása
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── L2: the page the link opens ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Brand() {
|
||||
return <span className="text-xs text-muted">Megosztva a Siftlode-dal</span>;
|
||||
}
|
||||
|
||||
function PageFrame({ phone, children }: { phone?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-3 rounded-xl bg-bg p-3 ring-1 ring-border",
|
||||
phone ? "w-[23.5rem] max-w-full" : "w-full",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-lg font-bold">Cimboráknak</h4>
|
||||
<span className="text-xs text-muted">4 fájl · 76 MB</span>
|
||||
</div>
|
||||
<Brand />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Player({ file }: { file: File }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={clsx(
|
||||
"relative aspect-video w-full overflow-hidden rounded-xl bg-linear-to-br to-card",
|
||||
file.hue,
|
||||
)}
|
||||
>
|
||||
<span className="absolute inset-x-3 bottom-2 flex items-center gap-2 text-xs text-fg/80">
|
||||
<Play className="size-3.5" /> 0:41 / {file.length}
|
||||
<span className="h-1 flex-1 rounded-full bg-fg/20">
|
||||
<span className="block h-full w-1/3 rounded-full bg-accent" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate text-sm font-semibold">{file.name}</span>
|
||||
<span className={buttonClass({ variant: "outline", size: "xs" })}>
|
||||
<Download className="size-3.5" /> Letöltés
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListPage({ phone, playing }: { phone?: boolean; playing?: boolean }) {
|
||||
return (
|
||||
<PageFrame phone={phone}>
|
||||
<Header />
|
||||
{playing && <Player file={FILES[1]!} />}
|
||||
<div className="flex flex-col">
|
||||
{FILES.map((f, i) => (
|
||||
<div
|
||||
key={f.name}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded-lg px-1.5 py-1.5",
|
||||
playing && i === 1 && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<Thumb file={f} className="h-10 w-16" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{f.name}</div>
|
||||
<div className="text-xs text-muted">{f.size}</div>
|
||||
</div>
|
||||
{!f.doc && (
|
||||
<span className={ICON}>
|
||||
<Play className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={ICON}>
|
||||
<Download className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function GridPage() {
|
||||
return (
|
||||
<PageFrame>
|
||||
<Header />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{FILES.map((f) => (
|
||||
<div key={f.name} className="flex flex-col gap-1">
|
||||
<Thumb file={f} className="aspect-video w-full" />
|
||||
<span className="truncate text-sm">{f.name}</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<span className="flex-1">{f.size}</span>
|
||||
<Download className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadOnlyPage() {
|
||||
return (
|
||||
<PageFrame>
|
||||
<Header />
|
||||
<div className="flex flex-col divide-y divide-border/60">
|
||||
{FILES.map((f) => (
|
||||
<div key={f.name} className="flex items-center gap-3 py-2">
|
||||
<FileText className="size-4 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{f.name}</span>
|
||||
<span className="text-xs text-muted tabular-nums">{f.size}</span>
|
||||
<span className={buttonClass({ variant: "outline", size: "xs" })}>
|
||||
<Download className="size-3.5" /> Letöltés
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ── The round ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LinksRound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Decision
|
||||
id="open-links-l1"
|
||||
code="L1"
|
||||
title="Making a folder link"
|
||||
question="A folder's right-click menu gets the share. Does it also get the one-click “Link másolása” a file row has, or only the dialog? The dialog is the file's Share dialog with only its link half (sharing with a user stays per file)."
|
||||
now="Only a file can be shared; a folder menu has Retention, Rename and Delete."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Variant
|
||||
letter="A"
|
||||
name="Copy link + Share with link…"
|
||||
recommended
|
||||
why="The same two entries a file row has, in the same order: one click copies the folder's link (making one the first time), the dialog is for a password, an expiry or revoking it."
|
||||
>
|
||||
<Sample label="the folder's right-click menu">
|
||||
<div className="flex gap-3">
|
||||
<Rail marked={false} />
|
||||
<FolderMenu quickCopy />
|
||||
</div>
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Share with link… only"
|
||||
why="One entry: every folder link goes through the dialog. One more click for the common case, nothing gained but a shorter menu."
|
||||
>
|
||||
<Sample label="the folder's right-click menu">
|
||||
<FolderMenu quickCopy={false} />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="A/B"
|
||||
name="…the dialog either opens"
|
||||
recommended
|
||||
why="It says what a folder link means: whoever has it sees the folder as it is NOW, files put in later included."
|
||||
>
|
||||
<FolderShareDialog />
|
||||
</Variant>
|
||||
</div>
|
||||
</Decision>
|
||||
|
||||
<Decision
|
||||
id="open-links-l2"
|
||||
code="L2"
|
||||
title="The page a folder link opens"
|
||||
question="Signed out, often on a phone: the folder's name and its files. Each video plays right there, each file downloads (when the link allows it); a document is never opened as a web page, only downloaded."
|
||||
now="A /watch link opens one file: its player, title and Download."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A list; Play opens the player above it"
|
||||
recommended
|
||||
why="The watch page's player, over a list you can read on a phone: a long name gets a full line, the playing row stays lit, the next one is one tap away. Mostly videos from a phone — this is the YouTube-playlist shape people know."
|
||||
>
|
||||
<Sample label="desktop, a video playing">
|
||||
<ListPage playing />
|
||||
</Sample>
|
||||
<Sample label="phone">
|
||||
<ListPage phone />
|
||||
</Sample>
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="Cards"
|
||||
why="Pictures first, like the Files grid. Nicer for a folder of photos-like clips, but names truncate to a line and a document is a grey card."
|
||||
>
|
||||
<GridPage />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="C"
|
||||
name="A download list only"
|
||||
why="Like a file-transfer page: name, size, Letöltés. Simplest, but a friend has to download a clip to see it — on a phone, on mobile data."
|
||||
>
|
||||
<DownloadOnlyPage />
|
||||
</Variant>
|
||||
</div>
|
||||
</Decision>
|
||||
|
||||
<Decision
|
||||
id="open-links-l3"
|
||||
code="L3"
|
||||
title="Seeing which folders are shared"
|
||||
question="A folder link keeps showing whatever you put in the folder later. Does the rail mark a folder that has a live link, so you know where you drop files in public?"
|
||||
now="Nothing marks a shared file or folder."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Variant
|
||||
letter="A"
|
||||
name="A link mark beside the count"
|
||||
recommended
|
||||
why="A folder link is the one place where moving a file in publishes it. The mark is quiet (one small icon), and it goes when the last link is revoked or runs out."
|
||||
>
|
||||
<Rail marked />
|
||||
</Variant>
|
||||
<Variant
|
||||
letter="B"
|
||||
name="No mark"
|
||||
why="A cleaner rail. You learn a folder is shared only from its Share dialog."
|
||||
>
|
||||
<Rail marked={false} />
|
||||
</Variant>
|
||||
</div>
|
||||
</Decision>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,9 @@
|
||||
"newPassword": "New password",
|
||||
"savePassword": "Save",
|
||||
"removePassword": "Delete",
|
||||
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password."
|
||||
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password.",
|
||||
"folderTitle": "Share folder · {{name}}",
|
||||
"folderLinkHint": "Anyone with the link can see the folder — no account needed. They see what is in it when they open it: files you put in later too, not the ones you take out."
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Cancel download?",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"share_own": "That's already your download",
|
||||
"share_password_required": "This link needs a password",
|
||||
"share_unfinished": "Only a finished download can be shared",
|
||||
"share_folder_kind": "Only a folder of your own can be shared by link",
|
||||
"smtp_failed": "The email couldn't be sent — check the SMTP settings",
|
||||
"smtp_off": "SMTP isn't configured",
|
||||
"spec_not_object": "The profile's settings are malformed",
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
"deleteBodyEmpty": "The folder is empty. Nothing else changes.",
|
||||
"deleteBody_one": "Its file goes back to the folder it came from, and its retention starts again from today.",
|
||||
"deleteBody_other": "Its {{count}} files go back to the folders they came from, and their retention starts again from today.",
|
||||
"deleteAction": "Delete folder"
|
||||
"deleteAction": "Delete folder",
|
||||
"shareLink": "Share with a link…",
|
||||
"shared": "Shared with a link"
|
||||
},
|
||||
"layout": {
|
||||
"label": "View",
|
||||
|
||||
@@ -118,7 +118,9 @@
|
||||
"newPassword": "Új jelszó",
|
||||
"savePassword": "Mentés",
|
||||
"removePassword": "Törlés",
|
||||
"removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL."
|
||||
"removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL.",
|
||||
"folderTitle": "Mappa megosztása · {{name}}",
|
||||
"folderLinkHint": "A link birtokában bárki megnézheti a mappát — fiók nélkül is. Azt látja, ami megnyitáskor benne van: a később betett fájlokat is, a kivetteket már nem."
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Megszakítod a letöltést?",
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
"share_own": "Ez már a te letöltésed",
|
||||
"share_password_required": "Ehhez a linkhez jelszó kell",
|
||||
"share_unfinished": "Csak befejezett letöltés osztható meg",
|
||||
"share_folder_kind": "Linkkel csak saját mappa osztható meg",
|
||||
"smtp_failed": "Nem sikerült elküldeni a levelet — ellenőrizd az SMTP-beállításokat",
|
||||
"smtp_off": "Az SMTP nincs beállítva",
|
||||
"spec_not_object": "A profil beállításai hibás formátumúak",
|
||||
|
||||
@@ -84,7 +84,9 @@
|
||||
"deleteBodyEmpty": "A mappa üres. Más nem változik.",
|
||||
"deleteBody_one": "A benne lévő fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési ideje mától újraindul.",
|
||||
"deleteBody_other": "A benne lévő {{count}} fájl visszakerül abba a mappába, ahonnan jött, és a megőrzési idejük mától újraindul.",
|
||||
"deleteAction": "Mappa törlése"
|
||||
"deleteAction": "Mappa törlése",
|
||||
"shareLink": "Megosztás linkkel…",
|
||||
"shared": "Linkkel megosztva"
|
||||
},
|
||||
"layout": {
|
||||
"label": "Nézet",
|
||||
|
||||
@@ -151,6 +151,13 @@ export interface ShareLink {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
// What a new link is made with; an omitted `allow_download` takes the server's default (true).
|
||||
export interface NewShareLink {
|
||||
allow_download?: boolean;
|
||||
expires_days?: number | null;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// The width of every user-supplied display name on a job, mirroring the backend's DISPLAY_TEXT_MAX
|
||||
// (backend/app/models.py) so an input can stop the user rather than letting the server truncate in
|
||||
// silence. The two are held together by `downloads.contract.test.ts` — a number living in two
|
||||
@@ -258,16 +265,21 @@ export const downloadsApi = {
|
||||
shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
|
||||
// Public watch links (share by link).
|
||||
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
|
||||
createDownloadLink: (
|
||||
jobId: number,
|
||||
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
createDownloadLink: (jobId: number, body: NewShareLink): Promise<ShareLink> =>
|
||||
req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateDownloadLink: (
|
||||
linkId: number,
|
||||
patch: { allow_download?: boolean; expires_days?: number | null; password?: string },
|
||||
): Promise<ShareLink> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
// A link to one of the user's own folders: it opens whatever is in the folder when it is opened.
|
||||
folderLinks: (folderId: number): Promise<ShareLink[]> =>
|
||||
req(`/api/downloads/folders/${folderId}/links`),
|
||||
createFolderLink: (folderId: number, body: NewShareLink): Promise<ShareLink> =>
|
||||
req(`/api/downloads/folders/${folderId}/links`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
revokeDownloadLink: (linkId: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
|
||||
// The library player's resume checkpoint. A position too early or too close to the end clears it.
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface FileFolder {
|
||||
count: number; // its done files
|
||||
size_bytes: number;
|
||||
created_at: string | null;
|
||||
// A live public link opens it: whatever is moved in is shown to whoever holds the link.
|
||||
has_link: boolean;
|
||||
}
|
||||
|
||||
// The most files one list answers with (backend `routes/files.py` MAX_LIST, held equal by
|
||||
|
||||
@@ -7,14 +7,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
|
||||
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
|
||||
downloadLinks: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
const { downloadLinks, createDownloadLink, folderLinks, createFolderLink, copyText, notify } =
|
||||
vi.hoisted(() => ({
|
||||
downloadLinks: vi.fn(),
|
||||
createDownloadLink: vi.fn(),
|
||||
folderLinks: vi.fn(),
|
||||
createFolderLink: vi.fn(),
|
||||
copyText: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
|
||||
vi.mock("./api", () => ({
|
||||
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
|
||||
}));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
|
||||
@@ -38,6 +43,8 @@ function qc() {
|
||||
beforeEach(() => {
|
||||
downloadLinks.mockReset();
|
||||
createDownloadLink.mockReset();
|
||||
folderLinks.mockReset();
|
||||
createFolderLink.mockReset();
|
||||
copyText.mockReset().mockResolvedValue(true);
|
||||
notify.mockReset();
|
||||
});
|
||||
@@ -48,7 +55,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 42 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/tok7`);
|
||||
// Minted with an EMPTY body so the backend default (allow_download=true) applies.
|
||||
@@ -65,7 +72,7 @@ describe("quickShareLink", () => {
|
||||
it("reuses an existing link instead of minting a duplicate", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/old`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -78,7 +85,7 @@ describe("quickShareLink", () => {
|
||||
{ id: 3, url: "/watch/older" },
|
||||
]);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 1 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/older`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -90,7 +97,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||
const client = qc();
|
||||
|
||||
const url = await quickShareLink(client, { id: 6 }, t);
|
||||
const url = await quickShareLink(client, { kind: "file", id: 6 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/new`);
|
||||
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||
@@ -100,11 +107,29 @@ describe("quickShareLink", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("a folder's link is the folder's own: listed, made and cached under the folder", async () => {
|
||||
folderLinks.mockResolvedValue([]);
|
||||
createFolderLink.mockResolvedValue({ id: 9, url: "/watch/fold" });
|
||||
const client = qc();
|
||||
client.setQueryData(qk.fileFolders(), []);
|
||||
|
||||
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/fold`);
|
||||
expect(folderLinks).toHaveBeenCalledWith(5);
|
||||
expect(createFolderLink).toHaveBeenCalledWith(5, {});
|
||||
expect(downloadLinks).not.toHaveBeenCalled();
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
expect(client.getQueryData(qk.folderLinks(5))).toEqual([{ id: 9, url: "/watch/fold" }]);
|
||||
// The rail's mark of a shared folder is asked for again.
|
||||
expect(client.getQueryState(qk.fileFolders())!.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
copyText.mockResolvedValue(false);
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 2 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t);
|
||||
|
||||
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
|
||||
const arg = firstToast();
|
||||
@@ -117,7 +142,7 @@ describe("quickShareLink", () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
createDownloadLink.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const url = await quickShareLink(qc(), { id: 9 }, t);
|
||||
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t);
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(copyText).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api } from "./api";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
@@ -16,28 +16,51 @@ export function invalidateDownloads(qc: QueryClient) {
|
||||
qc.invalidateQueries({ queryKey: qk.storageUsage() });
|
||||
}
|
||||
|
||||
// What a public link opens: one file, or one of the user's own folders (whatever is in it when the
|
||||
// link is opened).
|
||||
export type LinkTarget = { kind: "file"; id: number } | { kind: "folder"; id: number };
|
||||
|
||||
export const linksKey = (target: LinkTarget) =>
|
||||
target.kind === "file" ? qk.downloadLinks(target.id) : qk.folderLinks(target.id);
|
||||
|
||||
export const fetchLinks = (target: LinkTarget) =>
|
||||
target.kind === "file" ? api.downloadLinks(target.id) : api.folderLinks(target.id);
|
||||
|
||||
export const makeLink = (target: LinkTarget, body: NewShareLink) =>
|
||||
target.kind === "file"
|
||||
? api.createDownloadLink(target.id, body)
|
||||
: api.createFolderLink(target.id, body);
|
||||
|
||||
/** After a link was made, changed or revoked: its list, and — for a folder — the rail's mark of the
|
||||
* folders a live link opens (an expiry change can end it too). */
|
||||
export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
||||
void qc.invalidateQueries({ queryKey: linksKey(target) });
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
}
|
||||
|
||||
// One-click share: the row's Share button turns "share this" into a single action.
|
||||
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
|
||||
// default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
|
||||
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
|
||||
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
|
||||
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
|
||||
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
|
||||
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
|
||||
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
|
||||
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
|
||||
export async function quickShareLink(
|
||||
qc: QueryClient,
|
||||
job: { id: number },
|
||||
target: LinkTarget,
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
let path;
|
||||
try {
|
||||
const existing = await qc.fetchQuery({
|
||||
queryKey: qk.downloadLinks(job.id),
|
||||
queryFn: () => api.downloadLinks(job.id),
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||
path = existing.find((l) => l.url)?.url;
|
||||
if (!path) {
|
||||
const made = await api.createDownloadLink(job.id, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(qk.downloadLinks(job.id), [made, ...existing]);
|
||||
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(linksKey(target), [made, ...existing]);
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
path = made.url;
|
||||
}
|
||||
if (!path) throw new Error("the new link came back without its address");
|
||||
|
||||
@@ -77,6 +77,7 @@ export const qk = {
|
||||
downloadProfiles: () => ["download-profiles"] as const,
|
||||
downloadCredentials: () => ["download-credentials"] as const,
|
||||
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
|
||||
folderLinks: (folderId: number) => ["folder-links", folderId] as const,
|
||||
dlQuota: (userId: number) => ["dl-quota", userId] as const,
|
||||
storyboard: (jobId: number) => ["storyboard", jobId] as const,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user