Files
HealthLog/src/components/documents/upload-zone.tsx
T
Marc-André Bombeck 56daa57350 refactor(format): one byte formatter for every surface
The document vault and the admin area each carried a formatBytes; the
same backup read 1.25 MB in one and 1.2 MB in the other, and only the
vault knew gigabytes. The vault rule (one decimal below 100, up to GB,
digits in the reader's locale) now lives in src/lib/format/bytes.ts and
takes the locale, so the server-rendered share page uses it too. A test
holds the definition to that one module.
2026-10-20 12:55:06 +02:00

90 lines
3.1 KiB
TypeScript

"use client";
/**
* 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.
*/
import { type RefObject } from "react";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "@/lib/format/bytes";
/** Usage fraction above which the quota bar appears. */
const QUOTA_BAR_THRESHOLD = 0.8;
export function UploadZone({
usage,
onFiles,
inputRef,
}: {
/** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */
usage: DocumentUsageDto | undefined;
onFiles: (files: File[]) => void;
/** Exposed so the page header's Upload button can open the same picker. */
inputRef: RefObject<HTMLInputElement | null>;
}) {
const { t, locale } = useTranslations();
const accept = usage?.acceptedExtensions.join(",");
const usedFraction =
usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0;
const pickFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
onFiles(Array.from(list));
};
return (
<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 = "";
}}
/>
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
<div data-slot="document-quota-bar" className="space-y-1">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{t("documents.upload.quotaAlmostFull")}
</p>
<p className="text-muted-foreground text-xs tabular-nums">
{t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
</p>
</div>
<Progress
value={Math.min(100, usedFraction * 100)}
aria-label={t("documents.upload.quotaUsed", {
used: formatBytes(usage.usedBytes, locale),
quota: formatBytes(usage.quotaBytes, locale),
})}
/>
</div>
) : null}
</div>
);
}