Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/wiki/Admin-Settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions docs/wiki/Client-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/Features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/Usage-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "Project" ADD COLUMN "photoOnlyShare" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/app/admin/projects/[id]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface Project {
allowPhotoDownload: boolean
allowClientAssetUpload: boolean
allowReverseShare: boolean
photoOnlyShare: boolean
clientCanApprove: boolean
usePreviewForApprovedPlayback: boolean
showClientTutorial: boolean
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -307,6 +310,7 @@ export default function ProjectSettingsPage() {
allowPhotoDownload,
allowClientAssetUpload,
allowReverseShare,
photoOnlyShare,
clientCanApprove,
usePreviewForApprovedPlayback,
showClientTutorial,
Expand Down Expand Up @@ -727,6 +731,23 @@ export default function ProjectSettingsPage() {
// Client Share Page content
const clientShareContent = (
<>
{/* ── Delivery ─────────────────────────────────────────────── */}
<div className="space-y-3 border p-4 rounded-lg bg-muted/30">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5 flex-1">
<Label htmlFor="photoOnlyShare">{t('photoOnlyShare')}</Label>
<p className="text-xs text-muted-foreground">
{t('photoOnlyShareDescription')}
</p>
</div>
<Switch
id="photoOnlyShare"
checked={photoOnlyShare}
onCheckedChange={setPhotoOnlyShare}
/>
</div>
</div>

{/* ── Approval & Workflow ─────────────────────────────────── */}
<div className="space-y-3 border p-4 rounded-lg bg-muted/30">
<div className="flex items-center justify-between gap-4">
Expand Down
36 changes: 35 additions & 1 deletion src/app/admin/projects/[id]/share/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -45,6 +46,7 @@ export default function AdminSharePage() {
const [_companyName, setCompanyName] = useState('Studio')
const [viewMode, setViewMode] = useState<ShareViewMode>('grid')
const [albumCount, setAlbumCount] = useState(0)
const [hasPhotos, setHasPhotos] = useState(false)

useEffect(() => { setViewMode(loadShareViewMode()) }, [])
const [defaultQuality, setDefaultQuality] = useState<'720p' | '1080p' | '2160p'>('720p')
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 (
<PhotoDeliveryGallery
projectId={id}
title={project.title}
description={project.description}
allowPhotoDownload={project.allowPhotoDownload ?? true}
showLanguageToggle={false}
leadingActions={
<Button
variant="outline"
size="sm"
onClick={() => router.push(projectUrl)}
title={t('backToProject')}
>
<ArrowLeft className="w-4 h-4 sm:mr-2" />
<span className="hidden sm:inline">{t('backToProject')}</span>
</Button>
}
/>
)
}

// Show thumbnail grid when in grid view (same as public share layout)
if (viewState === 'grid') {
return (
Expand Down
13 changes: 12 additions & 1 deletion src/app/api/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
authMode: true,
companyName: true,
hideFeedback: true,
photoOnlyShare: true,
guestMode: true,
}
})
Expand All @@ -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([])
}

Expand Down Expand Up @@ -224,6 +226,7 @@ export async function POST(request: NextRequest) {
id: true,
sharePassword: true,
authMode: true,
photoOnlyShare: true,
}
})

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/content/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/app/api/content/zip/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/projects/[id]/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions src/app/api/projects/[id]/notify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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: {
Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/projects/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions src/app/api/share/[token]/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export async function GET(
authMode: true,
companyName: true,
hideFeedback: true,
photoOnlyShare: true,
guestMode: true,
}
})
Expand All @@ -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([])
}

Expand Down
9 changes: 9 additions & 0 deletions src/app/api/share/[token]/download-all-token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function POST(
sharePassword: true,
authMode: true,
allowAssetDownload: true,
photoOnlyShare: true,
title: true,
},
})
Expand Down Expand Up @@ -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: {
Expand Down
7 changes: 6 additions & 1 deletion src/app/api/share/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function GET(
guestMode: true,
guestLatestOnly: true,
guestShowPhotos: true,
photoOnlyShare: true,
sharePassword: true,
authMode: true,
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading