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:
@@ -1774,6 +1774,8 @@
|
||||
"linkMeeting": "ربط بلقاء خارجي",
|
||||
"noMeeting": "بدون ربط",
|
||||
"addPhotos": "إضافة صور",
|
||||
"download": "تنزيل",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"uploading": "جارٍ الرفع",
|
||||
"emptyAlbums": "لا توجد ألبومات.",
|
||||
"emptyPhotos": "لا توجد صور في هذا الألبوم.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user