diff --git a/src/app/api/ai-tagging/describe-bulk/route.ts b/src/app/api/ai-tagging/describe-bulk/route.ts index 35fc2ba..26dac78 100644 --- a/src/app/api/ai-tagging/describe-bulk/route.ts +++ b/src/app/api/ai-tagging/describe-bulk/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueBulkJobs } from '@/lib/ai-jobs' const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) @@ -19,7 +19,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) } - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS) diff --git a/src/app/api/ai-tagging/describe/route.ts b/src/app/api/ai-tagging/describe/route.ts index ba6e508..d6268e0 100644 --- a/src/app/api/ai-tagging/describe/route.ts +++ b/src/app/api/ai-tagging/describe/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { } const libraryId = itemKey.split(':')[0] - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const jobId = enqueueJob(itemKey, 'describe', libraryId) diff --git a/src/app/api/ai-tagging/extract-text-bulk/route.ts b/src/app/api/ai-tagging/extract-text-bulk/route.ts index 3004abd..e0f5429 100644 --- a/src/app/api/ai-tagging/extract-text-bulk/route.ts +++ b/src/app/api/ai-tagging/extract-text-bulk/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueBulkJobs } from '@/lib/ai-jobs' const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif']) @@ -17,7 +17,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) } - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS) diff --git a/src/app/api/ai-tagging/extract-text/route.ts b/src/app/api/ai-tagging/extract-text/route.ts index 857f710..1be8365 100644 --- a/src/app/api/ai-tagging/extract-text/route.ts +++ b/src/app/api/ai-tagging/extract-text/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { } const libraryId = itemKey.split(':')[0] - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const payload: Record = {} diff --git a/src/app/api/ai-tagging/fields/route.ts b/src/app/api/ai-tagging/fields/route.ts index db65d63..b173671 100644 --- a/src/app/api/ai-tagging/fields/route.ts +++ b/src/app/api/ai-tagging/fields/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger' export async function GET(request: NextRequest) { @@ -35,7 +35,7 @@ export async function PATCH(request: NextRequest) { } const libraryId = itemKey.split(':')[0] - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth if (extractedText !== undefined) { diff --git a/src/app/api/ai-tagging/route.ts b/src/app/api/ai-tagging/route.ts index 428b701..7f24047 100644 --- a/src/app/api/ai-tagging/route.ts +++ b/src/app/api/ai-tagging/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { } const libraryId = itemKey.split(':')[0] - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const jobId = enqueueJob(itemKey, 'tag', libraryId) diff --git a/src/app/api/ai-tagging/translate-bulk/route.ts b/src/app/api/ai-tagging/translate-bulk/route.ts index 7b10fa1..20ac605 100644 --- a/src/app/api/ai-tagging/translate-bulk/route.ts +++ b/src/app/api/ai-tagging/translate-bulk/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' import { getDb } from '@/lib/db' @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'libraryId is required' }, { status: 400 }) } - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const db = getDb() diff --git a/src/app/api/ai-tagging/translate/route.ts b/src/app/api/ai-tagging/translate/route.ts index 27ee8dd..9afeb2a 100644 --- a/src/app/api/ai-tagging/translate/route.ts +++ b/src/app/api/ai-tagging/translate/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryWriteAccess } from '@/lib/auth' import { enqueueJob } from '@/lib/ai-jobs' export async function POST(request: NextRequest) { @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { } const libraryId = itemKey.split(':')[0] - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined) diff --git a/src/app/api/libraries/route.ts b/src/app/api/libraries/route.ts index c09eec1..c4eb58d 100644 --- a/src/app/api/libraries/route.ts +++ b/src/app/api/libraries/route.ts @@ -12,7 +12,7 @@ export async function GET(request: NextRequest) { try { const libraries = session.role === 'admin' - ? getLibraries() + ? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' })) : getLibrariesForUser(session.userId, session.role) return NextResponse.json(libraries) } catch (err) { diff --git a/src/app/api/tags/assignments/route.ts b/src/app/api/tags/assignments/route.ts index 1b6ddb5..0f528f5 100644 --- a/src/app/api/tags/assignments/route.ts +++ b/src/app/api/tags/assignments/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags' -import { requireLibraryAccess } from '@/lib/auth' +import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' function extractLibraryId(itemKey: string): string | null { const colonIdx = itemKey.indexOf(':') @@ -38,7 +38,7 @@ export async function POST(request: NextRequest) { if (!libraryId) { return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) } - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth addTagToItem(itemKey, tagId) @@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) { if (!libraryId) { return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) } - const auth = await requireLibraryAccess(request, libraryId) + const auth = await requireLibraryWriteAccess(request, libraryId) if (auth instanceof NextResponse) return auth removeTagFromItem(itemKey, tagId) diff --git a/src/app/api/users/[id]/permissions/route.ts b/src/app/api/users/[id]/permissions/route.ts index 7c3f021..ae82a67 100644 --- a/src/app/api/users/[id]/permissions/route.ts +++ b/src/app/api/users/[id]/permissions/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { requireAdmin } from '@/lib/auth' -import { getUserById, getPermittedLibraryIds, setLibraryPermissions } from '@/lib/users' +import { getUserById, getLibraryPermissions, setLibraryPermissions, type LibraryPermission } from '@/lib/users' import { getLibraries } from '@/lib/libraries' export async function GET( @@ -17,8 +17,8 @@ export async function GET( return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - const libraryIds = getPermittedLibraryIds(id) - return NextResponse.json({ libraryIds }) + const permissions = getLibraryPermissions(id) + return NextResponse.json({ permissions }) } export async function PUT( @@ -35,24 +35,41 @@ export async function PUT( return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - let body: { libraryIds?: unknown } + let body: { permissions?: unknown } try { body = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) { - return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 }) + if (!Array.isArray(body.permissions)) { + return NextResponse.json({ error: 'permissions must be an array' }, { status: 400 }) } + const validAccessLevels = new Set(['read', 'write']) + for (const item of body.permissions) { + if ( + typeof item !== 'object' || + item === null || + typeof (item as Record).libraryId !== 'string' || + !validAccessLevels.has((item as Record).accessLevel as string) + ) { + return NextResponse.json( + { error: 'Each permission must have libraryId (string) and accessLevel ("read" | "write")' }, + { status: 400 } + ) + } + } + + const permissions = body.permissions as LibraryPermission[] + const allLibraries = getLibraries() const validIds = new Set(allLibraries.map((l) => l.id)) - const invalid = body.libraryIds.filter((id) => !validIds.has(id)) + const invalid = permissions.filter((p) => !validIds.has(p.libraryId)).map((p) => p.libraryId) if (invalid.length > 0) { return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 }) } - setLibraryPermissions(id, body.libraryIds) + setLibraryPermissions(id, permissions) return new NextResponse(null, { status: 204 }) } diff --git a/src/app/library/[id]/page.tsx b/src/app/library/[id]/page.tsx index 3ba1d1c..f5aaf36 100644 --- a/src/app/library/[id]/page.tsx +++ b/src/app/library/[id]/page.tsx @@ -1,7 +1,7 @@ import { getLibrary } from '@/lib/libraries' import { notFound, redirect } from 'next/navigation' import { getServerSession } from '@/lib/auth' -import { getPermittedLibraryIds } from '@/lib/users' +import { getLibraryAccessLevel } from '@/lib/users' import GamesView from '@/components/games/GamesView' import MixedView from '@/components/mixed/MixedView' import MoviesView from '@/components/movies/MoviesView' @@ -23,9 +23,11 @@ export default async function LibraryPage({ params, searchParams }: Props) { const library = getLibrary(id) if (!library) notFound() + let readOnly = false if (session.role !== 'admin') { - const permitted = getPermittedLibraryIds(session.userId) - if (!permitted.includes(id)) notFound() + const accessLevel = getLibraryAccessLevel(session.userId, id) + if (!accessLevel) notFound() + readOnly = accessLevel === 'read' } return ( @@ -52,10 +54,10 @@ export default async function LibraryPage({ params, searchParams }: Props) { )} - {library.type === 'games' && } - {library.type === 'mixed' && } - {library.type === 'movies' && } - {library.type === 'tv' && } + {library.type === 'games' && } + {library.type === 'mixed' && } + {library.type === 'movies' && } + {library.type === 'tv' && } ) } diff --git a/src/app/manage/users/page.tsx b/src/app/manage/users/page.tsx index 3a5790e..3bd411f 100644 --- a/src/app/manage/users/page.tsx +++ b/src/app/manage/users/page.tsx @@ -216,32 +216,39 @@ function UserRow({ // ─── Permissions Panel ──────────────────────────────────────────────────────── +type AccessLevel = 'none' | 'read' | 'write' + function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) { - const [permitted, setPermitted] = useState([]) + const [levels, setLevels] = useState>({}) const [saving, setSaving] = useState(false) const [loaded, setLoaded] = useState(false) useEffect(() => { fetch(`/api/users/${encodeURIComponent(userId)}/permissions`) .then((r) => r.json()) - .then((data: { libraryIds: string[] }) => { - setPermitted(data.libraryIds) + .then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => { + const map: Record = {} + for (const p of data.permissions) { + map[p.libraryId] = p.accessLevel + } + setLevels(map) setLoaded(true) }) }, [userId]) - const toggle = (libraryId: string) => { - setPermitted((prev) => - prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId] - ) + const setLevel = (libraryId: string, level: AccessLevel) => { + setLevels((prev) => ({ ...prev, [libraryId]: level })) } const save = async () => { setSaving(true) + const permissions = Object.entries(levels) + .filter(([, level]) => level !== 'none') + .map(([libraryId, accessLevel]) => ({ libraryId, accessLevel })) await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ libraryIds: permitted }), + body: JSON.stringify({ permissions }), }) setSaving(false) } @@ -265,23 +272,40 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li {libraries.length === 0 ? (

No libraries configured.

) : ( -
- {libraries.map((lib) => ( - - ))} +
+ {libraries.map((lib) => { + const current = levels[lib.id] ?? 'none' + return ( +
+
+ + {lib.name} + + + ({lib.type}) + +
+
+ {(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => ( + + ))} +
+
+ ) + })}
)}
)} - + } {/* AI description (read-only) */} @@ -525,49 +528,13 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {showTagPanel && ( -
e.stopPropagation()} - > - {/* Panel header — ‹ hide | ✕ close */} -
- - -
- - {/* Tags */} -
-

- Tags -

- { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} - refreshKey={tagRefreshKey} - /> -
-
+ setShowTagPanel(false)} + onClose={onClose} + onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} + readOnly={readOnly} + /> )} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 24e7921..041b9bc 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -58,9 +58,10 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) { interface Props { libraryId: string + readOnly?: boolean } -export default function GamesView({ libraryId }: Props) { +export default function GamesView({ libraryId, readOnly }: Props) { const [items, setItems] = useState<(Game | GameSeries)[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -237,6 +238,7 @@ export default function GamesView({ libraryId }: Props) { { setSelected(null); setSelectedGameIndex(null) }} onPrev={selectedGameIndex !== null && selectedGameIndex > 0 ? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) } diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index 12316b2..35900ec 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef, useState, useCallback } from 'react' -import TagSelector from '@/components/tags/TagSelector' +import MediaTagPanel from '@/components/tags/MediaTagPanel' interface Props { url: string @@ -14,17 +14,14 @@ interface Props { onAiTag?: () => Promise showTags?: boolean onShowTagsChange?: (v: boolean) => void + readOnly?: boolean } -export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange }: Props) { +export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, showTags: showTagsProp, onShowTagsChange, readOnly }: Props) { const overlayRef = useRef(null) const [showTagsLocal, setShowTagsLocal] = useState(false) const showTags = showTagsProp ?? showTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal - const [aiTagging, setAiTagging] = useState(false) - const [aiTagError, setAiTagError] = useState(null) - const [tagRefreshKey, setTagRefreshKey] = useState(0) - // Text extraction state const [extractedText, setExtractedText] = useState(null) const [translatedText, setTranslatedText] = useState(null) @@ -211,22 +208,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item } } - const handleAiTag = async () => { - if (!onAiTag) return - setAiTagging(true) - setAiTagError(null) - try { - await onAiTag() - setTagRefreshKey((k) => k + 1) - onTagsChanged?.() - } catch (err) { - setAiTagError(err instanceof Error ? err.message : 'AI tagging failed') - setTimeout(() => setAiTagError(null), 4000) - } finally { - setAiTagging(false) - } - } - const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' return ( @@ -369,343 +350,271 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {showTags && ( -
e.stopPropagation()} + setShowTags(false)} + onClose={onClose} + onTagsChanged={onTagsChanged} + onAiTag={readOnly ? undefined : onAiTag} + readOnly={readOnly} > - {/* Panel header — ‹ hide | ✨ AI tag ✕ close */} -
- -
+ {/* Description section */} +
+
+

+ Description +

+