Add download for protocol visit photos (Task #660)
Users could upload photos into Protocol "تصوير" albums but had no way to
download them — clicking a photo only opened it inline. This adds per-photo
downloads and a whole-album ZIP download.
Backend:
- objectStorage.ts: new exported extensionForContentType() mapping content
types to file extensions (stored paths are extension-less UUIDs).
- storage.ts: opt-in ?download=1 mode on GET /storage/objects/*path sets
Content-Disposition: attachment (with optional ?name base + derived ext);
default inline behavior and per-entity authz unchanged.
- protocol.ts: new GET /protocol/photo-albums/:id/download streams all album
photos as a ZIP (guarded by requireProtocolAccess), named from the album;
unreadable objects are skipped rather than failing the archive. Empty album
returns 404.
- Added archiver@8 dependency (+@types/archiver dev).
Frontend (protocol.tsx):
- Per-photo download button (attachment, name = album-index) in album detail.
- "تحميل الكل" / "Download all" ZIP button in album header; rendered disabled
(not hidden) when the album has no photos.
- Added photoDownloadSrc/albumDownloadSrc helpers, Download icon.
- AR/EN locale strings protocol.photos.download / downloadAll.
Notes / deviations:
- archiver v8 is ESM-only with no callable default export; used
`new ZipArchive(...)` instead of `archiver("zip")`. Recorded in memory.
- Could not run authenticated e2e (seed admin password is a secret). Verified:
api-server typecheck + build pass, tx-os typecheck passes, server healthy,
new routes wired (401 auth-required not 404). Pre-existing typecheck errors
in executive-meetings.ts / push.ts are unrelated and untouched.
This commit is contained in:
@@ -2101,7 +2101,7 @@ function PhotosView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
{(photos.data?.length ?? 0) > 0 && (
|
{(photos.data?.length ?? 0) > 0 ? (
|
||||||
<a
|
<a
|
||||||
href={albumDownloadSrc(openAlbum.id)}
|
href={albumDownloadSrc(openAlbum.id)}
|
||||||
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm hover:bg-slate-50 transition"
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm hover:bg-slate-50 transition"
|
||||||
@@ -2109,6 +2109,14 @@ function PhotosView({
|
|||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
{t("protocol.photos.downloadAll")}
|
{t("protocol.photos.downloadAll")}
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-disabled="true"
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-200 bg-white text-slate-400 text-sm opacity-60 cursor-not-allowed pointer-events-none"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{t("protocol.photos.downloadAll")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{canMutate && (
|
{canMutate && (
|
||||||
<label className="cursor-pointer">
|
<label className="cursor-pointer">
|
||||||
|
|||||||
Reference in New Issue
Block a user