fix(files): a steady list header, every user on the Storage tab, the name gets the row's room

- The Files list starts at the scroller's top: the padding above it let the sticky header ride up
  12px on the first scroll, then stop.
- Users -> Storage lists every user with their quota, not only those already holding a file — a
  quota is set before anyone downloads.
- On a wide Files list only the "…" holds a place; Copy link, Share and Save come up over the date
  while the row is hovered or holds the keyboard (a card keeps them in its line). Keeps is narrower.
This commit is contained in:
2026-10-11 20:33:48 +02:00
parent 078194731b
commit 6707550513
5 changed files with 221 additions and 171 deletions
+8 -9
View File
@@ -856,19 +856,18 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK) select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
).one() ).one()
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar() total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar()
per_user = db.execute( # EVERY user, not only those holding a file: a quota is set before anyone downloads.
select(DownloadJob.user_id, func.count(func.distinct(DownloadJob.asset_id))) emails: dict[int, str] = {
.where(DownloadJob.asset_id.is_not(None)) uid: email
.group_by(DownloadJob.user_id) for uid, email in db.execute(select(User.id, User.email).order_by(User.email)).tuples()
).all() }
uids = [uid for uid, _ in per_user] uids = list(emails)
emails = {u.id: u.email for u in db.execute(select(User).where(User.id.in_(uids))).scalars()}
# What each one's Files lists: their finished files. # What each one's Files lists: their finished files.
files: dict[int, int] = { files: dict[int, int] = {
uid: n uid: n
for uid, n in db.execute( for uid, n in db.execute(
select(DownloadJob.user_id, func.count()) select(DownloadJob.user_id, func.count())
.where(DownloadJob.user_id.in_(uids), DownloadJob.status == JobStatus.DONE) .where(DownloadJob.status == JobStatus.DONE)
.group_by(DownloadJob.user_id) .group_by(DownloadJob.user_id)
).tuples() ).tuples()
} }
@@ -877,7 +876,7 @@ def admin_storage(admin: User = Depends(admin_user), db: Session = Depends(get_d
lim = quota.resolve(db, uid) lim = quota.resolve(db, uid)
return { return {
"user_id": uid, "user_id": uid,
"email": emails.get(uid), "email": emails[uid],
"footprint_bytes": quota.footprint(db, uid), "footprint_bytes": quota.footprint(db, uid),
"files": files.get(uid, 0), "files": files.get(uid, 0),
"max_bytes": lim.max_bytes, "max_bytes": lim.max_bytes,
+18 -6
View File
@@ -593,9 +593,10 @@ def test_the_admin_storage_view_counts_a_waiting_upload(db, as_user, make_user,
_probe(monkeypatch) _probe(monkeypatch)
u = make_user() u = make_user()
_ingest(db, _root, u.id, size=10) _ingest(db, _root, u.id, size=10)
r = as_user(make_user(role="admin")).get("/api/admin/downloads/storage").json() admin = make_user(role="admin")
r = as_user(admin).get("/api/admin/downloads/storage").json()
assert (r["files_on_disk"], r["total_bytes"]) == (1, 10) assert (r["files_on_disk"], r["total_bytes"]) == (1, 10)
assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10} assert {p["user_id"]: p["footprint_bytes"] for p in r["per_user"]} == {u.id: 10, admin.id: 0}
def test_the_admin_storage_view_names_each_users_quota_and_files( def test_the_admin_storage_view_names_each_users_quota_and_files(
@@ -612,15 +613,26 @@ def test_the_admin_storage_view_names_each_users_quota_and_files(
admin = as_user(make_user(role="admin")) admin = as_user(make_user(role="admin"))
put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000}) put = admin.put(f"/api/admin/downloads/quota/{u.id}", json={"max_bytes": 1000})
assert put.status_code == 200 assert put.status_code == 200
row = admin.get("/api/admin/downloads/storage").json()["per_user"] rows = admin.get("/api/admin/downloads/storage").json()["per_user"]
assert [(p["user_id"], p["files"], p["max_bytes"], p["unlimited"]) for p in row] == [ mine = [(p["files"], p["max_bytes"], p["unlimited"]) for p in rows if p["user_id"] == u.id]
(u.id, 1, 1000, False) assert mine == [(1, 1000, False)]
]
# Every download names its owner, so its row can open THEIR quota. # Every download names its owner, so its row can open THEIR quota.
jobs = admin.get("/api/admin/downloads").json() jobs = admin.get("/api/admin/downloads").json()
assert {(j["id"], j["user_id"]) for j in jobs} == {(done.id, u.id), (waiting.id, u.id)} assert {(j["id"], j["user_id"]) for j in jobs} == {(done.id, u.id), (waiting.id, u.id)}
def test_the_admin_storage_view_lists_every_user_even_one_without_a_file(db, as_user, make_user):
# The admin sets a quota BEFORE anyone downloads: a user holding nothing is still a row.
empty = make_user()
admin_user = make_user(role="admin")
admin = as_user(admin_user)
rows = admin.get("/api/admin/downloads/storage").json()["per_user"]
by_id = {p["user_id"]: p for p in rows}
assert {empty.id, admin_user.id} <= by_id.keys()
assert (by_id[empty.id]["email"], by_id[empty.id]["files"]) == (empty.email, 0)
assert by_id[empty.id]["footprint_bytes"] == 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
("job_status", "asset_status", "ref_count"), ("job_status", "asset_status", "ref_count"),
[ [
+2 -1
View File
@@ -662,7 +662,7 @@ of a full-width panel and nothing on the right.
## Decision log ## Decision log
| Date | Decision | Chosen | Over | Why | | Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized | | 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait | | 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has | | 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
@@ -778,3 +778,4 @@ of a full-width panel and nothing on the right.
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click | | 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look | | 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing | | 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
@@ -1,7 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// The Files page: a row keeps Copy link, Share, Save and its "…" in sight — a slot an action does not // The Files page: a row has Copy link, Share and Save (on a wide list, shown while it is hovered or
// apply to stays empty, so the icons line up down the list — and its every action is its ONE menu, // focused) and its "…" — a slot an action does not apply to stays empty, so the icons line up down
// the list — and its every action is its ONE menu,
// opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or // opened by the "…" or a right-click. Renaming happens in the row; moving goes to an own folder or
// back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my // back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my
// list. i18n is key-passthrough, so labels read as keys. // list. i18n is key-passthrough, so labels read as keys.
@@ -177,6 +178,20 @@ describe("Files · my file's row", () => {
]); ]);
}); });
it("holds back the quick icons on a wide row until it is hovered or focused; the '…' stays", () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mount();
const row = rowOf("Clip 2");
const quick = row.querySelector<HTMLElement>("[data-quick-actions]")!;
expect(row.className).toMatch(/(^|\s)group(\s|$)/);
expect(quick.className).toContain("md:opacity-0");
expect(quick.className).toContain("md:group-hover:opacity-100");
expect(quick.className).toContain("md:group-focus-within:opacity-100");
expect(quick.contains(row.querySelector('[aria-label="files.actions.share"]'))).toBe(true);
expect(quick.contains(moreOf("Clip 2"))).toBe(false);
});
it("keeps the slot of an action a plain file lacks, so the icons line up", () => { it("keeps the slot of an action a plain file lacks, so the icons line up", () => {
mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)]; mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mocks.folders = [YT]; mocks.folders = [YT];
@@ -388,6 +403,14 @@ describe("Files · a shared file's row", () => {
}); });
describe("Files · the view", () => { describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)];
mocks.folders = [YT];
mount();
const list = screen.getByRole("table").closest<HTMLElement>(".max-w-7xl")!;
expect(list.className).not.toMatch(/(^|\s)(pt|py|p)-/);
});
it("reads one folder's files and names the folder", () => { it("reads one folder's files and names the folder", () => {
mocks.files = [file(2, { folder_id: 2 })]; mocks.files = [file(2, { folder_id: 2 })];
mocks.folders = [YT, OWN]; mocks.folders = [YT, OWN];
+21 -6
View File
@@ -369,7 +369,7 @@ export default function Files() {
{ {
key: "retention", key: "retention",
header: t("files.columns.retention"), header: t("files.columns.retention"),
width: "7.5rem", width: "6.75rem",
narrowHidden: true, narrowHidden: true,
render: (f) => <RetentionBadge file={f} />, render: (f) => <RetentionBadge file={f} />,
}, },
@@ -408,7 +408,10 @@ export default function Files() {
{ {
key: "actions", key: "actions",
header: "", header: "",
width: "10rem", // Only the "…" holds a place: the quick icons come up over the date while the pointer or the
// keyboard is on the row (the mail-list pattern), so the name gets their room. A card keeps
// them in its own line — a phone has no hover — and the menu holds them all anyway.
width: "3rem",
align: "right", align: "right",
cardLabel: false, cardLabel: false,
cardRow: true, cardRow: true,
@@ -416,7 +419,11 @@ export default function Files() {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
const mine = rowMenu.open && rowMenu.target?.id === f.id; const mine = rowMenu.open && rowMenu.target?.id === f.id;
return ( return (
<span className="flex items-center justify-end gap-0.5"> <span className="relative flex items-center justify-end gap-0.5">
<span
data-quick-actions
className="flex items-center gap-0.5 md:absolute md:right-full md:top-1/2 md:-translate-y-1/2 md:rounded-md md:bg-bg md:pointer-events-none md:opacity-0 md:transition-opacity md:group-hover:pointer-events-auto md:group-hover:opacity-100 md:group-focus-within:pointer-events-auto md:group-focus-within:opacity-100"
>
{!f.shared && a.play ? ( {!f.shared && a.play ? (
<IconButton <IconButton
variant="ghost" variant="ghost"
@@ -443,12 +450,17 @@ export default function Files() {
iconSlot iconSlot
)} )}
{f.can_download ? ( {f.can_download ? (
<IconLink href={api.fileUrl(f.id)} label={t("downloads.actions.download")} size="md"> <IconLink
href={api.fileUrl(f.id)}
label={t("downloads.actions.download")}
size="md"
>
<Download /> <Download />
</IconLink> </IconLink>
) : ( ) : (
iconSlot iconSlot
)} )}
</span>
<MoreButton <MoreButton
label={t("files.rowMenu")} label={t("files.rowMenu")}
size="md" size="md"
@@ -529,8 +541,9 @@ export default function Files() {
reveal={reveal} reveal={reveal}
narrowedBy={q} narrowedBy={q}
rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })} rowProps={(f) => ({ ...rowMenu.triggerProps(f), "data-file-id": f.id })}
// `group`: the row's quick icons show while it is hovered or holds the focus.
rowClassName={(f) => rowClassName={(f) =>
ringed === f.id ? "bg-accent/8 ring-2 ring-inset ring-accent/60" : "" ringed === f.id ? "group bg-accent/8 ring-2 ring-inset ring-accent/60" : "group"
} }
emptyText={emptyText} emptyText={emptyText}
/> />
@@ -614,7 +627,9 @@ export default function Files() {
</div> </div>
</PageToolbar> </PageToolbar>
<div ref={listRef} className="mx-auto w-full max-w-7xl px-4 pt-3 pb-4"> {/* The table starts at the scroller's top: a padding above it let its sticky header ride up that
far on the first scroll, then stop. The cards have no header, so they keep the gap. */}
<div ref={listRef} className={`mx-auto w-full max-w-7xl px-4 pb-4${isGrid ? " pt-3" : ""}`}>
{body} {body}
<ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} /> <ContextMenu menu={rowMenu} ariaLabel={t("files.rowMenu")} items={menuItems} />
<ContextMenu <ContextMenu