Merge pull request 'maintainability' (#30) from maintainability into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
All checks were successful
Build and Push Docker Image / build (push) Successful in 55s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/30
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
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 })
|
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
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'describe', 'mixed_file', MEDIA_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
const jobId = enqueueJob(itemKey, 'describe', libraryId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
import { enqueueBulkJobs } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.tif'])
|
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 })
|
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
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
const jobIds = enqueueBulkJobs(libraryId, dirPath ?? '', 'extract', 'mixed_file', IMAGE_EXTENSIONS)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const payload: Record<string, string> = {}
|
const payload: Record<string, string> = {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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'
|
import { getAiFields, updateExtractedText, updateAiDescription } from '@/lib/ai-tagger'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -35,7 +35,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
if (extractedText !== undefined) {
|
if (extractedText !== undefined) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
const jobId = enqueueJob(itemKey, 'tag', libraryId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
import { getDb } from '@/lib/db'
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'libraryId is required' }, { status: 400 })
|
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
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
import { enqueueJob } from '@/lib/ai-jobs'
|
import { enqueueJob } from '@/lib/ai-jobs'
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -16,7 +16,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const libraryId = itemKey.split(':')[0]
|
const libraryId = itemKey.split(':')[0]
|
||||||
const auth = await requireLibraryAccess(request, libraryId)
|
const auth = await requireLibraryWriteAccess(request, libraryId)
|
||||||
if (auth instanceof NextResponse) return auth
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
const jobId = enqueueJob(itemKey, 'translate', libraryId, sourceLanguage || undefined)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const libraries =
|
const libraries =
|
||||||
session.role === 'admin'
|
session.role === 'admin'
|
||||||
? getLibraries()
|
? getLibraries().map((l) => ({ ...l, accessLevel: 'admin' }))
|
||||||
: getLibrariesForUser(session.userId, session.role)
|
: getLibrariesForUser(session.userId, session.role)
|
||||||
return NextResponse.json(libraries)
|
return NextResponse.json(libraries)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
|
||||||
|
|
||||||
function extractLibraryId(itemKey: string): string | null {
|
function extractLibraryId(itemKey: string): string | null {
|
||||||
const colonIdx = itemKey.indexOf(':')
|
const colonIdx = itemKey.indexOf(':')
|
||||||
@@ -38,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
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
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
addTagToItem(itemKey, tagId)
|
addTagToItem(itemKey, tagId)
|
||||||
@@ -60,7 +60,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
if (!libraryId) {
|
if (!libraryId) {
|
||||||
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
|
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
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
removeTagFromItem(itemKey, tagId)
|
removeTagFromItem(itemKey, tagId)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
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'
|
import { getLibraries } from '@/lib/libraries'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -17,8 +17,8 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryIds = getPermittedLibraryIds(id)
|
const permissions = getLibraryPermissions(id)
|
||||||
return NextResponse.json({ libraryIds })
|
return NextResponse.json({ permissions })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
@@ -35,24 +35,41 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { libraryIds?: unknown }
|
let body: { permissions?: unknown }
|
||||||
try {
|
try {
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(body.libraryIds) || !body.libraryIds.every((id) => typeof id === 'string')) {
|
if (!Array.isArray(body.permissions)) {
|
||||||
return NextResponse.json({ error: 'libraryIds must be an array of strings' }, { status: 400 })
|
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<string, unknown>).libraryId !== 'string' ||
|
||||||
|
!validAccessLevels.has((item as Record<string, unknown>).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 allLibraries = getLibraries()
|
||||||
const validIds = new Set(allLibraries.map((l) => l.id))
|
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) {
|
if (invalid.length > 0) {
|
||||||
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
|
return NextResponse.json({ error: `Unknown library IDs: ${invalid.join(', ')}` }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
setLibraryPermissions(id, body.libraryIds)
|
setLibraryPermissions(id, permissions)
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getLibrary } from '@/lib/libraries'
|
import { getLibrary } from '@/lib/libraries'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { notFound, redirect } from 'next/navigation'
|
||||||
import { getServerSession } from '@/lib/auth'
|
import { getServerSession } from '@/lib/auth'
|
||||||
import { getPermittedLibraryIds } from '@/lib/users'
|
import { getLibraryAccessLevel } from '@/lib/users'
|
||||||
import GamesView from '@/components/games/GamesView'
|
import GamesView from '@/components/games/GamesView'
|
||||||
import MixedView from '@/components/mixed/MixedView'
|
import MixedView from '@/components/mixed/MixedView'
|
||||||
import MoviesView from '@/components/movies/MoviesView'
|
import MoviesView from '@/components/movies/MoviesView'
|
||||||
@@ -23,9 +23,11 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
const library = getLibrary(id)
|
const library = getLibrary(id)
|
||||||
if (!library) notFound()
|
if (!library) notFound()
|
||||||
|
|
||||||
|
let readOnly = false
|
||||||
if (session.role !== 'admin') {
|
if (session.role !== 'admin') {
|
||||||
const permitted = getPermittedLibraryIds(session.userId)
|
const accessLevel = getLibraryAccessLevel(session.userId, id)
|
||||||
if (!permitted.includes(id)) notFound()
|
if (!accessLevel) notFound()
|
||||||
|
readOnly = accessLevel === 'read'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,10 +54,10 @@ export default async function LibraryPage({ params, searchParams }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{library.type === 'games' && <GamesView libraryId={id} />}
|
{library.type === 'games' && <GamesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} />}
|
{library.type === 'mixed' && <MixedView libraryId={id} libraryName={library.name} initialPath={subpath ?? ''} readOnly={readOnly} />}
|
||||||
{library.type === 'movies' && <MoviesView libraryId={id} />}
|
{library.type === 'movies' && <MoviesView libraryId={id} readOnly={readOnly} />}
|
||||||
{library.type === 'tv' && <TvView libraryId={id} />}
|
{library.type === 'tv' && <TvView libraryId={id} readOnly={readOnly} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,32 +216,39 @@ function UserRow({
|
|||||||
|
|
||||||
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
// ─── Permissions Panel ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AccessLevel = 'none' | 'read' | 'write'
|
||||||
|
|
||||||
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Library[] }) {
|
||||||
const [permitted, setPermitted] = useState<string[]>([])
|
const [levels, setLevels] = useState<Record<string, AccessLevel>>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
fetch(`/api/users/${encodeURIComponent(userId)}/permissions`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { libraryIds: string[] }) => {
|
.then((data: { permissions: { libraryId: string; accessLevel: 'read' | 'write' }[] }) => {
|
||||||
setPermitted(data.libraryIds)
|
const map: Record<string, AccessLevel> = {}
|
||||||
|
for (const p of data.permissions) {
|
||||||
|
map[p.libraryId] = p.accessLevel
|
||||||
|
}
|
||||||
|
setLevels(map)
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
})
|
})
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const toggle = (libraryId: string) => {
|
const setLevel = (libraryId: string, level: AccessLevel) => {
|
||||||
setPermitted((prev) =>
|
setLevels((prev) => ({ ...prev, [libraryId]: level }))
|
||||||
prev.includes(libraryId) ? prev.filter((id) => id !== libraryId) : [...prev, libraryId]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
const permissions = Object.entries(levels)
|
||||||
|
.filter(([, level]) => level !== 'none')
|
||||||
|
.map(([libraryId, accessLevel]) => ({ libraryId, accessLevel }))
|
||||||
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
await fetch(`/api/users/${encodeURIComponent(userId)}/permissions`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ libraryIds: permitted }),
|
body: JSON.stringify({ permissions }),
|
||||||
})
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -265,23 +272,40 @@ function PermissionsPanel({ userId, libraries }: { userId: string; libraries: Li
|
|||||||
{libraries.length === 0 ? (
|
{libraries.length === 0 ? (
|
||||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => {
|
||||||
<label key={lib.id} className="flex items-center gap-2 cursor-pointer">
|
const current = levels[lib.id] ?? 'none'
|
||||||
<input
|
return (
|
||||||
type="checkbox"
|
<div key={lib.id} className="flex items-center justify-between gap-3">
|
||||||
checked={permitted.includes(lib.id)}
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
onChange={() => toggle(lib.id)}
|
<span className="text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
className="rounded"
|
{lib.name}
|
||||||
/>
|
</span>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-primary)' }}>
|
<span className="text-xs shrink-0" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{lib.name}
|
({lib.type})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
</div>
|
||||||
({lib.type})
|
<div
|
||||||
</span>
|
className="flex shrink-0 rounded-md overflow-hidden text-xs font-medium"
|
||||||
</label>
|
style={{ border: '1px solid var(--border)' }}
|
||||||
))}
|
>
|
||||||
|
{(['none', 'read', 'write'] as AccessLevel[]).map((lvl) => (
|
||||||
|
<button
|
||||||
|
key={lvl}
|
||||||
|
onClick={() => setLevel(lib.id, lvl)}
|
||||||
|
className="px-2.5 py-1 transition-colors capitalize"
|
||||||
|
style={{
|
||||||
|
backgroundColor: current === lvl ? 'var(--accent)' : 'transparent',
|
||||||
|
color: current === lvl ? 'var(--background)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lvl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { Game, GameFile, GamePlatform } from '@/types'
|
import type { Game, GameFile, GamePlatform } from '@/types'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||||
|
|
||||||
// Import SVG icons
|
// Import SVG icons
|
||||||
@@ -35,9 +35,10 @@ interface Props {
|
|||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onCoverUploaded?: () => void
|
onCoverUploaded?: () => void
|
||||||
onDeleted?: (gameId: string) => void
|
onDeleted?: (gameId: string) => void
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNext, onTagsChanged, onCoverUploaded, onDeleted, readOnly }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -122,6 +123,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
|
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
||||||
|
if (e.key === 'ArrowRight') { onNext?.(); return }
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
if (confirming) { setConfirming(false); return }
|
if (confirming) { setConfirming(false); return }
|
||||||
@@ -137,7 +140,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
|
}, [onClose, onPrev, onNext, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -215,7 +218,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
<div className="relative flex-shrink-0" ref={menuRef}>
|
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
@@ -267,7 +270,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI description (read-only) */}
|
{/* 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 ── */}
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
{showTagPanel && (
|
{showTagPanel && (
|
||||||
<div
|
<MediaTagPanel
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
itemKey={game.item_key!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTagPanel(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
>
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
{/* Panel header — ‹ hide | ✕ close */}
|
readOnly={readOnly}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setShowTagPanel(false)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Hide panel"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<TagSelector
|
|
||||||
itemKey={game.item_key!}
|
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
|
||||||
refreshKey={tagRefreshKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GamesView({ libraryId }: Props) {
|
export default function GamesView({ libraryId, readOnly }: Props) {
|
||||||
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -237,6 +238,7 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<GameDetailModal
|
<GameDetailModal
|
||||||
game={selected}
|
game={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
|
readOnly={readOnly}
|
||||||
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
|
onClose={() => { setSelected(null); setSelectedGameIndex(null) }}
|
||||||
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
|
onPrev={selectedGameIndex !== null && selectedGameIndex > 0
|
||||||
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
|
? () => { const g = filteredGames[selectedGameIndex - 1]; setSelected(g); setSelectedGameIndex(selectedGameIndex - 1) }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
url: string
|
url: string
|
||||||
@@ -14,17 +14,14 @@ interface Props {
|
|||||||
onAiTag?: () => Promise<void>
|
onAiTag?: () => Promise<void>
|
||||||
showTags?: boolean
|
showTags?: boolean
|
||||||
onShowTagsChange?: (v: boolean) => void
|
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<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
const showTags = showTagsProp ?? showTagsLocal
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
// Text extraction state
|
// Text extraction state
|
||||||
const [extractedText, setExtractedText] = useState<string | null>(null)
|
const [extractedText, setExtractedText] = useState<string | null>(null)
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null)
|
const [translatedText, setTranslatedText] = useState<string | null>(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'
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
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 ── */}
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
{showTags && (
|
{showTags && (
|
||||||
<div
|
<MediaTagPanel
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
itemKey={itemKey!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTags(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
onAiTag={readOnly ? undefined : onAiTag}
|
||||||
|
readOnly={readOnly}
|
||||||
>
|
>
|
||||||
{/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
|
{/* Description section */}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<button
|
<div className="flex items-center justify-between mb-2">
|
||||||
onClick={() => setShowTags(false)}
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
className={smallBtn}
|
Description
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
</p>
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Hide panel"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={handleGenerateDescription}
|
||||||
className={smallBtn}
|
disabled={generatingDesc || descPending}
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
style={{
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||||
aria-label="Close"
|
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||||
title="Close"
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||||||
|
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||||
>
|
>
|
||||||
✕
|
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editedDescription}
|
||||||
|
onChange={(e) => setEditedDescription(e.target.value)}
|
||||||
|
placeholder="No description yet…"
|
||||||
|
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
minHeight: '3.5rem',
|
||||||
|
maxHeight: '8rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editedDescription !== (aiDescription ?? '') && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setSavingDesc(true)
|
||||||
|
try {
|
||||||
|
await fetch('/api/ai-tagging/fields', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
||||||
|
})
|
||||||
|
setAiDescription(editedDescription)
|
||||||
|
} finally {
|
||||||
|
setSavingDesc(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={savingDesc}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{savingDesc ? '⟳ Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable panel content */}
|
{/* Text extraction section — only for images */}
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
|
{isImage && (
|
||||||
|
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
{/* Description section */}
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
|
||||||
{/* Heading row */}
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Description
|
Text Extraction
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateDescription}
|
onClick={() => callExtract('llm')}
|
||||||
disabled={generatingDesc || descPending}
|
disabled={extracting || extractPending}
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
}}
|
}}
|
||||||
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
aria-label="Extract text with AI"
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
title="Extract with AI (skips OCR)"
|
||||||
>
|
>
|
||||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Editable textarea */}
|
|
||||||
<textarea
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
value={editedDescription}
|
|
||||||
onChange={(e) => setEditedDescription(e.target.value)}
|
|
||||||
placeholder="No description yet…"
|
|
||||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
minHeight: '3.5rem',
|
|
||||||
maxHeight: '8rem',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{editedDescription !== (aiDescription ?? '') && (
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => callExtract('tesseract')}
|
||||||
setSavingDesc(true)
|
disabled={extracting || extractPending}
|
||||||
try {
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
await fetch('/api/ai-tagging/fields', {
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
method: 'PATCH',
|
onMouseEnter={(e) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!extracting && !extractPending) {
|
||||||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
})
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
setAiDescription(editedDescription)
|
|
||||||
} finally {
|
|
||||||
setSavingDesc(false)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={savingDesc}
|
onMouseLeave={(e) => {
|
||||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<input
|
||||||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
type="text"
|
||||||
</div>
|
value={ocrLanguageInput}
|
||||||
|
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||||
|
placeholder={defaultOcrLanguages}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
width: 120,
|
||||||
|
}}
|
||||||
|
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Text extraction section — only for images */}
|
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||||
{isImage && (
|
|
||||||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
|
||||||
{/* Heading row */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Text Extraction
|
|
||||||
</p>
|
|
||||||
{/* AI button — forces LLM, no OCR */}
|
|
||||||
<button
|
|
||||||
onClick={() => callExtract('llm')}
|
|
||||||
disabled={extracting || extractPending}
|
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
}}
|
|
||||||
aria-label="Extract text with AI"
|
|
||||||
title="Extract with AI (skips OCR)"
|
|
||||||
>
|
|
||||||
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OCR button row */}
|
{extractedText && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<div>
|
||||||
onClick={() => callExtract('tesseract')}
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
disabled={extracting || extractPending}
|
Extracted Text
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
</p>
|
||||||
style={{
|
<textarea
|
||||||
backgroundColor: 'var(--border)',
|
value={editedExtractedText}
|
||||||
color: 'var(--text-secondary)',
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
}}
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||||
onMouseEnter={(e) => {
|
style={{
|
||||||
if (!extracting && !extractPending) {
|
backgroundColor: 'var(--background)',
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
color: 'var(--text-primary)',
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
border: '1px solid var(--border)',
|
||||||
}
|
minHeight: '4rem',
|
||||||
}}
|
maxHeight: '10rem',
|
||||||
onMouseLeave={(e) => {
|
fontFamily: 'inherit',
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
}}
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
/>
|
||||||
}}
|
{editedExtractedText !== extractedText && (
|
||||||
>
|
|
||||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ocrLanguageInput}
|
|
||||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
|
||||||
placeholder={defaultOcrLanguages}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
width: 120,
|
|
||||||
}}
|
|
||||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{extractError && (
|
|
||||||
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{extractedText && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Extracted Text
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
value={editedExtractedText}
|
|
||||||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
minHeight: '4rem',
|
|
||||||
maxHeight: '10rem',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{editedExtractedText !== extractedText && (
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
setSavingText(true)
|
|
||||||
try {
|
|
||||||
await fetch('/api/ai-tagging/fields', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
|
||||||
})
|
|
||||||
setExtractedText(editedExtractedText)
|
|
||||||
} finally {
|
|
||||||
setSavingText(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={savingText}
|
|
||||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
|
||||||
>
|
|
||||||
{savingText ? '⟳ Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{translatedText && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Translation
|
|
||||||
</p>
|
|
||||||
<pre
|
|
||||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
|
||||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
{translatedText}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sourceLanguage}
|
|
||||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
|
||||||
placeholder="Source lang…"
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--background)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
width: 100,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRetranslating(true)
|
setSavingText(true)
|
||||||
setTranslatePending(false)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
await fetch('/api/ai-tagging/fields', {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||||
})
|
})
|
||||||
if (res.status === 202) {
|
setExtractedText(editedExtractedText)
|
||||||
setTranslatePending(true)
|
|
||||||
startPolling(extractedText, translatedText, aiDescription)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
|
||||||
}
|
|
||||||
const result = await res.json()
|
|
||||||
setTranslatedText(result.translatedText || null)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
} finally {
|
||||||
setRetranslating(false)
|
setSavingText(false)
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={retranslating || translatePending}
|
|
||||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
|
||||||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!retranslating && !translatePending) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!translatePending) {
|
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={savingText}
|
||||||
|
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
>
|
>
|
||||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
{savingText ? '⟳ Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags section */}
|
{translatedText && (
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
Translation
|
||||||
Tags
|
</p>
|
||||||
</p>
|
<pre
|
||||||
{onAiTag && (
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||||
<button
|
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
onClick={handleAiTag}
|
>
|
||||||
disabled={aiTagging}
|
{translatedText}
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
</pre>
|
||||||
style={{
|
</div>
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
)}
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
}}
|
<input
|
||||||
onMouseEnter={(e) => {
|
type="text"
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
value={sourceLanguage}
|
||||||
}}
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
onMouseLeave={(e) => {
|
placeholder="Source lang…"
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
}}
|
style={{
|
||||||
aria-label="AI Tag this image"
|
backgroundColor: 'var(--background)',
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
border: '1px solid var(--border)',
|
||||||
>
|
color: 'var(--text-primary)',
|
||||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
width: 100,
|
||||||
</button>
|
}}
|
||||||
)}
|
/>
|
||||||
</div>
|
<button
|
||||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
onClick={async () => {
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
setRetranslating(true)
|
||||||
|
setTranslatePending(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-tagging/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||||
|
})
|
||||||
|
if (res.status === 202) {
|
||||||
|
setTranslatePending(true)
|
||||||
|
startPolling(extractedText, translatedText, aiDescription)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||||
|
}
|
||||||
|
const result = await res.json()
|
||||||
|
setTranslatedText(result.translatedText || null)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setRetranslating(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={retranslating || translatePending}
|
||||||
|
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||||
|
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!retranslating && !translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!translatePending) {
|
||||||
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</MediaTagPanel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface Props {
|
|||||||
libraryId: string
|
libraryId: string
|
||||||
libraryName: string
|
libraryName: string
|
||||||
initialPath: string
|
initialPath: string
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalState =
|
type ModalState =
|
||||||
@@ -22,7 +23,7 @@ type ModalState =
|
|||||||
|
|
||||||
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
type TagPanelState = { entry: FileEntry; itemKey: string } | null
|
||||||
|
|
||||||
export default function MixedView({ libraryId, libraryName, initialPath }: Props) {
|
export default function MixedView({ libraryId, libraryName, initialPath, readOnly }: Props) {
|
||||||
const [currentPath, setCurrentPath] = useState(initialPath)
|
const [currentPath, setCurrentPath] = useState(initialPath)
|
||||||
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
const [listing, setListing] = useState<DirectoryListing | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -550,7 +551,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
|
|||||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||||
showTags={modalShowTags}
|
showTags={modalShowTags}
|
||||||
onShowTagsChange={setModalShowTags}
|
onShowTagsChange={setModalShowTags}
|
||||||
onAiTag={modal.itemKey ? async () => {
|
readOnly={readOnly}
|
||||||
|
onAiTag={!readOnly && modal.itemKey ? async () => {
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -576,7 +578,8 @@ export default function MixedView({ libraryId, libraryName, initialPath }: Props
|
|||||||
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
|
||||||
showTags={modalShowTags}
|
showTags={modalShowTags}
|
||||||
onShowTagsChange={setModalShowTags}
|
onShowTagsChange={setModalShowTags}
|
||||||
onAiTag={async () => {
|
readOnly={readOnly}
|
||||||
|
onAiTag={readOnly ? undefined : async () => {
|
||||||
const res = await fetch('/api/ai-tagging', {
|
const res = await fetch('/api/ai-tagging', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,9 +16,10 @@ interface Props {
|
|||||||
context?: 'mixed' | 'movies' | 'tv'
|
context?: 'mixed' | 'movies' | 'tv'
|
||||||
showTags?: boolean
|
showTags?: boolean
|
||||||
onShowTagsChange?: (v: boolean) => void
|
onShowTagsChange?: (v: boolean) => void
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange }: Props) {
|
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed', showTags: showTagsProp, onShowTagsChange, readOnly }: Props) {
|
||||||
const settings = useUserSettings()
|
const settings = useUserSettings()
|
||||||
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
|
||||||
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
|
||||||
@@ -28,9 +29,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||||
const showTags = showTagsProp ?? showTagsLocal
|
const showTags = showTagsProp ?? showTagsLocal
|
||||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||||
const [aiTagging, setAiTagging] = useState(false)
|
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
|
||||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
@@ -50,22 +48,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -157,72 +139,14 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
|||||||
|
|
||||||
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
|
||||||
{showTags && (
|
{showTags && (
|
||||||
<div
|
<MediaTagPanel
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
itemKey={itemKey!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTags(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
>
|
onTagsChanged={onTagsChanged}
|
||||||
{/* Panel header — ‹ hide | ✕ close */}
|
onAiTag={readOnly ? undefined : onAiTag}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
readOnly={readOnly}
|
||||||
<button
|
/>
|
||||||
onClick={() => setShowTags(false)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Hide panel"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<div className="flex items-center justify-between mt-4 mb-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
{onAiTag && (
|
|
||||||
<button
|
|
||||||
onClick={handleAiTag}
|
|
||||||
disabled={aiTagging}
|
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
|
||||||
}}
|
|
||||||
aria-label="AI Tag this video"
|
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
|
||||||
>
|
|
||||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie } from '@/types'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
|
|
||||||
@@ -15,9 +15,10 @@ interface Props {
|
|||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onDeleted: (movieId: string) => void
|
onDeleted: (movieId: string) => void
|
||||||
onMetadataRefreshed?: () => void
|
onMetadataRefreshed?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed }: Props) {
|
export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, onNext, onTagsChanged, onDeleted, onMetadataRefreshed, readOnly }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
@@ -40,6 +41,8 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowLeft') { onPrev?.(); return }
|
||||||
|
if (e.key === 'ArrowRight') { onNext?.(); return }
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
if (confirming) { setConfirming(false); return }
|
if (confirming) { setConfirming(false); return }
|
||||||
@@ -56,7 +59,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
}, [onClose, onPrev, onNext, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,7 +239,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Kebab menu */}
|
{/* Kebab menu */}
|
||||||
<div className="relative flex-shrink-0" ref={menuRef}>
|
{!readOnly && <div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
onClick={() => { setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
@@ -291,7 +294,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rename inline input */}
|
{/* Rename inline input */}
|
||||||
@@ -565,49 +568,13 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
|
|
||||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||||
{showTagPanel && (
|
{showTagPanel && (
|
||||||
<div
|
<MediaTagPanel
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
itemKey={movie.item_key!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTagPanel(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
>
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
{/* Panel header — ‹ hide | ✕ close */}
|
readOnly={readOnly}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
/>
|
||||||
<button
|
|
||||||
onClick={() => setShowTagPanel(false)}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Hide panel"
|
|
||||||
title="Hide panel"
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<TagSelector
|
|
||||||
itemKey={movie.item_key!}
|
|
||||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
|
||||||
refreshKey={tagRefreshKey}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MoviesView({ libraryId }: Props) {
|
export default function MoviesView({ libraryId, readOnly }: Props) {
|
||||||
const [movies, setMovies] = useState<Movie[]>([])
|
const [movies, setMovies] = useState<Movie[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -203,6 +204,7 @@ export default function MoviesView({ libraryId }: Props) {
|
|||||||
<MovieDetailModal
|
<MovieDetailModal
|
||||||
movie={selected}
|
movie={selected}
|
||||||
libraryId={libraryId}
|
libraryId={libraryId}
|
||||||
|
readOnly={readOnly}
|
||||||
onClose={() => setSelectedIndex(null)}
|
onClose={() => setSelectedIndex(null)}
|
||||||
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
onPrev={selectedIndex > 0 ? () => setSelectedIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||||
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
onNext={selectedIndex < filtered.length - 1 ? () => setSelectedIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||||
|
|||||||
138
src/components/tags/MediaTagPanel.tsx
Normal file
138
src/components/tags/MediaTagPanel.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import TagSelector from './TagSelector'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemKey: string
|
||||||
|
onHide: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onTagsChanged?: () => void
|
||||||
|
externalRefreshKey?: number
|
||||||
|
onAiTag?: () => Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
disabledMessage?: string
|
||||||
|
readOnly?: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
|
||||||
|
|
||||||
|
export default function MediaTagPanel({
|
||||||
|
itemKey,
|
||||||
|
onHide,
|
||||||
|
onClose,
|
||||||
|
onTagsChanged,
|
||||||
|
externalRefreshKey = 0,
|
||||||
|
onAiTag,
|
||||||
|
disabled,
|
||||||
|
disabledMessage,
|
||||||
|
readOnly,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
|
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
||||||
|
|
||||||
|
const handleAiTag = async () => {
|
||||||
|
if (!onAiTag) return
|
||||||
|
setAiTagging(true)
|
||||||
|
setAiTagError(null)
|
||||||
|
try {
|
||||||
|
await onAiTag()
|
||||||
|
setInternalRefreshKey((k) => k + 1)
|
||||||
|
onTagsChanged?.()
|
||||||
|
} catch (err) {
|
||||||
|
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
|
||||||
|
setTimeout(() => setAiTagError(null), 4000)
|
||||||
|
} finally {
|
||||||
|
setAiTagging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Panel header — ‹ hide | ✕ close */}
|
||||||
|
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={onHide}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Hide panel"
|
||||||
|
title="Hide panel"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={smallBtn}
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
||||||
|
aria-label="Close"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{disabled || !itemKey ? (
|
||||||
|
disabledMessage ? (
|
||||||
|
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
{disabledMessage}
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Tags section heading + optional AI button */}
|
||||||
|
<div className="flex items-center justify-between mt-4 mb-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
{onAiTag && (
|
||||||
|
<button
|
||||||
|
onClick={handleAiTag}
|
||||||
|
disabled={aiTagging}
|
||||||
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
||||||
|
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
aria-label="AI Tag"
|
||||||
|
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
||||||
|
>
|
||||||
|
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
||||||
|
<TagSelector
|
||||||
|
itemKey={itemKey}
|
||||||
|
onTagsChanged={onTagsChanged}
|
||||||
|
refreshKey={internalRefreshKey + externalRefreshKey}
|
||||||
|
hideDescription
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
refreshKey?: number
|
refreshKey?: number
|
||||||
hideDescription?: boolean
|
hideDescription?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllTags {
|
interface AllTags {
|
||||||
@@ -16,7 +17,7 @@ interface AllTags {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription }: Props) {
|
export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDescription, readOnly }: Props) {
|
||||||
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
|
||||||
tags: [],
|
tags: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
@@ -277,23 +278,25 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
style={{ backgroundColor: 'var(--surface-hover)' }}
|
style={{ backgroundColor: 'var(--surface-hover)' }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<button
|
{!readOnly && (
|
||||||
onClick={() => toggleTag(tag)}
|
<button
|
||||||
className="ml-0.5 leading-none transition-colors"
|
onClick={() => toggleTag(tag)}
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
className="ml-0.5 leading-none transition-colors"
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
aria-label={`Remove tag ${tag.name}`}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
>
|
aria-label={`Remove tag ${tag.name}`}
|
||||||
✕
|
>
|
||||||
</button>
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{ungrouped.map((tag) => (
|
{ungrouped.map((tag) => (
|
||||||
<TagBadge key={tag.id} tag={tag} onRemove={() => toggleTag(tag)} />
|
<TagBadge key={tag.id} tag={tag} onRemove={readOnly ? undefined : () => toggleTag(tag)} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -302,7 +305,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tag picker grouped by category */}
|
{/* Tag picker grouped by category */}
|
||||||
<div className="flex flex-col gap-2">
|
{!readOnly && <div className="flex flex-col gap-2">
|
||||||
{all.categories.map((category) => {
|
{all.categories.map((category) => {
|
||||||
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
|
||||||
const search = categorySearches[category.id] ?? ''
|
const search = categorySearches[category.id] ?? ''
|
||||||
@@ -531,7 +534,7 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey, hideDe
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
|||||||
|
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||||
|
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||||
import EpisodeCard from './EpisodeCard'
|
import EpisodeCard from './EpisodeCard'
|
||||||
@@ -13,11 +14,12 @@ import { isBrowserPlayable } from '@/lib/browser-media'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewLevel = 'series' | 'seasons' | 'episodes'
|
type ViewLevel = 'series' | 'seasons' | 'episodes'
|
||||||
|
|
||||||
export default function TvView({ libraryId }: Props) {
|
export default function TvView({ libraryId, readOnly }: Props) {
|
||||||
const [view, setView] = useState<ViewLevel>('series')
|
const [view, setView] = useState<ViewLevel>('series')
|
||||||
const [series, setSeries] = useState<TvSeries[]>([])
|
const [series, setSeries] = useState<TvSeries[]>([])
|
||||||
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
const [seasons, setSeasons] = useState<TvSeason[]>([])
|
||||||
@@ -397,6 +399,28 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
||||||
|
useEffect(() => {
|
||||||
|
if (view === 'series') return
|
||||||
|
const handleArrowKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0)
|
||||||
|
openSeries(filteredSeries[selectedSeriesIndex - 1])
|
||||||
|
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0)
|
||||||
|
openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1)
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1)
|
||||||
|
openSeries(filteredSeries[selectedSeriesIndex + 1])
|
||||||
|
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1)
|
||||||
|
openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleArrowKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleArrowKey)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons])
|
||||||
|
|
||||||
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
|
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
|
||||||
|
|
||||||
if (playingEpisode && playingEpisodeIndex !== null) {
|
if (playingEpisode && playingEpisodeIndex !== null) {
|
||||||
@@ -411,6 +435,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
|
||||||
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined}
|
||||||
context="tv"
|
context="tv"
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -977,7 +1002,7 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
|
|
||||||
{/* Floating controls — tag + close */}
|
{/* Floating controls — tag + close */}
|
||||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && (
|
{view === 'seasons' && selectedSeries?.item_key && !showTagPanel && !readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
|
onClick={() => { setShowTagPanel(true); setTagPanelItemKey(selectedSeries.item_key!); setTagPanelDisabled(false) }}
|
||||||
className={smallBtn}
|
className={smallBtn}
|
||||||
@@ -1041,59 +1066,21 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
|
|
||||||
{/* Right tag panel */}
|
{/* Right tag panel */}
|
||||||
{showTagPanel && (
|
{showTagPanel && (
|
||||||
<div
|
<MediaTagPanel
|
||||||
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
|
itemKey={tagPanelItemKey ?? ''}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTagPanel(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={goToSeries}
|
||||||
>
|
onTagsChanged={() => {
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
setTagRefreshKey((k) => k + 1)
|
||||||
<button
|
setFilterRefreshKey((k) => k + 1)
|
||||||
onClick={() => setShowTagPanel(false)}
|
fetchAssignments()
|
||||||
className={smallBtn}
|
fetchSeriesEpisodeTags()
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
externalRefreshKey={tagRefreshKey}
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
disabled={tagPanelDisabled}
|
||||||
aria-label="Hide panel"
|
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
|
||||||
title="Hide panel"
|
readOnly={readOnly}
|
||||||
>
|
/>
|
||||||
›
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={goToSeries}
|
|
||||||
className={smallBtn}
|
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
|
||||||
aria-label="Close"
|
|
||||||
title="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
{tagPanelDisabled ? (
|
|
||||||
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Seasons cannot be tagged. Select an episode to tag it.
|
|
||||||
</p>
|
|
||||||
) : tagPanelItemKey ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
<TagSelector
|
|
||||||
itemKey={tagPanelItemKey}
|
|
||||||
onTagsChanged={() => {
|
|
||||||
setTagRefreshKey((k) => k + 1)
|
|
||||||
setFilterRefreshKey((k) => k + 1)
|
|
||||||
fetchAssignments()
|
|
||||||
fetchSeriesEpisodeTags()
|
|
||||||
}}
|
|
||||||
refreshKey={tagRefreshKey}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth guard result type
|
// Auth guard result type
|
||||||
type AuthSuccess = { session: IronSession<SessionData> }
|
type AuthSuccess = { session: IronSession<SessionData>; accessLevel?: 'admin' | 'write' | 'read' }
|
||||||
type AuthResult = AuthSuccess | NextResponse
|
type AuthResult = AuthSuccess | NextResponse
|
||||||
|
|
||||||
// Read-only session from an API route request (throwaway response)
|
// Read-only session from an API route request (throwaway response)
|
||||||
@@ -100,13 +100,22 @@ export async function requireLibraryAccess(req: NextRequest, libraryId: string):
|
|||||||
if (!session.userId) {
|
if (!session.userId) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
if (session.role === 'admin') return { session }
|
if (session.role === 'admin') return { session, accessLevel: 'admin' }
|
||||||
|
|
||||||
// Lazy import to avoid pulling DB into edge contexts
|
// Lazy import to avoid pulling DB into edge contexts
|
||||||
const { getPermittedLibraryIds } = await import('./users')
|
const { getLibraryAccessLevel } = await import('./users')
|
||||||
const permitted = getPermittedLibraryIds(session.userId)
|
const accessLevel = getLibraryAccessLevel(session.userId, libraryId)
|
||||||
if (!permitted.includes(libraryId)) {
|
if (!accessLevel) {
|
||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
}
|
}
|
||||||
return { session }
|
return { session, accessLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireLibraryWriteAccess(req: NextRequest, libraryId: string): Promise<AuthResult> {
|
||||||
|
const result = await requireLibraryAccess(req, libraryId)
|
||||||
|
if (result instanceof NextResponse) return result
|
||||||
|
if (result.accessLevel === 'read') {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function initDb(db: Database.Database): void {
|
|||||||
migrateMediaItemsAiFields(db)
|
migrateMediaItemsAiFields(db)
|
||||||
migrateLibraryAiSettings(db)
|
migrateLibraryAiSettings(db)
|
||||||
migrateAiJobs(db)
|
migrateAiJobs(db)
|
||||||
|
migrateLibraryPermissionsAccessLevel(db)
|
||||||
seedAppSettings(db)
|
seedAppSettings(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +319,15 @@ function migrateLibrariesType(db: Database.Database): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLibraryPermissionsAccessLevel(db: Database.Database): void {
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='library_permissions'")
|
||||||
|
.get() as { sql: string } | undefined
|
||||||
|
if (row && !row.sql.includes('access_level')) {
|
||||||
|
db.exec(`ALTER TABLE library_permissions ADD COLUMN access_level TEXT NOT NULL DEFAULT 'write'`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function migrateAiJobs(db: Database.Database): void {
|
function migrateAiJobs(db: Database.Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS ai_jobs (
|
CREATE TABLE IF NOT EXISTS ai_jobs (
|
||||||
|
|||||||
@@ -77,43 +77,60 @@ export function listUsers(): User[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPermittedLibraryIds(userId: string): string[] {
|
export interface LibraryPermission {
|
||||||
const db = getDb()
|
libraryId: string
|
||||||
const rows = db
|
accessLevel: 'read' | 'write'
|
||||||
.prepare('SELECT library_id FROM library_permissions WHERE user_id = ?')
|
|
||||||
.all(userId) as { library_id: string }[]
|
|
||||||
return rows.map((r) => r.library_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLibraryPermissions(userId: string, libraryIds: string[]): void {
|
export function getLibraryPermissions(userId: string): LibraryPermission[] {
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT library_id, access_level FROM library_permissions WHERE user_id = ?')
|
||||||
|
.all(userId) as { library_id: string; access_level: string }[]
|
||||||
|
return rows.map((r) => ({ libraryId: r.library_id, accessLevel: r.access_level as 'read' | 'write' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLibraryAccessLevel(userId: string, libraryId: string): 'read' | 'write' | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT access_level FROM library_permissions WHERE user_id = ? AND library_id = ?')
|
||||||
|
.get(userId, libraryId) as { access_level: string } | undefined
|
||||||
|
if (!row) return null
|
||||||
|
return row.access_level as 'read' | 'write'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLibraryPermissions(userId: string, permissions: LibraryPermission[]): void {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const tx = db.transaction(() => {
|
const tx = db.transaction(() => {
|
||||||
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
|
db.prepare('DELETE FROM library_permissions WHERE user_id = ?').run(userId)
|
||||||
const insert = db.prepare('INSERT INTO library_permissions (user_id, library_id) VALUES (?, ?)')
|
const insert = db.prepare(
|
||||||
for (const libraryId of libraryIds) {
|
'INSERT INTO library_permissions (user_id, library_id, access_level) VALUES (?, ?, ?)'
|
||||||
insert.run(userId, libraryId)
|
)
|
||||||
|
for (const { libraryId, accessLevel } of permissions) {
|
||||||
|
insert.run(userId, libraryId, accessLevel)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tx()
|
tx()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
|
export function getLibrariesForUser(userId: string, role: 'admin' | 'user'): Library[] {
|
||||||
if (role === 'admin') return getLibraries()
|
if (role === 'admin') return getLibraries().map((l) => ({ ...l, accessLevel: 'admin' as const }))
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT l.id, l.name, l.path, l.type, l.cover_ext
|
`SELECT l.id, l.name, l.path, l.type, l.cover_ext, lp.access_level
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
INNER JOIN library_permissions lp ON lp.library_id = l.id
|
INNER JOIN library_permissions lp ON lp.library_id = l.id
|
||||||
WHERE lp.user_id = ?
|
WHERE lp.user_id = ?
|
||||||
ORDER BY l.name ASC`
|
ORDER BY l.name ASC`
|
||||||
)
|
)
|
||||||
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null }[]
|
.all(userId) as { id: string; name: string; path: string; type: string; cover_ext: string | null; access_level: string }[]
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
path: r.path,
|
path: r.path,
|
||||||
type: r.type as Library['type'],
|
type: r.type as Library['type'],
|
||||||
coverExt: r.cover_ext,
|
coverExt: r.cover_ext,
|
||||||
|
accessLevel: r.access_level as 'read' | 'write',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface Library {
|
|||||||
path: string
|
path: string
|
||||||
type: LibraryType
|
type: LibraryType
|
||||||
coverExt: string | null
|
coverExt: string | null
|
||||||
|
accessLevel?: 'admin' | 'read' | 'write'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
|
export type GamePlatform = 'windows' | 'linux' | 'macos' | 'android'
|
||||||
|
|||||||
Reference in New Issue
Block a user