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 (hidden when empty).
- 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:
Riyadh
2026-07-07 09:34:06 +00:00
parent 22af2e8848
commit a36adb5e9e
8 changed files with 584 additions and 27 deletions
+2
View File
@@ -1774,6 +1774,8 @@
"linkMeeting": "ربط بلقاء خارجي",
"noMeeting": "بدون ربط",
"addPhotos": "إضافة صور",
"download": "تنزيل",
"downloadAll": "تنزيل الكل",
"uploading": "جارٍ الرفع",
"emptyAlbums": "لا توجد ألبومات.",
"emptyPhotos": "لا توجد صور في هذا الألبوم.",
+2
View File
@@ -1647,6 +1647,8 @@
"linkMeeting": "Link to external meeting",
"noMeeting": "No link",
"addPhotos": "Add photos",
"download": "Download",
"downloadAll": "Download all",
"uploading": "Uploading",
"emptyAlbums": "No albums yet.",
"emptyPhotos": "No photos in this album.",
+58 -25
View File
@@ -25,6 +25,7 @@ import {
Camera,
Upload,
Loader2,
Download,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -1982,6 +1983,15 @@ type Photo = {
// Build the authorized image URL from a stored `/objects/<id>` path.
const photoSrc = (objectPath: string) => `${API}/storage${objectPath}`;
// Force-download URL for a single photo (attachment via ?download=1). The
// optional name base gets an extension appended server-side.
const photoDownloadSrc = (objectPath: string, name?: string) => {
const base = `${API}/storage${objectPath}?download=1`;
return name ? `${base}&name=${encodeURIComponent(name)}` : base;
};
// ZIP download URL for a whole album.
const albumDownloadSrc = (albumId: number) =>
`${API}/protocol/photo-albums/${albumId}/download`;
function PhotosView({
canMutate,
@@ -2090,31 +2100,42 @@ function PhotosView({
)}
</div>
</div>
{canMutate && (
<label className="cursor-pointer shrink-0">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
<div className="flex items-center gap-2 shrink-0">
{(photos.data?.length ?? 0) > 0 && (
<a
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"
>
<Download size={16} />
{t("protocol.photos.downloadAll")}
</a>
)}
{canMutate && (
<label className="cursor-pointer">
<input
type="file"
accept="image/*"
multiple
disabled={isUploading}
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
e.currentTarget.value = "";
}}
/>
<span className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:opacity-90 transition">
{isUploading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Upload size={16} />
)}
{isUploading
? `${t("protocol.photos.uploading")} ${progress}%`
: t("protocol.photos.addPhotos")}
</span>
</label>
)}
</div>
</div>
{photos.data && photos.data.length === 0 && (
@@ -2134,6 +2155,18 @@ function PhotosView({
className="w-full h-full object-cover"
/>
</a>
<a
href={photoDownloadSrc(
p.objectPath,
`${openAlbum.name}-${p.sortOrder + 1}`,
)}
download
title={t("protocol.photos.download")}
aria-label={t("protocol.photos.download")}
className="absolute bottom-1 end-1 p-1.5 rounded-lg bg-black/50 text-white opacity-0 group-hover:opacity-100 transition"
>
<Download size={14} />
</a>
{canMutate && (
<button
type="button"