fix(documents): conform the vault page to the app frame and trim its chrome

- width: drop the /documents wide-content exception; the page uses the
  standard content frame like every other route (the multi-column
  timeline still fits, and no page should be wider than the rest).
- upload: retire the standing dashed drop card — it took a vertical band
  on every visit for what the header Upload button and the page-wide
  drag-and-drop overlay already cover. The hidden input and the
  above-80% quota bar stay.
- filter: search shares one row with the tag chips on desktop instead of
  sitting on its own line above them.
- detail: move Delete to the bottom-left as a quiet text button; the
  bottom-right slot reads as the primary/confirm action, so a
  destructive control there is a footgun. Download keeps the trailing edge.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 15:56:30 +02:00
1 parent b6e6fa3bdc
commit cd450a8186
10 files changed
+140 -208

No files matched your search

-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Dokumenten-Zeitleiste"
},
"upload": {
"zoneTitle": "Dateien hier ablegen oder vom Gerät auswählen",
"zoneHint": "PDF, Fotos und Office-Dateien bis je {maxSize}",
"browse": "Dateien auswählen",
"quotaAlmostFull": "Speicher fast voll",
"quotaUsed": "{used} von {quota} belegt",
-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Documents timeline"
},
"upload": {
"zoneTitle": "Drop files here, or choose from your device",
"zoneHint": "PDF, photos, and office files up to {maxSize} each",
"browse": "Choose files",
"quotaAlmostFull": "Storage almost full",
"quotaUsed": "{used} of {quota} used",
-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Cronología de documentos"
},
"upload": {
"zoneTitle": "Suelta archivos aquí o elige desde tu dispositivo",
"zoneHint": "PDF, fotos y archivos de Office de hasta {maxSize} cada uno",
"browse": "Elegir archivos",
"quotaAlmostFull": "Almacenamiento casi lleno",
"quotaUsed": "{used} de {quota} usados",
-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Chronologie des documents"
},
"upload": {
"zoneTitle": "Déposez des fichiers ici ou choisissez depuis votre appareil",
"zoneHint": "PDF, photos et fichiers Office jusqu'à {maxSize} chacun",
"browse": "Choisir des fichiers",
"quotaAlmostFull": "Stockage presque plein",
"quotaUsed": "{used} sur {quota} utilisés",
-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Cronologia dei documenti"
},
"upload": {
"zoneTitle": "Trascina i file qui o scegli dal dispositivo",
"zoneHint": "PDF, foto e file Office fino a {maxSize} ciascuno",
"browse": "Scegli file",
"quotaAlmostFull": "Spazio quasi pieno",
"quotaUsed": "{used} di {quota} usati",
-2
View File
@@ -8073,8 +8073,6 @@
"listLabel": "Oś czasu dokumentów"
},
"upload": {
"zoneTitle": "Upuść pliki tutaj lub wybierz z urządzenia",
"zoneHint": "PDF, zdjęcia i pliki Office do {maxSize} każdy",
"browse": "Wybierz pliki",
"quotaAlmostFull": "Miejsce prawie pełne",
"quotaUsed": "Użyto {used} z {quota}",
@@ -285,22 +285,27 @@ export function DocumentDetailSheet({
contentWidth="3xl"
footer={
doc ? (
<>
<Button variant="outline" asChild data-slot="document-download">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Download — the safe,
// expected action — keeps the trailing edge.
<div className="flex w-full items-center justify-between">
<Button
variant="destructive"
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
</>
<Button variant="outline" asChild data-slot="document-download">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
) : undefined
}
>
+102 -94
View File
@@ -68,8 +68,12 @@ export function DocumentFilterBar({
data-slot="document-filter-bar"
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
>
<div className="flex items-center gap-3">
<div className="relative w-full max-w-md">
{/* Search shares one row with the tag chips on desktop — it wraps to
its own line only on phone widths where a chip scroller plus an
input can't share a row. The chip scroller flexes to fill the
rest; the clear button pins to the trailing edge. */}
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-3">
<div className="relative w-full shrink-0 md:w-64">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
@@ -91,12 +95,107 @@ export function DocumentFilterBar({
/
</kbd>
</div>
<div
className="-mx-4 flex min-w-0 flex-1 [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
);
})}
</span>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(
CHIP_CLASSES,
active && CHIP_ACTIVE_CLASSES,
)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
{activeCount > 0 ? (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="text-muted-foreground shrink-0"
className="text-muted-foreground shrink-0 self-start md:self-auto"
>
<X className="size-3.5" aria-hidden />
{t("documents.filter.clear")}
@@ -106,97 +205,6 @@ export function DocumentFilterBar({
</Button>
) : null}
</div>
<div
className="-mx-4 mt-3 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
role="group"
aria-label={t("documents.filter.groupLabel")}
>
<span
role="group"
aria-label={t("documents.filter.typeGroup")}
className="contents"
>
{DOCUMENT_KIND_ORDER.map((kind) => {
const Icon = DOCUMENT_KIND_ICONS[kind];
const active = activeKinds.has(kind);
return (
<button
key={kind}
type="button"
aria-pressed={active}
onClick={() => onToggleKind(kind)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{t(`documents.kind.${kind}`)}
</button>
);
})}
</span>
{conditionChips.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.conditionGroup")}
className="contents"
>
{conditionChips.map((chip) => {
const active = chip.episodeId === activeEpisodeId;
return (
<button
key={chip.episodeId}
type="button"
aria-pressed={active}
onClick={() => onToggleEpisode(chip.episodeId)}
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
>
<span className="max-w-40 truncate">{chip.name}</span>
</button>
);
})}
</span>
</>
) : null}
{years.length > 0 ? (
<>
<span
aria-hidden
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
/>
<span
role="group"
aria-label={t("documents.filter.yearGroup")}
className="contents"
>
{years.map((year) => {
const active = year === activeYear;
return (
<button
key={year}
type="button"
aria-pressed={active}
onClick={() => onToggleYear(year)}
className={cn(
CHIP_CLASSES,
"tabular-nums",
active && CHIP_ACTIVE_CLASSES,
)}
>
{year}
</button>
);
})}
</span>
</>
) : null}
</div>
</div>
);
}
+23 -76
View File
@@ -1,23 +1,23 @@
"use client";
/**
* The vault's upload entry: a dashed drop-target card with a file button.
* The vault's upload plumbing: the hidden file input the page header's
* Upload button opens, plus a quota bar that appears only above 80 %
* usage. There is no standing drop-target card — it took a full band of
* vertical space on every visit for an affordance the header button and
* the page-wide drag-and-drop overlay already cover. Dropping a file
* anywhere on the page is caught by that overlay; the header button opens
* this input.
*
* The `accept` list comes verbatim from the usage endpoint (HEIC is
* deliberately absent so the iOS picker transcodes camera photos to JPEG),
* `multiple` is on, and there is NO `capture` attribute — mobile Safari
* then offers camera AND library. Dropping files onto the card feeds the
* same queue as the picker.
*
* The quota bar renders only above 80 % usage — a calm surface until
* storage actually becomes a topic.
* then offers camera AND library.
*/
import { UploadCloud } from "lucide-react";
import { useRef, useState, type RefObject } from "react";
import { type RefObject } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "./vault-utils";
@@ -36,8 +36,6 @@ export function UploadZone({
inputRef: RefObject<HTMLInputElement | null>;
}) {
const { t, locale } = useTranslations();
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
const accept = usage?.acceptedExtensions.join(",");
const usedFraction =
@@ -49,71 +47,20 @@ export function UploadZone({
};
return (
<div data-slot="document-upload-zone" className="space-y-2">
<div
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
<div data-slot="document-upload-zone">
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="sr-only"
aria-label={t("documents.upload.browse")}
onChange={(e) => {
pickFiles(e.target.files);
// Allow re-selecting the same file (duplicate flow) immediately.
e.target.value = "";
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
pickFiles(e.dataTransfer.files);
}}
className={cn(
"border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
dragOver && "border-primary bg-primary/5",
)}
>
<div className="flex flex-col items-center gap-2">
<UploadCloud
className={cn(
"text-muted-foreground size-6",
dragOver && "text-primary",
)}
aria-hidden
/>
<p className="text-sm font-medium">
{t("documents.upload.zoneTitle")}
</p>
{usage ? (
<p className="text-muted-foreground text-xs">
{t("documents.upload.zoneHint", {
maxSize: formatBytes(usage.maxFileBytes, locale),
})}
</p>
) : null}
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => inputRef.current?.click()}
>
{t("documents.upload.browse")}
</Button>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="sr-only"
aria-label={t("documents.upload.browse")}
onChange={(e) => {
pickFiles(e.target.files);
// Allow re-selecting the same file (duplicate flow) immediately.
e.target.value = "";
}}
/>
</div>
/>
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
<div data-slot="document-quota-bar" className="space-y-1">
+1 -17
View File
@@ -36,13 +36,6 @@ const PUBLIC_PATHS = [
"/c/",
];
// Routes that opt into the wide content container (`max-w-screen-2xl`
// instead of the standard `max-w-screen-xl`). The shell stays the single
// owner of the page frame — a wide page never re-implements paddings or
// margins; it is listed here instead. Currently: the Dokumente vault,
// whose multi-column document timeline earns the extra width.
const WIDE_CONTENT_PATHS = ["/documents"];
export function AuthShell({
children,
demoMode = false,
@@ -76,9 +69,6 @@ export function AuthShell({
pathname.startsWith("/about") ||
pathname.startsWith("/c/");
const isAdminPage = pathname.startsWith("/admin");
const isWidePage = WIDE_CONTENT_PATHS.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
@@ -343,13 +333,7 @@ export function AuthShell({
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div
className={
isWidePage
? "mx-auto max-w-screen-2xl px-4 pt-6 pb-20 md:px-6"
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
}
>
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
{children}
</div>
</main>