From a5ad5ef77c81315415d05dfdb0b0c6145696ced5 Mon Sep 17 00:00:00 2001 From: donutinit <64450412+donutinit@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:08:31 -0600 Subject: [PATCH] Photo-only delivery and a gallery page for photo share links --- docs/wiki/Admin-Settings.md | 1 + docs/wiki/Client-Guide.md | 9 + docs/wiki/Features.md | 1 + docs/wiki/Usage-Guide.md | 2 + .../migration.sql | 1 + prisma/schema.prisma | 1 + src/app/admin/projects/[id]/settings/page.tsx | 21 + src/app/admin/projects/[id]/share/page.tsx | 36 +- src/app/api/comments/route.ts | 13 +- src/app/api/content/[token]/route.ts | 5 + src/app/api/content/zip/[token]/route.ts | 5 + src/app/api/projects/[id]/approve/route.ts | 8 +- src/app/api/projects/[id]/notify/route.ts | 18 +- src/app/api/projects/[id]/route.ts | 4 + src/app/api/share/[token]/comments/route.ts | 6 +- .../share/[token]/download-all-token/route.ts | 9 + src/app/api/share/[token]/route.ts | 7 +- .../api/share/[token]/video-token/route.ts | 7 +- .../assets/[assetId]/download-token/route.ts | 5 + .../api/videos/[id]/assets/[assetId]/route.ts | 5 + .../[id]/assets/download-zip-token/route.ts | 5 + .../videos/[id]/assets/download-zip/route.ts | 5 + src/app/api/videos/[id]/assets/route.ts | 5 + .../api/videos/[id]/client-assets/route.ts | 15 + .../api/videos/[id]/download-token/route.ts | 5 + src/app/api/videos/[id]/download/route.ts | 5 + src/app/share/[token]/SharePageClient.tsx | 43 +- src/components/LanguageToggle.tsx | 13 +- src/components/PhotoDeliveryGallery.tsx | 516 ++++++++++++++++++ src/components/PhotoLightbox.tsx | 140 ++++- src/components/ProjectActions.tsx | 52 +- src/components/SharePhotoSection.tsx | 165 +----- src/components/ThemeToggle.tsx | 18 +- src/hooks/useAlbumGallery.ts | 211 +++++++ src/lib/comment-helpers.ts | 11 +- src/lib/email.ts | 10 +- src/lib/notifications.ts | 18 +- src/lib/s3-upload-auth.ts | 4 +- src/lib/validation.ts | 3 + src/locales/de.json | 6 + src/locales/en.json | 6 + src/locales/nl.json | 6 + src/pages/api/uploads/[[...path]].ts | 4 +- src/worker/client-notifications.ts | 11 + 44 files changed, 1226 insertions(+), 215 deletions(-) create mode 100644 prisma/migrations/20260930120000_add_photo_only_share/migration.sql create mode 100644 src/components/PhotoDeliveryGallery.tsx create mode 100644 src/hooks/useAlbumGallery.ts diff --git a/docs/wiki/Admin-Settings.md b/docs/wiki/Admin-Settings.md index 240703b2..ecf6d5e4 100644 --- a/docs/wiki/Admin-Settings.md +++ b/docs/wiki/Admin-Settings.md @@ -179,6 +179,7 @@ Subscribe to project deadlines from any calendar app (Google Calendar, Apple Cal - Guest mode: view-only access without credentials. - Guest latest only: restrict guests to the latest version of each video (default: true). - Show photo albums to guests: guests can view photo albums; downloads stay disabled for guests (default: false). +- Photo-only delivery: the share link becomes a photo gallery and never exposes the project's videos. Recipients cannot see, stream, comment on, approve or download them, and video links handed out before the switch was turned on stop working. Admins keep full access (default: false). **Workflow** - Revision limit (enable/disable with max revisions count). diff --git a/docs/wiki/Client-Guide.md b/docs/wiki/Client-Guide.md index c621de8e..302b4c68 100644 --- a/docs/wiki/Client-Guide.md +++ b/docs/wiki/Client-Guide.md @@ -182,6 +182,15 @@ If the project contains photos, albums appear below the videos (for guests only - Select multiple photos (checkbox on each thumbnail) to download just those. - If the admin has enabled photo downloads, you can download your selection, the whole album, or all project photos as a ZIP. Photo downloads are never available in guest mode. +### Photo galleries + +When a share link delivers photos only (the project has no videos, or the admin turned on photo-only delivery), it opens straight into a gallery instead of the overview: + +- A full-screen cover with the project title; **View Gallery** scrolls down to the photos. +- The first album is already open. With several albums, they appear as tabs under the title; click one to switch. The address bar keeps the open album, so a copied link opens the same one. +- Photos sit in a masonry grid that keeps their original proportions. Click one to open the lightbox; use the arrow keys, or swipe on touch screens. +- With photo downloads enabled, the **Download** button in the top bar saves the open album or every album as a ZIP. With a mouse, hovering a photo shows a single-photo download and a circle that adds it to a selection you can download from the bar at the bottom. On touch screens, download single photos from the lightbox. + ## Downloading Download options appear after a video has been approved (not available in guest mode): diff --git a/docs/wiki/Features.md b/docs/wiki/Features.md index 647378f3..b4237c8d 100644 --- a/docs/wiki/Features.md +++ b/docs/wiki/Features.md @@ -15,6 +15,7 @@ - Video version comparison: side-by-side and slider overlay modes with synced playback. - Photo galleries: per-project albums with automatic webp thumbnails, admin-selectable covers, and a full-page client album view with lightbox and multi-select. Dropping folders onto the Photos section creates an album per folder. - Photo zip downloads on the share page (selected photos, one album, or the whole project), gated by a per-project toggle. +- Photo delivery gallery: share links with photos and no videos open on a full-screen cover with the albums as tabs and a masonry grid. A per-project photo-only switch keeps a project's videos off the share link entirely. - Recipient portal at `/portal`: recipients sign in with a magic link and see all active projects they are assigned to in one place. - Loop playback toggle in the video player. - Grid/list view toggle on the share page, applying to both videos and photo albums. diff --git a/docs/wiki/Usage-Guide.md b/docs/wiki/Usage-Guide.md index 8c8dd484..2ff8df58 100644 --- a/docs/wiki/Usage-Guide.md +++ b/docs/wiki/Usage-Guide.md @@ -124,6 +124,8 @@ Projects can hold photo albums alongside videos — useful for stills, BTS shots **Client side:** albums appear on the share page below the videos, with a full-page album view, lightbox, and multi-select. To let clients download photos (selection, album, or all project photos as a ZIP), enable **Allow photo downloads** in the project settings. +**Photo deliveries:** a share link that carries photos but no videos opens as a gallery: a full-screen cover, the albums as tabs, and a masonry grid with the first album already open. To deliver only photos from a project that also holds videos, enable **Photo-only delivery** in the project settings (Client Share Page). The link then exposes no video at all (page, streams, downloads, comments or approval), while you keep working on the videos in the admin area. Notifications sent for a photo-only project always announce the whole gallery. + ## Recipient portal Recipients added to projects can use the portal at `/portal` to see all their active projects in one place: diff --git a/prisma/migrations/20260930120000_add_photo_only_share/migration.sql b/prisma/migrations/20260930120000_add_photo_only_share/migration.sql new file mode 100644 index 00000000..218d5572 --- /dev/null +++ b/prisma/migrations/20260930120000_add_photo_only_share/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Project" ADD COLUMN "photoOnlyShare" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 52f30109..535f4d6b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,6 +92,7 @@ model Project { allowClientAssetUpload Boolean @default(false) // Allow clients to upload file attachments on comments clientCanApprove Boolean @default(true) // Allow clients to approve videos (false = admin only) allowReverseShare Boolean @default(false) // Allow clients to upload files directly to the project (reverse share) + photoOnlyShare Boolean @default(false) // Photo-only delivery: the share link serves photo albums only; videos stay admin-side // Approved playback settings usePreviewForApprovedPlayback Boolean @default(false) // Use browser-compatible preview instead of original after approval diff --git a/src/app/admin/projects/[id]/settings/page.tsx b/src/app/admin/projects/[id]/settings/page.tsx index caefd0a8..d3d9005c 100644 --- a/src/app/admin/projects/[id]/settings/page.tsx +++ b/src/app/admin/projects/[id]/settings/page.tsx @@ -54,6 +54,7 @@ interface Project { allowPhotoDownload: boolean allowClientAssetUpload: boolean allowReverseShare: boolean + photoOnlyShare: boolean clientCanApprove: boolean usePreviewForApprovedPlayback: boolean showClientTutorial: boolean @@ -107,6 +108,7 @@ export default function ProjectSettingsPage() { const [allowPhotoDownload, setAllowPhotoDownload] = useState(true) const [allowClientAssetUpload, setAllowClientAssetUpload] = useState(false) const [allowReverseShare, setAllowReverseShare] = useState(false) + const [photoOnlyShare, setPhotoOnlyShare] = useState(false) const [clientCanApprove, setClientCanApprove] = useState(true) const [usePreviewForApprovedPlayback, setUsePreviewForApprovedPlayback] = useState(false) const [showClientTutorial, setShowClientTutorial] = useState(true) @@ -193,6 +195,7 @@ export default function ProjectSettingsPage() { setAllowPhotoDownload(data.allowPhotoDownload ?? true) setAllowClientAssetUpload(data.allowClientAssetUpload ?? false) setAllowReverseShare(data.allowReverseShare ?? false) + setPhotoOnlyShare(data.photoOnlyShare ?? false) setClientCanApprove(data.clientCanApprove ?? true) setUsePreviewForApprovedPlayback(data.usePreviewForApprovedPlayback ?? false) setShowClientTutorial(data.showClientTutorial ?? true) @@ -307,6 +310,7 @@ export default function ProjectSettingsPage() { allowPhotoDownload, allowClientAssetUpload, allowReverseShare, + photoOnlyShare, clientCanApprove, usePreviewForApprovedPlayback, showClientTutorial, @@ -727,6 +731,23 @@ export default function ProjectSettingsPage() { // Client Share Page content const clientShareContent = ( <> + {/* ── Delivery ─────────────────────────────────────────────── */} +
+
+
+ +

+ {t('photoOnlyShareDescription')} +

+
+ +
+
+ {/* ── Approval & Workflow ─────────────────────────────────── */}
diff --git a/src/app/admin/projects/[id]/share/page.tsx b/src/app/admin/projects/[id]/share/page.tsx index 085d3db2..09662808 100644 --- a/src/app/admin/projects/[id]/share/page.tsx +++ b/src/app/admin/projects/[id]/share/page.tsx @@ -13,6 +13,7 @@ import { ArrowLeft } from 'lucide-react' import { apiFetch } from '@/lib/api-client' import ThemeToggle from '@/components/ThemeToggle' import SharePhotoSection from '@/components/SharePhotoSection' +import PhotoDeliveryGallery from '@/components/PhotoDeliveryGallery' import ShareViewToggle, { loadShareViewMode, type ShareViewMode } from '@/components/ShareViewToggle' import { useTranslations } from 'next-intl' @@ -45,6 +46,7 @@ export default function AdminSharePage() { const [_companyName, setCompanyName] = useState('Studio') const [viewMode, setViewMode] = useState('grid') const [albumCount, setAlbumCount] = useState(0) + const [hasPhotos, setHasPhotos] = useState(false) useEffect(() => { setViewMode(loadShareViewMode()) }, []) const [defaultQuality, setDefaultQuality] = useState<'720p' | '1080p' | '2160p'>('720p') @@ -266,14 +268,21 @@ export default function AdminSharePage() { } try { // Fetch project, settings, and current user in parallel - const [projectResponse, userResponse, settingsResponse] = await Promise.all([ + const [projectResponse, userResponse, settingsResponse, albumsResponse] = await Promise.all([ apiFetch(`/api/projects/${id}`, { cache: 'no-store' }), apiFetch('/api/auth/session', { cache: 'no-store' }), apiFetch('/api/settings', { cache: 'no-store' }), + apiFetch(`/api/projects/${id}/photo-albums`, { cache: 'no-store' }), ]) if (!isMounted) return + // Same rule as the share API's hasPhotos: an album with at least one finished photo + if (albumsResponse.ok) { + const albumsData = await albumsResponse.json() + setHasPhotos((albumsData.albums || []).some((album: any) => album.photoCount > 0)) + } + if (projectResponse.ok) { const projectData = await projectResponse.json() @@ -550,6 +559,31 @@ export default function AdminSharePage() { const showCommentPanel = !project.hideFeedback && !hideComments + // Photo deliveries open as a gallery, exactly like the public share page + const hasReadyVideos = project.videos.some((video: any) => video.status === 'READY') + if (project.photoOnlyShare || (hasPhotos && !hasReadyVideos)) { + return ( + router.push(projectUrl)} + title={t('backToProject')} + > + + {t('backToProject')} + + } + /> + ) + } + // Show thumbnail grid when in grid view (same as public share layout) if (viewState === 'grid') { return ( diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts index a6627887..b20ae94b 100644 --- a/src/app/api/comments/route.ts +++ b/src/app/api/comments/route.ts @@ -49,6 +49,7 @@ export async function GET(request: NextRequest) { authMode: true, companyName: true, hideFeedback: true, + photoOnlyShare: true, guestMode: true, } }) @@ -74,7 +75,8 @@ export async function GET(request: NextRequest) { const { isAdmin, isAuthenticated, isGuest } = accessCheck - if (isGuest) { + // Guests and photo-only share links never see video feedback + if (isGuest || (!isAdmin && project.photoOnlyShare)) { return NextResponse.json([]) } @@ -224,6 +226,7 @@ export async function POST(request: NextRequest) { id: true, sharePassword: true, authMode: true, + photoOnlyShare: true, } }) @@ -257,6 +260,14 @@ export async function POST(request: NextRequest) { const { isAdmin, isAuthenticated } = accessCheck + // Photo-only delivery: share recipients cannot comment on videos they cannot see + if (!isAdmin && project.photoOnlyShare) { + return NextResponse.json( + { error: shareMessages.accessDenied || 'Access denied' }, + { status: 403 } + ) + } + const { authorEmail: finalAuthorEmail, fallbackName } = await resolveCommentAuthor({ projectId, authorEmail, diff --git a/src/app/api/content/[token]/route.ts b/src/app/api/content/[token]/route.ts index fd4bb4f3..c16bc47d 100644 --- a/src/app/api/content/[token]/route.ts +++ b/src/app/api/content/[token]/route.ts @@ -181,6 +181,11 @@ export async function GET( return NextResponse.json({ error: shareMessages.accessDenied || 'Access denied' }, { status: 404 }) } + // Photo-only delivery: tokens minted before the switch was turned on stop working too + if (!isAdminRequest && video.project.photoOnlyShare) { + return NextResponse.json({ error: shareMessages.accessDenied || 'Access denied' }, { status: 404 }) + } + const originalPath = video.originalStoragePath const requestedQuality = verifiedToken.quality diff --git a/src/app/api/content/zip/[token]/route.ts b/src/app/api/content/zip/[token]/route.ts index 9b28c5b0..db3e4b7e 100644 --- a/src/app/api/content/zip/[token]/route.ts +++ b/src/app/api/content/zip/[token]/route.ts @@ -90,6 +90,11 @@ export async function GET( return NextResponse.json({ error: shareMessages.accessDenied || 'Access denied' }, { status: 403 }) } + // Photo-only delivery: zip links minted before the switch was turned on stop working too + if (tokenData.isAdmin !== true && video.project.photoOnlyShare) { + return NextResponse.json({ error: shareMessages.accessDenied || 'Access denied' }, { status: 403 }) + } + const assets = await prisma.videoAsset.findMany({ where: { id: { in: assetIds }, diff --git a/src/app/api/projects/[id]/approve/route.ts b/src/app/api/projects/[id]/approve/route.ts index c05d5048..fdea18b8 100644 --- a/src/app/api/projects/[id]/approve/route.ts +++ b/src/app/api/projects/[id]/approve/route.ts @@ -83,6 +83,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }, { status: 403 }) } + // Photo-only delivery: share recipients never see the videos, so they cannot approve them + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: projectMessages.selectedVideoNotFound || 'Selected video not found' }, { status: 404 }) + } + if (project.status === 'APPROVED') { return NextResponse.json({ error: projectMessages.projectAlreadyApproved || 'Project already approved' }, { status: 400 }) } @@ -205,7 +210,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: project.id, title: project.title, slug: project.slug, - clientNotificationSchedule: project.clientNotificationSchedule + clientNotificationSchedule: project.clientNotificationSchedule, + photoOnlyShare: project.photoOnlyShare, }, approvedVideos: approvedVideosList, approved: true, diff --git a/src/app/api/projects/[id]/notify/route.ts b/src/app/api/projects/[id]/notify/route.ts index 1cd131f8..152dc6d9 100644 --- a/src/app/api/projects/[id]/notify/route.ts +++ b/src/app/api/projects/[id]/notify/route.ts @@ -48,7 +48,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id: projectId } = await params const body = await request.json() - const { videoId, notifyEntireProject, sendPasswordSeparately } = body + const { videoId, notifyEntireProject: requestedEntireProject, sendPasswordSeparately } = body // Get project details including password const project = await prisma.project.findUnique({ @@ -58,6 +58,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ description: true, slug: true, sharePassword: true, + photoOnlyShare: true, videos: { where: { status: 'READY' }, select: { @@ -75,6 +76,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: projectMessages.projectNotFound || 'Project not found' }, { status: 404 }) } + // Photo-only links deliver the gallery as a whole: never announce a single video + const notifyEntireProject = requestedEntireProject || project.photoOnlyShare + + // Photo-only links list their albums as deliverables + const photoAlbums = project.photoOnlyShare + ? (await prisma.photoAlbum.findMany({ + where: { projectId, photos: { some: { uploadCompletedAt: { not: null } } } }, + orderBy: { createdAt: 'asc' }, + select: { name: true }, + })).map(album => album.name) + : [] + // Get recipients const recipients = await getProjectRecipients(projectId) @@ -139,7 +152,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ projectTitle: project.title, projectDescription: project.description || '', shareUrl, - readyVideos: project.videos.map(v => ({ name: v.name, versionLabel: v.versionLabel })), + readyVideos: project.photoOnlyShare ? [] : project.videos.map(v => ({ name: v.name, versionLabel: v.versionLabel })), + photoAlbums, isPasswordProtected, unsubscribeUrl, locale: recipientLocale, diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index 58955c07..c04875fa 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -300,6 +300,10 @@ export async function PATCH( updateData.allowReverseShare = validatedBody.allowReverseShare } + if (validatedBody.photoOnlyShare !== undefined) { + updateData.photoOnlyShare = validatedBody.photoOnlyShare + } + if (validatedBody.clientCanApprove !== undefined) { updateData.clientCanApprove = validatedBody.clientCanApprove } diff --git a/src/app/api/share/[token]/comments/route.ts b/src/app/api/share/[token]/comments/route.ts index 08860f32..3769c935 100644 --- a/src/app/api/share/[token]/comments/route.ts +++ b/src/app/api/share/[token]/comments/route.ts @@ -51,6 +51,7 @@ export async function GET( authMode: true, companyName: true, hideFeedback: true, + photoOnlyShare: true, guestMode: true, } }) @@ -59,8 +60,9 @@ export async function GET( return NextResponse.json({ error: shareMessages?.accessDenied || 'Access denied' }, { status: 403 }) } - // SECURITY: If feedback is hidden, return empty array (don't expose comments) - if (project.hideFeedback) { + // SECURITY: If feedback is hidden or the link is photo-only, return empty array + // (comments are video feedback and must not leak through a photo delivery) + if (project.hideFeedback || project.photoOnlyShare) { return NextResponse.json([]) } diff --git a/src/app/api/share/[token]/download-all-token/route.ts b/src/app/api/share/[token]/download-all-token/route.ts index 16137afa..7c58c081 100644 --- a/src/app/api/share/[token]/download-all-token/route.ts +++ b/src/app/api/share/[token]/download-all-token/route.ts @@ -39,6 +39,7 @@ export async function POST( sharePassword: true, authMode: true, allowAssetDownload: true, + photoOnlyShare: true, title: true, }, }) @@ -71,6 +72,14 @@ export async function POST( ) } + // Photo-only delivery: videos are not downloadable through the share link + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json( + { error: shareMessages.noApprovedVideos || 'No approved videos available for download' }, + { status: 404 } + ) + } + // Find all approved videos with latest version per name const approvedVideos = await prisma.video.findMany({ where: { diff --git a/src/app/api/share/[token]/route.ts b/src/app/api/share/[token]/route.ts index ff5c4629..69579a8b 100644 --- a/src/app/api/share/[token]/route.ts +++ b/src/app/api/share/[token]/route.ts @@ -42,6 +42,7 @@ export async function GET( guestMode: true, guestLatestOnly: true, guestShowPhotos: true, + photoOnlyShare: true, sharePassword: true, authMode: true, }, @@ -142,7 +143,10 @@ export async function GET( }, { status: 401 }) } - const videosSanitizedBase = project.videos.map((video: any) => ({ + // Photo-only delivery: the share link never carries videos, whoever opens it + const sharedVideos = projectMeta.photoOnlyShare ? [] : project.videos + + const videosSanitizedBase = sharedVideos.map((video: any) => ({ id: video.id, name: video.name, version: video.version, @@ -309,6 +313,7 @@ export async function GET( allowAssetDownload: project.allowAssetDownload, allowPhotoDownload: project.allowPhotoDownload, hasPhotos: photoAlbumCount > 0, + photoOnlyShare: projectMeta.photoOnlyShare, allowClientAssetUpload: project.allowClientAssetUpload, allowReverseShare: project.allowReverseShare, clientCanApprove: project.clientCanApprove, diff --git a/src/app/api/share/[token]/video-token/route.ts b/src/app/api/share/[token]/video-token/route.ts index eda43830..233d79fc 100644 --- a/src/app/api/share/[token]/video-token/route.ts +++ b/src/app/api/share/[token]/video-token/route.ts @@ -31,13 +31,18 @@ export async function GET( const project = await prisma.project.findUnique({ where: { id: shareContext.projectId }, - select: { id: true, slug: true }, + select: { id: true, slug: true, photoOnlyShare: true }, }) if (!project || project.slug !== token) { return NextResponse.json({ error: shareMessages?.accessDenied || 'Access denied' }, { status: 403 }) } + // Photo-only delivery: no stream, thumbnail or download tokens for videos + if (project.photoOnlyShare) { + return NextResponse.json({ error: shareMessages?.videoNotFound || 'Video not found' }, { status: 404 }) + } + const video = await prisma.video.findUnique({ where: { id: videoId }, select: { diff --git a/src/app/api/videos/[id]/assets/[assetId]/download-token/route.ts b/src/app/api/videos/[id]/assets/[assetId]/download-token/route.ts index bc84bf4e..1f144bb1 100644 --- a/src/app/api/videos/[id]/assets/[assetId]/download-token/route.ts +++ b/src/app/api/videos/[id]/assets/[assetId]/download-token/route.ts @@ -58,6 +58,11 @@ export async function POST( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // Check download permissions for non-admins (non-client assets only) // Client-uploaded comment attachments bypass approval/download checks if (!accessCheck.isAdmin && !isClientAsset) { diff --git a/src/app/api/videos/[id]/assets/[assetId]/route.ts b/src/app/api/videos/[id]/assets/[assetId]/route.ts index 670159ff..bce04e30 100644 --- a/src/app/api/videos/[id]/assets/[assetId]/route.ts +++ b/src/app/api/videos/[id]/assets/[assetId]/route.ts @@ -67,6 +67,11 @@ export async function GET( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // For non-admins, verify asset download settings and video approval if (!accessCheck.isAdmin) { // Check if project allows asset downloads diff --git a/src/app/api/videos/[id]/assets/download-zip-token/route.ts b/src/app/api/videos/[id]/assets/download-zip-token/route.ts index 1719d3e7..a7697278 100644 --- a/src/app/api/videos/[id]/assets/download-zip-token/route.ts +++ b/src/app/api/videos/[id]/assets/download-zip-token/route.ts @@ -77,6 +77,11 @@ export async function POST( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // For non-admins, verify asset download settings and video approval if (!accessCheck.isAdmin) { if (!project.allowAssetDownload) { diff --git a/src/app/api/videos/[id]/assets/download-zip/route.ts b/src/app/api/videos/[id]/assets/download-zip/route.ts index f958caf6..c95d8dbd 100644 --- a/src/app/api/videos/[id]/assets/download-zip/route.ts +++ b/src/app/api/videos/[id]/assets/download-zip/route.ts @@ -69,6 +69,11 @@ export async function POST( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // For non-admins, verify asset download settings and video approval if (!accessCheck.isAdmin) { // Check if project allows asset downloads diff --git a/src/app/api/videos/[id]/assets/route.ts b/src/app/api/videos/[id]/assets/route.ts index c08db059..3b96878b 100644 --- a/src/app/api/videos/[id]/assets/route.ts +++ b/src/app/api/videos/[id]/assets/route.ts @@ -73,6 +73,11 @@ export async function GET( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // For non-admins, check if asset downloads are allowed if (!accessCheck.isAdmin && !project.allowAssetDownload) { return NextResponse.json( diff --git a/src/app/api/videos/[id]/client-assets/route.ts b/src/app/api/videos/[id]/client-assets/route.ts index f31a4a5a..cf689079 100644 --- a/src/app/api/videos/[id]/client-assets/route.ts +++ b/src/app/api/videos/[id]/client-assets/route.ts @@ -69,6 +69,11 @@ export async function POST( return accessCheck.errorResponse || NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videosMessages.videoNotFound || 'Video not found' }, { status: 404 }) + } + const uploaderSessionId = accessCheck.shareTokenSessionId if (!uploaderSessionId) { return NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) @@ -205,6 +210,11 @@ export async function GET( return NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videosMessages.videoNotFound || 'Video not found' }, { status: 404 }) + } + const uploaderSessionId = accessCheck.shareTokenSessionId if (!uploaderSessionId) { return NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) @@ -293,6 +303,11 @@ export async function DELETE( return accessCheck.errorResponse || NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && project.photoOnlyShare) { + return NextResponse.json({ error: videosMessages.videoNotFound || 'Video not found' }, { status: 404 }) + } + const uploaderSessionId = accessCheck.shareTokenSessionId if (!uploaderSessionId) { return NextResponse.json({ error: videosMessages.unauthorized || 'Unauthorized' }, { status: 403 }) diff --git a/src/app/api/videos/[id]/download-token/route.ts b/src/app/api/videos/[id]/download-token/route.ts index 993816ec..f8f8313f 100644 --- a/src/app/api/videos/[id]/download-token/route.ts +++ b/src/app/api/videos/[id]/download-token/route.ts @@ -47,6 +47,11 @@ export async function POST( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && video.project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + // Check download permissions for non-admins if (!accessCheck.isAdmin) { if (!video.project.allowAssetDownload) { diff --git a/src/app/api/videos/[id]/download/route.ts b/src/app/api/videos/[id]/download/route.ts index 2ba16e5d..9504073d 100644 --- a/src/app/api/videos/[id]/download/route.ts +++ b/src/app/api/videos/[id]/download/route.ts @@ -56,6 +56,11 @@ export async function GET( return NextResponse.json({ error: videoMessages.unauthorizedApi || 'Unauthorized' }, { status: 403 }) } + // Photo-only delivery: share recipients have no access to project videos + if (!accessCheck.isAdmin && video.project.photoOnlyShare) { + return NextResponse.json({ error: videoMessages.videoNotFoundApi || 'Video not found' }, { status: 404 }) + } + if (!accessCheck.isAdmin) { if (!video.project.allowAssetDownload) { return NextResponse.json({ error: videoMessages.downloadsDisabledForProject || 'Downloads are disabled for this project' }, { status: 403 }) diff --git a/src/app/share/[token]/SharePageClient.tsx b/src/app/share/[token]/SharePageClient.tsx index c0f7f303..24aab5b2 100644 --- a/src/app/share/[token]/SharePageClient.tsx +++ b/src/app/share/[token]/SharePageClient.tsx @@ -22,6 +22,7 @@ import { ShareTutorial } from '@/components/ShareTutorial' import PrivacyBanner, { PRIVACY_STORAGE_KEY } from '@/components/PrivacyBanner' import ReverseShareUploadPanel from '@/components/ReverseShareUploadPanel' import SharePhotoSection from '@/components/SharePhotoSection' +import PhotoDeliveryGallery from '@/components/PhotoDeliveryGallery' import ShareViewToggle, { loadShareViewMode, type ShareViewMode } from '@/components/ShareViewToggle' interface SharePageClientProps { @@ -188,7 +189,7 @@ export default function SharePageClient({ token }: SharePageClientProps) { tokenCacheRef.current.clear() - if (!projectData.hideFeedback) { + if (!projectData.hideFeedback && !projectData.photoOnlyShare) { fetchComments() } } @@ -302,7 +303,7 @@ export default function SharePageClient({ token }: SharePageClientProps) { setDefaultQuality(projectData.previewResolution || projectData.settings.defaultPreviewResolution || '720p') } - if (!projectData.hideFeedback) { + if (!projectData.hideFeedback && !projectData.photoOnlyShare) { fetchComments() } } @@ -939,6 +940,44 @@ export default function SharePageClient({ token }: SharePageClientProps) { return !comment.videoId || activeVideoIds.has(comment.videoId) }) + // Photo deliveries (photo-only links, or photos without any video) open as a + // gallery; the first album shows right away instead of an overview to click through + const hasVideos = Object.keys(project.videosByName || {}).length > 0 + if (project.photoOnlyShare || (project.hasPhotos && !hasVideos)) { + // Album requests need the share token; never fall back to the admin fetch + if (project.id && !shareToken) { + return ( +
+

{tc('loading')}

+
+ ) + } + + return ( + <> + + ) : undefined} + /> + + {project.settings?.privacyDisclosureEnabled && ( + + )} + + ) + } + if (viewState === 'grid') { return ( <> diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx index a99bd850..c4db710b 100644 --- a/src/components/LanguageToggle.tsx +++ b/src/components/LanguageToggle.tsx @@ -10,9 +10,11 @@ interface LocaleOption { interface LanguageToggleProps { onChange?: (locale: string) => void + /** 'ghost' drops the border and fill, for minimal bars like the photo gallery */ + variant?: 'button' | 'ghost' } -export default function LanguageToggle({ onChange }: LanguageToggleProps) { +export default function LanguageToggle({ onChange, variant = 'button' }: LanguageToggleProps) { const [locale, setLocale] = useState('en') const [availableLocales, setAvailableLocales] = useState([]) const [mounted, setMounted] = useState(false) @@ -88,12 +90,15 @@ export default function LanguageToggle({ onChange }: LanguageToggleProps) { return ( ) } diff --git a/src/components/PhotoDeliveryGallery.tsx b/src/components/PhotoDeliveryGallery.tsx new file mode 100644 index 00000000..924db8c8 --- /dev/null +++ b/src/components/PhotoDeliveryGallery.tsx @@ -0,0 +1,516 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useTranslations } from 'next-intl' +import { Check, Download, ImageIcon, Loader2, X } from 'lucide-react' +import PhotoLightbox from './PhotoLightbox' +import BrandLogo from './BrandLogo' +import ThemeToggle from './ThemeToggle' +import LanguageToggle from './LanguageToggle' +import { cn } from '@/lib/utils' +import { useAlbumGallery, type PhotoZipScope } from '@/hooks/useAlbumGallery' +import type { GalleryPhoto } from './PhotoGrid' + +/** URL parameter that keeps the open album linkable */ +const ALBUM_PARAM = 'album' + +interface PhotoDeliveryGalleryProps { + /** Omit when the viewer has no album access (the gallery shows its empty state) */ + projectId?: string + /** Share bearer token; omit for admin sessions (uses apiFetch instead) */ + shareToken?: string + title: string + description?: string | null + allowPhotoDownload: boolean + /** Shown before the title in the gallery bar and over the cover (e.g. back to project) */ + leadingActions?: ReactNode + /** Shown next to the download button in the gallery bar (e.g. reverse share upload) */ + trailingActions?: ReactNode + showLanguageToggle?: boolean +} + +/** Column count for the masonry grid, from the grid's own width. */ +function columnsForWidth(width: number): number { + if (width < 640) return 2 + if (width < 1024) return 3 + if (width < 1536) return 4 + return 5 +} + +/** Height-to-width ratio; unprocessed photos hold a square slot. */ +function photoRatio(photo: GalleryPhoto): number { + return photo.width && photo.height ? photo.height / photo.width : 1 +} + +function scrollBehavior(): ScrollBehavior { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' +} + +/** + * Share page for links that deliver photos only: a full-bleed cover, a sticky + * bar with the albums as tabs, and a masonry grid of the open album. The first + * album opens straight away, so a single-album delivery never shows an overview. + * Also used on the admin share preview (without a share token). + */ +export default function PhotoDeliveryGallery({ + projectId, + shareToken, + title, + description, + allowPhotoDownload, + leadingActions, + trailingActions, + showLanguageToggle = true, +}: PhotoDeliveryGalleryProps) { + const t = useTranslations('photos') + const tc = useTranslations('common') + + const scrollRef = useRef(null) + const galleryStartRef = useRef(null) + const gridRef = useRef(null) + const downloadMenuRef = useRef(null) + const downloadButtonRef = useRef(null) + const albumNavRef = useRef(null) + + const { + albums, + albumsLoading, + selectedAlbum, + setSelectedAlbum, + photos, + totalPhotos, + photosLoading, + sentinelRef, + loadMore, + buildPhotoUrl, + downloadPhoto, + downloadZip, + downloading, + } = useAlbumGallery({ projectId, shareToken, scrollRootRef: scrollRef }) + + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [lightboxIndex, setLightboxIndex] = useState(null) + const [downloadMenuOpen, setDownloadMenuOpen] = useState(false) + const [coverLoaded, setCoverLoaded] = useState(false) + const [columnCount, setColumnCount] = useState(3) + + // Albums still waiting for their first upload stay out of the delivery + const visibleAlbums = useMemo(() => albums.filter(album => album.photoCount > 0), [albums]) + + // Open the linked album, or the first one, as soon as the list arrives + useEffect(() => { + if (selectedAlbum || visibleAlbums.length === 0) return + const url = new URL(window.location.href) + const linked = visibleAlbums.find(album => album.id === url.searchParams.get(ALBUM_PARAM)) + if (!linked && url.searchParams.has(ALBUM_PARAM)) { + url.searchParams.delete(ALBUM_PARAM) + window.history.replaceState(null, '', url) + } + setSelectedAlbum(linked ?? visibleAlbums[0]) + }, [visibleAlbums, selectedAlbum, setSelectedAlbum]) + + useEffect(() => { + setSelectedIds(new Set()) + }, [selectedAlbum]) + + // Keep the active tab in view on the swipeable (touch) tab strip; scroll the strip only, never the page + useEffect(() => { + const nav = albumNavRef.current + const tab = nav?.querySelector('[aria-current]') + if (!nav || !tab || nav.scrollWidth <= nav.clientWidth) return + nav.scrollLeft = tab.offsetLeft - nav.offsetLeft - (nav.clientWidth - tab.offsetWidth) / 2 + }, [selectedAlbum]) + + // The lightbox owns Escape and focus while open + useEffect(() => { + if (lightboxIndex !== null) setDownloadMenuOpen(false) + }, [lightboxIndex]) + + const closeLightbox = useCallback(() => setLightboxIndex(null), []) + + useEffect(() => { + const node = gridRef.current + if (!node) return + const update = () => setColumnCount(columnsForWidth(node.clientWidth)) + update() + const observer = new ResizeObserver(update) + observer.observe(node) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (!downloadMenuOpen) return + const onMouseDown = (e: MouseEvent) => { + if (downloadMenuRef.current && !downloadMenuRef.current.contains(e.target as Node)) setDownloadMenuOpen(false) + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return + setDownloadMenuOpen(false) + downloadButtonRef.current?.focus() + } + document.addEventListener('mousedown', onMouseDown) + window.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('mousedown', onMouseDown) + window.removeEventListener('keydown', onKeyDown) + } + }, [downloadMenuOpen]) + + // Greedy shortest-column placement keeps the reading order left to right + // and stays stable while further pages are appended. + const columns = useMemo(() => { + const cols = Array.from({ length: columnCount }, () => ({ height: 0, items: [] as Array<{ photo: GalleryPhoto; index: number }> })) + photos.forEach((photo, index) => { + const shortest = cols.reduce((min, col) => (col.height < min.height ? col : min), cols[0]) + shortest.items.push({ photo, index }) + shortest.height += photoRatio(photo) + }) + return cols + }, [photos, columnCount]) + + const coverAlbum = visibleAlbums.find(album => album.coverPhotoId && album.contentToken) ?? null + const coverUrl = coverAlbum + ? `/api/content/photo/${coverAlbum.contentToken}?photoId=${coverAlbum.coverPhotoId}&variant=full` + : null + + const scrollToGallery = useCallback(() => { + galleryStartRef.current?.scrollIntoView({ behavior: scrollBehavior(), block: 'start' }) + }, []) + + const openAlbum = (albumId: string) => { + const album = visibleAlbums.find(a => a.id === albumId) + if (!album || album.id === selectedAlbum?.id) return + // Start the new album from its first row when the viewer was deep in the last one. + // Jump before swapping: a smooth scroll would glide through the half-loaded album. + const scroller = scrollRef.current + const start = galleryStartRef.current + if (scroller && start && scroller.scrollTop > start.offsetTop) { + scroller.scrollTo({ top: start.offsetTop, behavior: 'auto' }) + } + setSelectedAlbum(album) + const url = new URL(window.location.href) + url.searchParams.set(ALBUM_PARAM, album.id) + window.history.replaceState(null, '', url) + } + + const toggleSelect = (photoId: string) => { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(photoId)) next.delete(photoId) + else next.add(photoId) + return next + }) + } + + const runDownload = (scope: PhotoZipScope) => { + setDownloadMenuOpen(false) + downloadZip(scope, Array.from(selectedIds)) + } + + const selecting = selectedIds.size > 0 + const photoTotal = visibleAlbums.reduce((sum, album) => sum + album.photoCount, 0) + const canDownload = allowPhotoDownload && photoTotal > 0 + + return ( +
+ {/* Cover */} +
+ {coverUrl && ( + // eslint-disable-next-line @next/next/no-img-element + setCoverLoaded(true)} + className={cn( + 'absolute inset-0 -z-10 h-full w-full object-cover object-[50%_30%] motion-safe:transition-opacity motion-safe:duration-700 motion-safe:ease-out', + coverLoaded ? 'opacity-100' : 'opacity-0' + )} + /> + )} +
+ {leadingActions &&
{leadingActions}
} +
+ {/* Trailing letter-spacing pushes centered caps left; pad it back */} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + +
+
+ +
+ + {/* Gallery bar */} +
+
+ {leadingActions} + +
+

{title}

+ {photoTotal > 0 && ( +

+ {t('photoCount', { count: photoTotal })} +

+ )} +
+ +
+ {trailingActions} + {canDownload && ( +
{ + // Tabbing out closes the menu; clicks outside are handled by the mousedown listener + if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget as Node)) setDownloadMenuOpen(false) + }} + > + + {downloadMenuOpen && ( +
+ {selectedAlbum && ( + + )} + +
+ )} +
+ )} + {showLanguageToggle && } + +
+
+ + {visibleAlbums.length > 1 && ( + + )} +
+ + {/* Grid: at least a viewport tall so switching to a shorter album never drags the cover back into view */} +
+
+ {albumsLoading || photosLoading || (visibleAlbums.length > 0 && !selectedAlbum) ? ( +
+ +
+ ) : visibleAlbums.length === 0 ? ( +

+ {/* Without album access (guests on a link that hides photos from them) nothing will show up later */} + {projectId ? t('galleryEmpty') : t('noPhotosFound')} +

+ ) : photos.length === 0 ? ( +

{t('noPhotosYet')}

+ ) : ( + <> +
+ {columns.map((column, columnIndex) => ( +
+ {column.items.map(({ photo, index }) => { + const isSelected = selectedIds.has(photo.id) + return ( +
+ {photo.thumbUrl ? ( + + ) : ( +
+ + {t('processing')} +
+ )} + + {/* Hover controls: hidden on touch screens, where the lightbox and the bar carry downloads */} + {allowPhotoDownload && photo.thumbUrl && ( + <> +
+ + + + )} +
+ ) + })} +
+ ))} +
+ {photos.length < totalPhotos && ( +
+ +
+ )} + + )} +
+
+ + + + {/* Selection bar */} + {allowPhotoDownload && selecting && ( +
+
+ + {t('selectedCount', { count: selectedIds.size })} + + + +
+
+ )} + + {lightboxIndex !== null && ( + + )} +
+ ) +} diff --git a/src/components/PhotoLightbox.tsx b/src/components/PhotoLightbox.tsx index f7f24fa0..5691d1fb 100644 --- a/src/components/PhotoLightbox.tsx +++ b/src/components/PhotoLightbox.tsx @@ -1,9 +1,10 @@ 'use client' -import { useEffect, useCallback } from 'react' +import { useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react' import type { GalleryPhoto } from './PhotoGrid' +import { cn } from '@/lib/utils' interface PhotoLightboxProps { photos: GalleryPhoto[] @@ -12,6 +13,12 @@ interface PhotoLightboxProps { onClose: () => void onNavigate: (index: number) => void canDownload: boolean + /** Photos in the whole album when only some pages are loaded; the counter shows it and navigation waits for the next page instead of wrapping */ + total?: number + /** Called while browsing the last few loaded photos, so the host can fetch the next page */ + onNearEnd?: () => void + /** 'gallery' drops the file name and chrome for a plain, solid backdrop (photo delivery page) */ + variant?: 'default' | 'gallery' } export default function PhotoLightbox({ @@ -21,28 +28,93 @@ export default function PhotoLightbox({ onClose, onNavigate, canDownload, + total, + onNearEnd, + variant = 'default', }: PhotoLightboxProps) { const t = useTranslations('photos') const photo = photos[index] + const isGallery = variant === 'gallery' + const dialogRef = useRef(null) + const closeRef = useRef(null) + const touchStartRef = useRef<{ x: number; y: number } | null>(null) + + const count = Math.max(total ?? 0, photos.length) const goPrev = useCallback(() => { - onNavigate(index > 0 ? index - 1 : photos.length - 1) - }, [index, photos.length, onNavigate]) + if (index > 0) onNavigate(index - 1) + else if (photos.length >= count) onNavigate(photos.length - 1) + }, [index, photos.length, count, onNavigate]) const goNext = useCallback(() => { - onNavigate(index < photos.length - 1 ? index + 1 : 0) - }, [index, photos.length, onNavigate]) + if (index < photos.length - 1) onNavigate(index + 1) + else if (photos.length >= count) onNavigate(0) + }, [index, photos.length, count, onNavigate]) + + useEffect(() => { + if (onNearEnd && index >= photos.length - 5) onNearEnd() + }, [index, photos.length, onNearEnd]) + + // Horizontal swipes belong to the lightbox: stop the browser's swipe-back navigation meanwhile + useEffect(() => { + const root = document.documentElement + const previous = root.style.overscrollBehaviorX + root.style.overscrollBehaviorX = 'none' + return () => { root.style.overscrollBehaviorX = previous } + }, []) + + // Move focus into the dialog and hand it back to the opener on close + useEffect(() => { + const opener = document.activeElement as HTMLElement | null + closeRef.current?.focus() + return () => opener?.focus?.() + }, []) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() else if (e.key === 'ArrowLeft') goPrev() else if (e.key === 'ArrowRight') goNext() + else if (e.key === 'Tab' && dialogRef.current) { + // Keep Tab inside the dialog + const focusable = Array.from(dialogRef.current.querySelectorAll('button')) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } else if (!dialogRef.current.contains(document.activeElement)) { + e.preventDefault() + first.focus() + } + } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [onClose, goPrev, goNext]) + // Horizontal one-finger swipe on touch screens flips photos; pinch and vertical movement are left alone + const handleTouchStart = (e: React.TouchEvent) => { + const touch = e.touches.length === 1 ? e.touches[0] : null + touchStartRef.current = touch ? { x: touch.clientX, y: touch.clientY } : null + } + + const handleTouchEnd = (e: React.TouchEvent) => { + const start = touchStartRef.current + touchStartRef.current = null + const touch = e.changedTouches[0] + if (!start || !touch || e.touches.length > 0 || photos.length < 2) return + const dx = touch.clientX - start.x + const dy = touch.clientY - start.y + if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return + if (dx < 0) goNext() + else goPrev() + } + if (!photo) return null const handleDownload = () => { @@ -56,19 +128,45 @@ export default function PhotoLightbox({ document.body.removeChild(a) } + const iconButtonClass = isGallery + ? 'p-2 rounded-md text-muted-foreground hover:text-foreground transition-colors' + : 'p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors' + const arrowClass = isGallery + ? 'absolute top-1/2 -translate-y-1/2 p-3 text-muted-foreground hover:text-foreground transition-colors' + : 'absolute top-1/2 -translate-y-1/2 p-2 rounded-full bg-background/70 backdrop-blur-sm border border-border text-muted-foreground hover:text-foreground transition-colors' + const arrowIconClass = isGallery ? 'w-7 h-7' : 'w-5 h-5' + return ( -
-
-

- {photo.fileName} - {index + 1} / {photos.length} -

+
+
+ {isGallery ? ( +

+ {index + 1} / {count} +

+ ) : ( +

+ {photo.fileName} + {index + 1} / {count} +

+ )}
{canDownload && ( )}
-
+
{/* eslint-disable-next-line @next/next/no-img-element */} { e.stopPropagation(); goPrev() }} - className="absolute left-2 top-1/2 -translate-y-1/2 p-2 rounded-full bg-background/70 backdrop-blur-sm border border-border text-muted-foreground hover:text-foreground transition-colors" + className={cn(arrowClass, 'left-2')} aria-label={t('previousPhoto')} > - + )} diff --git a/src/components/ProjectActions.tsx b/src/components/ProjectActions.tsx index 9e8ffffb..fefb81b8 100644 --- a/src/components/ProjectActions.tsx +++ b/src/components/ProjectActions.tsx @@ -415,8 +415,8 @@ export default function ProjectActions({ project, videos, onRefresh, shareUrl =

{t('projectActions')}

- {/* Send Notification Button - only show if there are ready videos */} - {readyVideos.length > 0 && ( + {/* Send Notification Button - only show if there are ready videos (photo-only links announce the gallery) */} + {(readyVideos.length > 0 || project.photoOnlyShare) && (
-
+
{photosLoading ? (
@@ -491,6 +372,8 @@ export default function SharePhotoSection({ projectId, shareToken, allowPhotoDow onClose={() => setLightboxIndex(null)} onNavigate={setLightboxIndex} canDownload={allowPhotoDownload} + total={totalPhotos} + onNearEnd={loadMore} /> )} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index 8ce7622d..cf4bafcb 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -5,7 +5,8 @@ import { useEffect, useState, useCallback } from 'react' import { useTranslations } from 'next-intl' type ThemeToggleProps = { - variant?: 'button' | 'sidebar' + /** 'ghost' drops the border and fill, for minimal bars like the photo gallery */ + variant?: 'button' | 'sidebar' | 'ghost' collapsed?: boolean } @@ -151,14 +152,19 @@ export default function ThemeToggle({ variant = 'button', collapsed = false }: T ) } + const buttonClass = variant === 'ghost' + ? 'p-2 rounded-md text-muted-foreground hover:text-foreground transition-colors' + : 'p-2 rounded-lg border border-border bg-background hover:bg-accent transition-colors shadow-sm' + const iconClass = variant === 'ghost' ? 'h-4 w-4' : 'h-5 w-5 text-foreground' + // Avoid hydration mismatch if (!mounted) { return ( ) } @@ -166,14 +172,14 @@ export default function ThemeToggle({ variant = 'button', collapsed = false }: T return ( ) diff --git a/src/hooks/useAlbumGallery.ts b/src/hooks/useAlbumGallery.ts new file mode 100644 index 00000000..2baa56a5 --- /dev/null +++ b/src/hooks/useAlbumGallery.ts @@ -0,0 +1,211 @@ +'use client' + +import { useState, useEffect, useCallback, useRef, type RefObject } from 'react' +import type { GalleryPhoto } from '@/components/PhotoGrid' +import { apiFetch } from '@/lib/api-client' +import { logError } from '@/lib/logging' + +/** Matches the server page size — one request per grid page. */ +const PHOTO_PAGE_SIZE = 200 + +interface GalleryAlbum { + id: string + name: string + photoCount: number + coverPhotoId: string | null + contentToken: string | null +} + +export type PhotoZipScope = 'selection' | 'album' | 'project' + +interface UseAlbumGalleryOptions { + /** Omit when the viewer has no album access; the gallery then stays empty */ + projectId?: string + /** Share bearer token; omit for admin sessions (uses apiFetch instead) */ + shareToken?: string + /** Reports the album count once albums load */ + onAlbumCount?: (count: number) => void + /** Scroll container of the photo grid, so pages are prefetched before they come into view */ + scrollRootRef?: RefObject +} + +/** Hands a URL to the browser as a download without navigating away. */ +function triggerDownload(url: string) { + const a = document.createElement('a') + a.href = url + a.download = '' + a.rel = 'noopener' + a.style.display = 'none' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) +} + +/** + * Client-facing album data: album list, the paged photo grid of the open + * album, photo URLs and zip downloads. Shared by the share page photo section + * and the photo delivery gallery (and their admin previews). + */ +export function useAlbumGallery({ projectId, shareToken, onAlbumCount, scrollRootRef }: UseAlbumGalleryOptions) { + const [albums, setAlbums] = useState([]) + const [albumsLoading, setAlbumsLoading] = useState(true) + const [selectedAlbum, setSelectedAlbum] = useState(null) + + const [photos, setPhotos] = useState([]) + const [contentToken, setContentToken] = useState(null) + const [photosLoading, setPhotosLoading] = useState(false) + const [totalPhotos, setTotalPhotos] = useState(0) + const [downloading, setDownloading] = useState(false) + const loadingPageRef = useRef(false) + // Bumped on every album change: requests from an earlier album neither land + // nor release the loading lock the new album's first page now holds + const generationRef = useRef(0) + const sentinelRef = useRef(null) + + // Share sessions authenticate with the share bearer token; admin preview + // sessions fall back to apiFetch (admin access token + refresh handling) + const doFetch = useCallback((url: string, init?: RequestInit): Promise => { + if (shareToken) { + return fetch(url, { + ...init, + headers: { ...(init?.headers as Record | undefined), Authorization: `Bearer ${shareToken}` }, + }) + } + return apiFetch(url, init) + }, [shareToken]) + + const fetchAlbums = useCallback(async () => { + if (!projectId) { + setAlbums([]) + setAlbumsLoading(false) + return + } + try { + const res = await doFetch(`/api/projects/${projectId}/photo-albums`) + if (res.ok) { + const data = await res.json() + setAlbums(data.albums || []) + onAlbumCount?.((data.albums || []).length) + } + } catch (error) { + logError('Error fetching photo albums:', error) + } finally { + setAlbumsLoading(false) + } + }, [projectId, doFetch, onAlbumCount]) + + const fetchPhotoPage = useCallback(async (albumId: string, offset: number) => { + if (loadingPageRef.current) return + loadingPageRef.current = true + const generation = generationRef.current + if (offset === 0) setPhotosLoading(true) + try { + const res = await doFetch( + `/api/projects/${projectId}/photo-albums/${albumId}/photos?offset=${offset}&limit=${PHOTO_PAGE_SIZE}` + ) + if (!res.ok) return + const data = await res.json() + // Drop a page that landed after the viewer moved to another album + if (generation !== generationRef.current) return + setContentToken(data.contentToken || null) + setTotalPhotos(data.total || 0) + setPhotos(prev => (offset === 0 ? data.photos || [] : [...prev, ...(data.photos || [])])) + } catch (error) { + logError('Error fetching photos:', error) + } finally { + if (generation === generationRef.current) { + loadingPageRef.current = false + if (offset === 0) setPhotosLoading(false) + } + } + }, [projectId, doFetch]) + + useEffect(() => { + fetchAlbums() + }, [fetchAlbums]) + + useEffect(() => { + generationRef.current += 1 + loadingPageRef.current = false + setPhotos([]) + setTotalPhotos(0) + if (selectedAlbum) { + fetchPhotoPage(selectedAlbum.id, 0) + } else { + setContentToken(null) + setPhotosLoading(false) + } + }, [selectedAlbum, fetchPhotoPage]) + + // Pull the next page as the sentinel nears the viewport. Re-running on + // photos.length re-observes an already-visible sentinel, so a page that was + // skipped while another was in flight is picked straight back up. + useEffect(() => { + const node = sentinelRef.current + if (!node || !selectedAlbum || photos.length >= totalPhotos) return + const observer = new IntersectionObserver( + entries => { + if (entries[0]?.isIntersecting) fetchPhotoPage(selectedAlbum.id, photos.length) + }, + // The grid scrolls inside its own container, which clips the sentinel + // for a viewport root: observe against that container instead + { root: scrollRootRef?.current ?? null, rootMargin: '600px' } + ) + observer.observe(node) + return () => observer.disconnect() + }, [selectedAlbum, photos.length, totalPhotos, fetchPhotoPage, scrollRootRef]) + + /** Fetch the next page ahead of time (the lightbox pages past the loaded grid) */ + const loadMore = useCallback(() => { + if (selectedAlbum && photos.length < totalPhotos) fetchPhotoPage(selectedAlbum.id, photos.length) + }, [selectedAlbum, photos.length, totalPhotos, fetchPhotoPage]) + + const buildPhotoUrl = useCallback((photoId: string, variant: 'thumb' | 'full') => { + return `/api/content/photo/${contentToken}?photoId=${photoId}&variant=${variant}` + }, [contentToken]) + + const downloadPhoto = useCallback((photoId: string) => { + triggerDownload(`${buildPhotoUrl(photoId, 'full')}&download=true`) + }, [buildPhotoUrl]) + + const downloadZip = useCallback(async (scope: PhotoZipScope, photoIds: string[] = []) => { + setDownloading(true) + try { + const body = + scope === 'selection' && selectedAlbum + ? { scope, albumId: selectedAlbum.id, photoIds } + : scope === 'album' && selectedAlbum + ? { scope, albumId: selectedAlbum.id } + : { scope: 'project' as const } + + const res = await doFetch(`/api/projects/${projectId}/photos/download-zip-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) return + const { url } = await res.json() + triggerDownload(url) + } catch (error) { + logError('Error downloading photos:', error) + } finally { + setDownloading(false) + } + }, [projectId, doFetch, selectedAlbum]) + + return { + albums, + albumsLoading, + selectedAlbum, + setSelectedAlbum, + photos, + totalPhotos, + photosLoading, + sentinelRef, + loadMore, + buildPhotoUrl, + downloadPhoto, + downloadZip, + downloading, + } +} diff --git a/src/lib/comment-helpers.ts b/src/lib/comment-helpers.ts index c0b31757..18244293 100644 --- a/src/lib/comment-helpers.ts +++ b/src/lib/comment-helpers.ts @@ -175,6 +175,7 @@ export async function handleCommentNotifications(params: { title: true, slug: true, clientNotificationSchedule: true, + photoOnlyShare: true, } }) @@ -296,7 +297,11 @@ export async function handleCommentNotifications(params: { // A single queue entry has both sentToAdmins/sentToClients flags, // so we only queue once when either (or both) sides are non-IMMEDIATE. - const clientImmediate = clientSchedule === 'IMMEDIATE' + // Photo-only links never show videos, so recipients get no video feedback emails: + // the client side counts as handled from the start + const clientsMuted = project.photoOnlyShare + const clientImmediate = clientSchedule === 'IMMEDIATE' && !clientsMuted + const clientHandled = clientImmediate || clientsMuted const adminImmediate = adminSchedule === 'IMMEDIATE' if (clientImmediate) { @@ -310,11 +315,11 @@ export async function handleCommentNotifications(params: { // Queue once if either side needs batched delivery. // Pre-mark sides that were already sent immediately so workers don't re-process them. - if (!clientImmediate || !adminImmediate) { + if (!clientHandled || !adminImmediate) { logMessage(`[COMMENT-NOTIFICATION] Queuing for batched delivery (admin: ${adminSchedule}, client: ${clientSchedule})...`) await queueNotification(context, { admins: adminImmediate, - clients: clientImmediate, + clients: clientHandled, }) } } catch (emailError) { diff --git a/src/lib/email.ts b/src/lib/email.ts index 4897389c..00f5e161 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1217,6 +1217,7 @@ export async function sendProjectGeneralNotificationEmail({ projectDescription, shareUrl, readyVideos = [], + photoAlbums = [], isPasswordProtected = false, unsubscribeUrl, locale: localeOverride, @@ -1227,6 +1228,8 @@ export async function sendProjectGeneralNotificationEmail({ projectDescription: string shareUrl: string readyVideos?: Array<{ name: string; versionLabel: string }> + /** Album names listed as deliverables when the link delivers photos only */ + photoAlbums?: string[] isPasswordProtected?: boolean unsubscribeUrl?: string locale?: string @@ -1242,7 +1245,7 @@ export async function sendProjectGeneralNotificationEmail({ const template = await getEmailTemplate('PROJECT_GENERAL', locale) const readyToViewLabel = emailMessages.common?.readyToView || 'Ready to view' - const videoListHtml = readyVideos.length > 0 ? ` + const videoListHtml = readyVideos.length > 0 || photoAlbums.length > 0 ? `
${readyToViewLabel}
${readyVideos.map(v => ` @@ -1250,6 +1253,11 @@ export async function sendProjectGeneralNotificationEmail({ • ${escapeHtml(v.name)} ${escapeHtml(v.versionLabel)}
`).join('')} + ${photoAlbums.map(name => ` +
+ • ${escapeHtml(name)} +
+ `).join('')}
` : '' diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index dfa9c990..6ebe0ac8 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -19,7 +19,7 @@ interface NotificationContext { } interface ApprovalNotificationContext { - project: { id: string; title: string; slug: string; clientNotificationSchedule: string; watermarkEnabled?: boolean } + project: { id: string; title: string; slug: string; clientNotificationSchedule: string; watermarkEnabled?: boolean; photoOnlyShare?: boolean } video?: { id: string; name: string; versionLabel?: string | null } approvedVideos?: Array<{ id: string; name: string }> approved: boolean // true = approved, false = unapproved @@ -260,8 +260,9 @@ async function sendApprovalImmediately(context: ApprovalNotificationContext) { }) // Send to clients ONLY if complete project approval (all videos approved) - // Don't send for partial approvals - client knows they just clicked approve - if (recipients.length > 0 && isComplete && approved) { + // Don't send for partial approvals - client knows they just clicked approve. + // Photo-only links keep videos off the client side, approval emails included. + if (recipients.length > 0 && isComplete && approved && !project.photoOnlyShare) { logMessage(`[IMMEDIATE→CLIENT] Sending complete project approval to ${recipients.length} recipient(s)`) const emailPromises = recipients.map(async (recipient) => { @@ -490,6 +491,7 @@ export async function flushPendingClientNotifications(projectId: string): Promis id: true, title: true, slug: true, + photoOnlyShare: true, notificationQueue: { where: { sentToClients: false, @@ -505,6 +507,16 @@ export async function flushPendingClientNotifications(projectId: string): Promis return } + // Photo-only links never show videos: drop queued video feedback instead of flushing it + if (project.photoOnlyShare) { + await prisma.notificationQueue.updateMany({ + where: { id: { in: project.notificationQueue.map(n => n.id) } }, + data: { sentToClients: true, clientSentAt: new Date() }, + }) + logMessage(`[FLUSH-CLIENT] Photo-only delivery, dropped ${project.notificationQueue.length} pending notification(s) for project ${projectId}`) + return + } + const redis = getRedis() const validNotifications = [] const cancelledIds: string[] = [] diff --git a/src/lib/s3-upload-auth.ts b/src/lib/s3-upload-auth.ts index cfc344d8..5d038cfe 100644 --- a/src/lib/s3-upload-auth.ts +++ b/src/lib/s3-upload-auth.ts @@ -142,9 +142,9 @@ export async function verifyS3UploadAccess( if (requireUploadPermission) { const project = await prisma.project.findUnique({ where: { id: sharePayload.projectId }, - select: { allowClientAssetUpload: true }, + select: { allowClientAssetUpload: true, photoOnlyShare: true }, }) - if (!project?.allowClientAssetUpload) { + if (!project?.allowClientAssetUpload || project.photoOnlyShare) { return { errorResponse: NextResponse.json({ error: 'File attachments are not enabled for this project' }, { status: 403 }) } } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index e2f5b24c..24141243 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -300,6 +300,9 @@ export const updateProjectSchema = z.object({ // Reverse share allowReverseShare: z.boolean().optional(), + // Photo-only delivery + photoOnlyShare: z.boolean().optional(), + // Approval settings clientCanApprove: z.boolean().optional(), diff --git a/src/locales/de.json b/src/locales/de.json index 8914f118..78351557 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -393,6 +393,7 @@ "sendingNotification": "Wird gesendet...", "notifyAllVideos": "Dies sendet eine E-Mail an den Kunden mit Zugang zu allen fertigen Videos in diesem Projekt.", "notifySpecificVideo": "Dies sendet eine E-Mail an den Kunden mit einem Link zur ausgewählten Videoversion.", + "notifyPhotoGallery": "Dies sendet eine E-Mail an den Kunden mit einem Link zur Fotogalerie.", "selectVideoAndVersion": "Bitte ein Video und eine Version auswählen", "sendingNotificationProgress": "Benachrichtigung wird gesendet...", "notificationSentSuccessfully": "Benachrichtigung erfolgreich gesendet!", @@ -518,6 +519,8 @@ "allowClientFileAttachmentsDescription": "Kunden erlauben, Dateien (Bilder, Dokumente, Audio) an ihre Kommentare anzuhängen", "allowReverseShare": "Datei-Einreichungen erlauben", "allowReverseShareDescription": "Kunden erlauben, Dateien direkt in dieses Projekt hochzuladen, ohne sie an einen Kommentar anzuhängen", + "photoOnlyShare": "Nur Fotos ausliefern", + "photoOnlyShareDescription": "Der Share-Link wird zur Fotogalerie. Videos bleiben im Adminbereich: Kunden können sie weder ansehen, kommentieren, freigeben noch herunterladen.", "clientUploads": "Kunden-Uploads", "noClientUploads": "Es wurden noch keine Dateien von Kunden hochgeladen.", "confirmDeleteUpload": "Diesen Upload löschen? Dies kann nicht rückgängig gemacht werden.", @@ -629,6 +632,9 @@ "downloadSelected": "Herunterladen ({count})", "downloadAlbum": "Album herunterladen", "downloadAllAlbums": "Alle Alben herunterladen ({count})", + "viewGallery": "Galerie ansehen", + "selectedCount": "{count, plural, =1 {1 ausgewählt} other {# ausgewählt}}", + "galleryEmpty": "Die Fotos erscheinen hier, sobald sie bereit sind.", "photoAlbums": "Fotoalben", "confirmDeleteAlbum": "Dieses Album und alle Fotos löschen? Dies kann nicht rückgängig gemacht werden.", "confirmDeletePhoto": "Dieses Foto löschen? Dies kann nicht rückgängig gemacht werden.", diff --git a/src/locales/en.json b/src/locales/en.json index c6f5d235..6f553c23 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -393,6 +393,7 @@ "sendingNotification": "Sending...", "notifyAllVideos": "This will send an email to the client with access to all ready videos in this project.", "notifySpecificVideo": "This will send an email to the client with a link to view the selected video version.", + "notifyPhotoGallery": "This will send an email to the client with a link to the photo gallery.", "selectVideoAndVersion": "Please select a video and version", "sendingNotificationProgress": "Sending notification...", "notificationSentSuccessfully": "Notification sent successfully!", @@ -518,6 +519,8 @@ "allowClientFileAttachmentsDescription": "Allow clients to attach files (images, documents, audio) to their comments", "allowReverseShare": "Allow File Submissions", "allowReverseShareDescription": "Allow clients to upload files directly to this project without attaching them to a comment", + "photoOnlyShare": "Photo-Only Delivery", + "photoOnlyShareDescription": "Turn the share link into a photo gallery. Videos stay in the admin area: recipients can't view, comment on, approve or download them.", "clientUploads": "Client Uploads", "noClientUploads": "No files have been uploaded by clients yet.", "confirmDeleteUpload": "Delete this upload? This cannot be undone.", @@ -629,6 +632,9 @@ "downloadSelected": "Download ({count})", "downloadAlbum": "Download Album", "downloadAllAlbums": "Download All Albums ({count})", + "viewGallery": "View Gallery", + "selectedCount": "{count, plural, =1 {1 selected} other {# selected}}", + "galleryEmpty": "Photos will appear here once they're ready.", "photoAlbums": "Photo Albums", "confirmDeleteAlbum": "Delete this album and all its photos? This cannot be undone.", "confirmDeletePhoto": "Delete this photo? This cannot be undone.", diff --git a/src/locales/nl.json b/src/locales/nl.json index 7a2ea52c..5ef58a01 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -374,6 +374,7 @@ "sendingNotification": "Versturen...", "notifyAllVideos": "Dit verstuurt een e-mail naar de klant met toegang tot alle gereedstaande video's in dit project.", "notifySpecificVideo": "Dit verstuurt een e-mail naar de klant met een link naar de geselecteerde videoversie.", + "notifyPhotoGallery": "Dit verstuurt een e-mail naar de klant met een link naar de fotogalerij.", "selectVideoAndVersion": "Selecteer een video en versie", "sendingNotificationProgress": "Notificatie wordt verzonden...", "notificationSentSuccessfully": "Notificatie succesvol verzonden!", @@ -491,6 +492,8 @@ "allowClientFileAttachmentsDescription": "Sta klanten toe bestanden (afbeeldingen, documenten, audio) aan hun reacties toe te voegen", "allowReverseShare": "Bestanden Indienen Toestaan", "allowReverseShareDescription": "Sta klanten toe bestanden rechtstreeks naar dit project te uploaden zonder ze aan een reactie te koppelen", + "photoOnlyShare": "Alleen Foto's Leveren", + "photoOnlyShareDescription": "De deellink wordt een fotogalerij. Video's blijven in de beheeromgeving: klanten kunnen ze niet bekijken, becommentariëren, goedkeuren of downloaden.", "clientUploads": "Klant Uploads", "noClientUploads": "Er zijn nog geen bestanden door klanten geüpload.", "confirmDeleteUpload": "Deze upload verwijderen? Dit kan niet ongedaan worden gemaakt.", @@ -601,6 +604,9 @@ "downloadSelected": "Downloaden ({count})", "downloadAlbum": "Album Downloaden", "downloadAllAlbums": "Alle albums downloaden ({count})", + "viewGallery": "Galerij Bekijken", + "selectedCount": "{count, plural, =1 {1 geselecteerd} other {# geselecteerd}}", + "galleryEmpty": "De foto's verschijnen hier zodra ze klaar zijn.", "photoAlbums": "Fotoalbums", "confirmDeleteAlbum": "Dit album en alle foto's verwijderen? Dit kan niet ongedaan worden gemaakt.", "confirmDeletePhoto": "Deze foto verwijderen? Dit kan niet ongedaan worden gemaakt.", diff --git a/src/pages/api/uploads/[[...path]].ts b/src/pages/api/uploads/[[...path]].ts index d9088d07..42310fe5 100644 --- a/src/pages/api/uploads/[[...path]].ts +++ b/src/pages/api/uploads/[[...path]].ts @@ -137,10 +137,10 @@ const tusServer: Server = new Server({ const project = await prisma.project.findUnique({ where: { id: sharePayload.projectId }, - select: { allowClientAssetUpload: true }, + select: { allowClientAssetUpload: true, photoOnlyShare: true }, }) - if (!project?.allowClientAssetUpload) { + if (!project?.allowClientAssetUpload || project.photoOnlyShare) { throw { status_code: 403, body: 'File attachments are not enabled for this project' } } } diff --git a/src/worker/client-notifications.ts b/src/worker/client-notifications.ts index 2fb1183f..7f9ffd8e 100644 --- a/src/worker/client-notifications.ts +++ b/src/worker/client-notifications.ts @@ -40,6 +40,7 @@ export async function processClientNotifications() { clientNotificationTime: true, clientNotificationDay: true, lastClientNotificationSent: true, + photoOnlyShare: true, notificationQueue: { where: { sentToClients: false, @@ -62,6 +63,16 @@ export async function processClientNotifications() { const pending = project.notificationQueue.length logMessage(`[CLIENT] "${project.title}": ${project.clientNotificationSchedule} at ${project.clientNotificationTime || 'N/A'} (${pending} pending)`) + // Photo-only links never show videos: drop video feedback queued before the switch + if (project.photoOnlyShare) { + await prisma.notificationQueue.updateMany({ + where: { id: { in: project.notificationQueue.map(n => n.id) } }, + data: { sentToClients: true, clientSentAt: now }, + }) + logMessage('[CLIENT] Skip - photo-only delivery, pending video notifications dropped') + continue + } + if (project.clientNotificationSchedule === 'IMMEDIATE') { logMessage('[CLIENT] Skip - IMMEDIATE notifications sent instantly') continue