From d854bbe99bc46fe6aaaf63957db63f2efed1aeb4 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Tue, 21 Apr 2026 10:57:08 -0400 Subject: [PATCH 1/2] add rating system --- src/app/api/browse/route.ts | 12 ++++- src/app/api/ratings/route.ts | 64 ++++++++++++++++++++++ src/components/FilterPanel.tsx | 76 +++++++++++++++++++++++++-- src/components/comics/ComicsView.tsx | 68 +++++++++++++++++++----- src/components/games/GamesView.tsx | 71 ++++++++++++++++++++----- src/components/mixed/MixedView.tsx | 37 ++++++++++--- src/components/movies/MoviesView.tsx | 39 +++++++++++--- src/components/tags/MediaTagPanel.tsx | 69 +++++++++++++++++++++++- src/components/tv/TvView.tsx | 60 ++++++++++++++++----- src/hooks/useDebounce.ts | 14 +++++ src/lib/comics.ts | 38 ++++++++++++-- src/lib/db.ts | 8 +++ src/lib/games.ts | 15 +++++- src/lib/movies.ts | 12 +++++ src/lib/tv.ts | 20 +++++++ src/types/index.ts | 27 ++++++++++ 16 files changed, 568 insertions(+), 62 deletions(-) create mode 100644 src/app/api/ratings/route.ts create mode 100644 src/hooks/useDebounce.ts diff --git a/src/app/api/browse/route.ts b/src/app/api/browse/route.ts index 6182661..63cf227 100644 --- a/src/app/api/browse/route.ts +++ b/src/app/api/browse/route.ts @@ -52,12 +52,20 @@ export async function GET(request: NextRequest) { } } + const ratingRows = db + .prepare('SELECT item_key, user_rating FROM media_items WHERE library_id = ? AND user_rating IS NOT NULL') + .all(libraryId) as { item_key: string; user_rating: number }[] + const ratingMap = new Map(ratingRows.map((r) => [r.item_key, r.user_rating])) + listing.entries = listing.entries.map((e) => { if (e.type === 'file') { - if (e.mediaType !== 'image') return e const relPath = subpath ? path.join(subpath, e.name) : e.name const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}` - return { ...e, hasExtractedText: withText.has(itemKey) } + return { + ...e, + ...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}), + userRating: ratingMap.get(itemKey) ?? null, + } } if (e.type === 'directory') { const dirRel = subpath ? `${subpath}/${e.name}` : e.name diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts new file mode 100644 index 0000000..7d421e9 --- /dev/null +++ b/src/app/api/ratings/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth' +import { getDb } from '@/lib/db' + +function extractLibraryId(itemKey: string): string | null { + const colonIdx = itemKey.indexOf(':') + if (colonIdx === -1) return null + return itemKey.slice(0, colonIdx) +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const itemKey = searchParams.get('itemKey') + if (!itemKey) { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + const libraryId = extractLibraryId(itemKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) + } + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const db = getDb() + const row = db + .prepare('SELECT user_rating FROM media_items WHERE item_key = ?') + .get(itemKey) as { user_rating: number | null } | undefined + + if (!row) { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }) + } + + return NextResponse.json({ userRating: row.user_rating ?? null }) +} + +export async function PATCH(request: NextRequest) { + const body = await request.json() + const { itemKey, userRating } = body as { itemKey: string; userRating: number | null } + + if (!itemKey) { + return NextResponse.json({ error: 'itemKey is required' }, { status: 400 }) + } + if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) { + return NextResponse.json({ error: 'userRating must be null or an integer 1–5' }, { status: 400 }) + } + + const libraryId = extractLibraryId(itemKey) + if (!libraryId) { + return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 }) + } + const auth = await requireLibraryWriteAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const db = getDb() + const result = db + .prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?') + .run(userRating, itemKey) + + if (result.changes === 0) { + return NextResponse.json({ error: 'Item not found' }, { status: 404 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/src/components/FilterPanel.tsx b/src/components/FilterPanel.tsx index 00cf4d2..a8f6528 100644 --- a/src/components/FilterPanel.tsx +++ b/src/components/FilterPanel.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import type { Tag, TagCategory } from '@/types' +import type { Tag, TagCategory, RatingOperator } from '@/types' interface Props { libraryId: string @@ -11,9 +11,24 @@ interface Props { selectedTagIds: Set onTagToggle: (tagId: string) => void refreshKey?: number + ratingValue: number | null + ratingOperator: RatingOperator + onRatingChange: (value: number | null, operator: RatingOperator) => void + showRatingFilter?: boolean } -export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) { +export default function FilterPanel({ + assignments, + search, + onSearchChange, + selectedTagIds, + onTagToggle, + refreshKey, + ratingValue, + ratingOperator, + onRatingChange, + showRatingFilter = true, +}: Props) { const [categories, setCategories] = useState([]) const [tags, setTags] = useState([]) const [loading, setLoading] = useState(true) @@ -53,6 +68,59 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec }} /> + {/* Rating filter */} + {showRatingFilter && ( +
+

Rating

+ {/* Operator toggle */} +
+ {(['gte', 'eq', 'lte'] as RatingOperator[]).map((op) => { + const label = op === 'gte' ? '≥' : op === 'eq' ? '=' : '≤' + const active = ratingValue !== null && ratingOperator === op + return ( + + ) + })} +
+ {/* Star picker */} +
+ {[1, 2, 3, 4, 5].map((star) => { + const lit = + ratingValue !== null && + ((ratingOperator === 'gte' && star <= ratingValue) || + (ratingOperator === 'eq' && star === ratingValue) || + (ratingOperator === 'lte' && star >= ratingValue)) + return ( + + ) + })} +
+
+ )} + {/* Tag filters */} {loading ? (
@@ -62,7 +130,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec className="h-3 w-16 rounded animate-pulse" style={{ backgroundColor: 'var(--border)' }} /> -
+
{[50, 65, 42].map((w) => (
{cat.name}

-
+
{catTags.map((tag) => { const active = selectedTagIds.has(tag.id) return ( diff --git a/src/components/comics/ComicsView.tsx b/src/components/comics/ComicsView.tsx index b3026a3..5fa0ff7 100644 --- a/src/components/comics/ComicsView.tsx +++ b/src/components/comics/ComicsView.tsx @@ -1,7 +1,8 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' -import type { ComicIssue, ComicSeries } from '@/types' +import { useCallback, useEffect, useRef, useState, useMemo } from 'react' +import type { ComicIssue, ComicSeries, RatingOperator } from '@/types' +import { useDebounce } from '@/hooks/useDebounce' import ComicIssueView from './ComicIssueView' import FilterPanel from '@/components/FilterPanel' import TagSelector from '@/components/tags/TagSelector' @@ -29,6 +30,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) { const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) + const [ratingValue, setRatingValue] = useState(null) + const [ratingOperator, setRatingOperator] = useState('gte') + const debouncedSearch = useDebounce(search, 200) const [seriesIssueMeta, setSeriesIssueMeta] = useState< Record >({}) @@ -132,43 +136,76 @@ export default function ComicsView({ libraryId, readOnly }: Props) { fetchSeriesIssueMeta() }, [fetchAssignments, fetchSeriesIssueMeta]) - const filtered = items.filter((item) => { + const handleRatingChange = (value: number | null, operator: RatingOperator) => { + if (value === ratingValue && operator === ratingOperator) { + setRatingValue(null) + } else { + setRatingValue(value) + setRatingOperator(operator) + } + } + + const filtered = useMemo(() => items.filter((item) => { const isSeries = 'issueCount' in item + const series = isSeries ? (item as ComicSeries) : null + const issue = isSeries ? null : (item as ComicIssue) - if (isSeries) { - const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] } + if (series) { + const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] } - if (search) { - const q = search.toLowerCase() - const titleMatch = item.title.toLowerCase().includes(q) + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + const titleMatch = series.title.toLowerCase().includes(q) const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q)) - if (!titleMatch && !issueMatch) return false + const aiMatch = series.aiDescription?.toLowerCase().includes(q) ?? false + const textMatch = series.extractedText?.toLowerCase().includes(q) ?? false + const translatedMatch = series.extractedTextTranslated?.toLowerCase().includes(q) ?? false + if (!titleMatch && !issueMatch && !aiMatch && !textMatch && !translatedMatch) return false } if (selectedTagIds.size > 0) { - const seriesTags = assignments[item.item_key ?? ''] ?? [] + const seriesTags = assignments[series.item_key ?? ''] ?? [] const allTags = [...new Set([...seriesTags, ...meta.tagIds])] if (![...selectedTagIds].every((id) => allTags.includes(id))) return false } + if (ratingValue !== null) { + const r = series.userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } + return true } // Standalone issue - if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + if (![issue!.title, issue!.aiDescription, issue!.extractedText, issue!.extractedTextTranslated] + .some((f) => f?.toLowerCase().includes(q))) return false + } if (selectedTagIds.size > 0) { - const tags = assignments[item.item_key ?? ''] ?? [] + const tags = assignments[issue!.item_key ?? ''] ?? [] if (![...selectedTagIds].every((id) => tags.includes(id))) return false } + if (ratingValue !== null) { + const r = issue!.userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + }), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator]) // Flat list of issues at the current navigation level for prev/next const filteredIssues: ComicIssue[] = selectedSeries ? seriesIssues : filtered.filter((item): item is ComicIssue => !('issueCount' in item)) - const filtersActive = search !== '' || selectedTagIds.size > 0 + const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null return ( <> @@ -198,6 +235,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) { selectedTagIds={selectedTagIds} onTagToggle={toggleTag} refreshKey={filterRefreshKey} + ratingValue={ratingValue} + ratingOperator={ratingOperator} + onRatingChange={handleRatingChange} />
)} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index 9d4313b..e2c2551 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -1,7 +1,8 @@ 'use client' -import { useEffect, useState, useCallback, useRef } from 'react' -import type { Game, GamePlatform, GameSeries } from '@/types' +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types' +import { useDebounce } from '@/hooks/useDebounce' import GameDetailModal from './GameDetailModal' import FilterPanel from '@/components/FilterPanel' @@ -72,6 +73,9 @@ export default function GamesView({ libraryId, readOnly }: Props) { const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) + const [ratingValue, setRatingValue] = useState(null) + const [ratingOperator, setRatingOperator] = useState('gte') + const debouncedSearch = useDebounce(search, 200) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 @@ -128,29 +132,69 @@ export default function GamesView({ libraryId, readOnly }: Props) { ? selectedSeries.games : items - const filtered = visibleItems.filter((item) => { + const handleRatingChange = (value: number | null, operator: RatingOperator) => { + if (value === ratingValue && operator === ratingOperator) { + setRatingValue(null) + } else { + setRatingValue(value) + setRatingOperator(operator) + } + } + + const filtered = useMemo(() => visibleItems.filter((item) => { if ('games' in item) { - const searchMatch = !search || - item.title.toLowerCase().includes(search.toLowerCase()) || - item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase())) - if (!searchMatch) return false + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + const searchMatch = + item.title.toLowerCase().includes(q) || + item.games.some((g) => + g.title.toLowerCase().includes(q) || + (g.aiDescription?.toLowerCase().includes(q) ?? false) || + (g.extractedText?.toLowerCase().includes(q) ?? false) || + (g.extractedTextTranslated?.toLowerCase().includes(q) ?? false) + ) + if (!searchMatch) return false + } if (selectedTagIds.size > 0) { - return item.games.some((g) => { + if (!item.games.some((g) => { const gameTags = assignments[g.item_key!] ?? [] return [...selectedTagIds].every((id) => gameTags.includes(id)) - }) + })) return false + } + if (ratingValue !== null) { + if (!item.games.some((g) => { + const r = g.userRating + if (r === null) return false + if (ratingOperator === 'gte') return r >= ratingValue + if (ratingOperator === 'eq') return r === ratingValue + if (ratingOperator === 'lte') return r <= ratingValue + return false + })) return false } return true } - if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false + // Standalone Game + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + const g = item as Game + if (![g.title, g.aiDescription, g.extractedText, g.extractedTextTranslated] + .some((f) => f?.toLowerCase().includes(q))) return false + } if (selectedTagIds.size > 0) { const gameTags = assignments[item.item_key!] ?? [] if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false } + if (ratingValue !== null) { + const r = (item as Game).userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + }), [visibleItems, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator]) - const filtersActive = search !== '' || selectedTagIds.size > 0 + const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null const filteredGames: Game[] = filtered.flatMap((item) => 'games' in item ? item.games : [item as Game] ) @@ -182,6 +226,9 @@ export default function GamesView({ libraryId, readOnly }: Props) { selectedTagIds={selectedTagIds} onTagToggle={toggleTag} refreshKey={filterRefreshKey} + ratingValue={ratingValue} + ratingOperator={ratingOperator} + onRatingChange={handleRatingChange} />
)} diff --git a/src/components/mixed/MixedView.tsx b/src/components/mixed/MixedView.tsx index 8e393e4..bba39d1 100644 --- a/src/components/mixed/MixedView.tsx +++ b/src/components/mixed/MixedView.tsx @@ -1,7 +1,8 @@ 'use client' -import { useEffect, useState, useCallback, useRef } from 'react' -import type { DirectoryListing, FileEntry } from '@/types' +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import type { DirectoryListing, FileEntry, RatingOperator } from '@/types' +import { useDebounce } from '@/hooks/useDebounce' import VideoPlayerModal from './VideoPlayerModal' import ImageLightbox from './ImageLightbox' import TagSelector from '@/components/tags/TagSelector' @@ -34,6 +35,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) + const debouncedSearch = useDebounce(search, 200) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 @@ -110,7 +112,19 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl .catch(() => {}) }, []) - const filtersActive = search !== '' || selectedTagIds.size > 0 + const [ratingValue, setRatingValue] = useState(null) + const [ratingOperator, setRatingOperator] = useState('gte') + + const handleRatingChange = (value: number | null, operator: RatingOperator) => { + if (value === ratingValue && operator === ratingOperator) { + setRatingValue(null) + } else { + setRatingValue(value) + setRatingOperator(operator) + } + } + + const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null const fetchRecursive = useCallback(() => { if (recursiveLoaded || recursiveLoading) return @@ -155,14 +169,22 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) - const filteredEntries = sourceEntries.filter((entry) => { - if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false + const filteredEntries = useMemo(() => sourceEntries.filter((entry) => { + if (debouncedSearch && !entry.name.toLowerCase().includes(debouncedSearch.toLowerCase())) return false if (selectedTagIds.size > 0 && entry.type !== 'directory') { const entryTags = assignments[itemKeyFor(entry)] ?? [] if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false } + if (ratingValue !== null && entry.type !== 'directory') { + const r = entry.userRating ?? null + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [sourceEntries, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator]) const mediaEntries = filteredEntries.filter( (e) => e.mediaType === 'video' || e.mediaType === 'image' @@ -337,6 +359,9 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl selectedTagIds={selectedTagIds} onTagToggle={toggleTag} refreshKey={filterRefreshKey} + ratingValue={ratingValue} + ratingOperator={ratingOperator} + onRatingChange={handleRatingChange} />
)} diff --git a/src/components/movies/MoviesView.tsx b/src/components/movies/MoviesView.tsx index 577467b..0d6a2bb 100644 --- a/src/components/movies/MoviesView.tsx +++ b/src/components/movies/MoviesView.tsx @@ -1,11 +1,12 @@ 'use client' -import { useEffect, useState, useCallback } from 'react' -import type { Movie } from '@/types' +import { useEffect, useState, useCallback, useMemo } from 'react' +import type { Movie, RatingOperator } from '@/types' import MovieDetailModal from './MovieDetailModal' import FilterPanel from '@/components/FilterPanel' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import { isBrowserPlayable } from '@/lib/browser-media' +import { useDebounce } from '@/hooks/useDebounce' interface Props { libraryId: string @@ -20,6 +21,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) { const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) + const [ratingValue, setRatingValue] = useState(null) + const [ratingOperator, setRatingOperator] = useState('gte') + const debouncedSearch = useDebounce(search, 200) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 @@ -58,14 +62,34 @@ export default function MoviesView({ libraryId, readOnly }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) - const filtered = movies.filter((movie) => { - if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false + const handleRatingChange = (value: number | null, operator: RatingOperator) => { + if (value === ratingValue && operator === ratingOperator) { + setRatingValue(null) + } else { + setRatingValue(value) + setRatingOperator(operator) + } + } + + const filtered = useMemo(() => movies.filter((movie) => { + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + if (![movie.title, movie.plot, movie.aiDescription, movie.extractedText, movie.extractedTextTranslated] + .some((f) => f?.toLowerCase().includes(q))) return false + } if (selectedTagIds.size > 0) { const movieTags = assignments[movie.item_key!] ?? [] if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false } + if (ratingValue !== null) { + const r = movie.userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + }), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator]) const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null @@ -74,7 +98,7 @@ export default function MoviesView({ libraryId, readOnly }: Props) { setMovies((prev) => prev.filter((m) => m.id !== movieId)) } - const filtersActive = search !== '' || selectedTagIds.size > 0 + const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null const handleDoomScroll = () => { // Use filtered movies — respects any active search/tag filters automatically @@ -135,6 +159,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) { selectedTagIds={selectedTagIds} onTagToggle={toggleTag} refreshKey={filterRefreshKey} + ratingValue={ratingValue} + ratingOperator={ratingOperator} + onRatingChange={handleRatingChange} />
)} diff --git a/src/components/tags/MediaTagPanel.tsx b/src/components/tags/MediaTagPanel.tsx index 4ee8c89..8396bba 100644 --- a/src/components/tags/MediaTagPanel.tsx +++ b/src/components/tags/MediaTagPanel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import TagSelector from './TagSelector' interface Props { @@ -33,6 +33,35 @@ export default function MediaTagPanel({ const [aiTagging, setAiTagging] = useState(false) const [aiTagError, setAiTagError] = useState(null) const [internalRefreshKey, setInternalRefreshKey] = useState(0) + const [userRating, setUserRatingState] = useState(null) + const [ratingHover, setRatingHover] = useState(null) + const [savingRating, setSavingRating] = useState(false) + + const fetchRating = useCallback(async () => { + if (!itemKey) return + const res = await fetch(`/api/ratings?itemKey=${encodeURIComponent(itemKey)}`) + if (res.ok) { + const { userRating: r } = await res.json() + setUserRatingState(r) + } + }, [itemKey]) + + useEffect(() => { fetchRating() }, [fetchRating]) + + const setRating = async (star: number) => { + const next = userRating === star ? null : star + setSavingRating(true) + try { + const res = await fetch('/api/ratings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemKey, userRating: next }), + }) + if (res.ok) setUserRatingState(next) + } finally { + setSavingRating(false) + } + } const handleAiTag = async () => { if (!onAiTag) return @@ -94,8 +123,44 @@ export default function MediaTagPanel({ ) : null ) : ( <> + {/* Rating section */} +
+

+ Rating +

+
setRatingHover(null)}> + {[1, 2, 3, 4, 5].map((star) => { + const filled = (ratingHover ?? userRating ?? 0) >= star + return readOnly ? ( + = star ? '#f59e0b' : 'var(--border)' }} + aria-label={`${star} star`} + >★ + ) : ( + + ) + })} +
+
{/* Tags section heading + optional AI button */} -
+

Tags

diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index eb6cf0e..3407f84 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -1,7 +1,8 @@ 'use client' -import { useEffect, useRef, useState, useCallback } from 'react' -import type { TvSeries, TvSeason, TvEpisode } from '@/types' +import { useEffect, useRef, useState, useCallback, useMemo } from 'react' +import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types' +import { useDebounce } from '@/hooks/useDebounce' import FilterPanel from '@/components/FilterPanel' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' @@ -33,6 +34,9 @@ export default function TvView({ libraryId, readOnly }: Props) { const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) const [seriesEpisodeTags, setSeriesEpisodeTags] = useState>({}) + const [ratingValue, setRatingValue] = useState(null) + const [ratingOperator, setRatingOperator] = useState('gte') + const debouncedSearch = useDebounce(search, 200) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState( () => typeof window !== 'undefined' && window.innerWidth >= 768 @@ -375,29 +379,58 @@ export default function TvView({ libraryId, readOnly }: Props) { } }, [view, menuOpen, showTagPanel, selectedSeries]) - const filtersActive = search !== '' || selectedTagIds.size > 0 + const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null - const filteredSeries = series.filter((s) => { - if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false + const handleRatingChange = (value: number | null, operator: RatingOperator) => { + if (value === ratingValue && operator === ratingOperator) { + setRatingValue(null) + } else { + setRatingValue(value) + setRatingOperator(operator) + } + } + + const filteredSeries = useMemo(() => series.filter((s) => { + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + if (![s.title, s.plot, s.aiDescription, s.extractedText, s.extractedTextTranslated] + .some((f) => f?.toLowerCase().includes(q))) return false + } if (selectedTagIds.size > 0) { const seriesTags = assignments[s.item_key!] ?? [] const episodeTags = seriesEpisodeTags[s.id] ?? [] - const allTags = seriesTags.length === 0 ? episodeTags - : episodeTags.length === 0 ? seriesTags - : [...new Set([...seriesTags, ...episodeTags])] + const allTags = [...new Set([...seriesTags, ...episodeTags])] if (![...selectedTagIds].every((id) => allTags.includes(id))) return false } + if (ratingValue !== null) { + const r = s.userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + }), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator]) - const filteredEpisodes = episodes.filter((ep) => { - if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false + const filteredEpisodes = useMemo(() => episodes.filter((ep) => { + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase() + if (![ep.title, ep.plot, ep.aiDescription, ep.extractedText, ep.extractedTextTranslated] + .some((f) => f?.toLowerCase().includes(q))) return false + } if (selectedTagIds.size > 0) { const epTags = assignments[ep.item_key!] ?? [] if (![...selectedTagIds].every((id) => epTags.includes(id))) return false } + if (ratingValue !== null) { + const r = ep.userRating + if (r === null) return false + if (ratingOperator === 'gte' && r < ratingValue) return false + if (ratingOperator === 'eq' && r !== ratingValue) return false + if (ratingOperator === 'lte' && r > ratingValue) return false + } return true - }) + }), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator]) // Arrow key navigation for series/season levels (mirrors the prev/next UI buttons) useEffect(() => { @@ -524,6 +557,9 @@ export default function TvView({ libraryId, readOnly }: Props) { selectedTagIds={selectedTagIds} onTagToggle={toggleTag} refreshKey={filterRefreshKey} + ratingValue={ratingValue} + ratingOperator={ratingOperator} + onRatingChange={handleRatingChange} />
)} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..c4cf4fd --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,14 @@ +'use client' + +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs) + return () => clearTimeout(id) + }, [value, delayMs]) + + return debounced +} diff --git a/src/lib/comics.ts b/src/lib/comics.ts index 39ddfb7..de49c64 100644 --- a/src/lib/comics.ts +++ b/src/lib/comics.ts @@ -153,6 +153,10 @@ export async function scanComicsLibrary( coverUrl, filePath: c.relPath, isStandalone: c.isStandalone, + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, } if (c.isStandalone) { @@ -166,6 +170,10 @@ export async function scanComicsLibrary( coverUrl, // first issue (sorted) becomes the series cover issueCount: 0, issues: [], + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, }) } const series = seriesMap.get(key)! @@ -201,6 +209,10 @@ export function comicsFromDb( title: string | null metadata: string | null file_path: string | null + user_rating: number | null + ai_description: string | null + extracted_text: string | null + extracted_text_translated: string | null } const baseWhere = ` @@ -216,17 +228,20 @@ export function comicsFromDb( .prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`) .get(libraryId) as { cnt: number }).cnt + const cols = `item_key, item_type, parent_key, title, metadata, file_path, + user_rating, ai_description, extracted_text, extracted_text_translated` + const rows: DbRow[] = opts.search ? db .prepare( - `SELECT item_key, item_type, parent_key, title, metadata, file_path + `SELECT ${cols} FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\' ORDER BY title LIMIT ? OFFSET ?` ) .all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[] : db .prepare( - `SELECT item_key, item_type, parent_key, title, metadata, file_path + `SELECT ${cols} FROM media_items ${baseWhere} ORDER BY title LIMIT ? OFFSET ?` ) @@ -243,6 +258,10 @@ export function comicsFromDb( title: row.title ?? decodeURIComponent(idPart), coverUrl: meta.coverUrl ?? null, issueCount: meta.issueCount ?? 0, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } as ComicSeries) } else { const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key @@ -255,6 +274,10 @@ export function comicsFromDb( coverUrl: meta.coverUrl ?? null, filePath: row.file_path ?? '', isStandalone: meta.isStandalone ?? true, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } as ComicIssue) } } @@ -271,11 +294,16 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss title: string | null metadata: string | null file_path: string | null + user_rating: number | null + ai_description: string | null + extracted_text: string | null + extracted_text_translated: string | null } const rows = db .prepare( - `SELECT item_key, title, metadata, file_path + `SELECT item_key, title, metadata, file_path, + user_rating, ai_description, extracted_text, extracted_text_translated FROM media_items WHERE parent_key = ? AND item_type = 'comic_issue'` ) @@ -293,6 +321,10 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss coverUrl: meta.coverUrl ?? null, filePath: row.file_path ?? '', isStandalone: false, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } }) diff --git a/src/lib/db.ts b/src/lib/db.ts index c92bde9..a514b28 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -112,6 +112,7 @@ function initDb(db: Database.Database): void { migrateImportedTags(db) migrateComicsIndex(db) migrateTagMappingsIndexes(db) + migrateUserRating(db) seedAppSettings(db) } @@ -465,3 +466,10 @@ function migrateTagMappingsIndexes(db: Database.Database): void { CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id); `) } + +function migrateUserRating(db: Database.Database): void { + const cols = db.pragma('table_info(media_items)') as { name: string }[] + if (!cols.some((c) => c.name === 'user_rating')) { + db.exec('ALTER TABLE media_items ADD COLUMN user_rating INTEGER') + } +} diff --git a/src/lib/games.ts b/src/lib/games.ts index 30dbba7..3a7b97b 100644 --- a/src/lib/games.ts +++ b/src/lib/games.ts @@ -93,6 +93,10 @@ function buildGame( : null, gameFiles, platforms, + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, } } @@ -175,10 +179,15 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] { parent_key: string | null title: string | null metadata: string | null + user_rating: number | null + ai_description: string | null + extracted_text: string | null + extracted_text_translated: string | null } const allRows = db - .prepare(`SELECT item_key, item_type, parent_key, title, metadata + .prepare(`SELECT item_key, item_type, parent_key, title, metadata, + user_rating, ai_description, extracted_text, extracted_text_translated FROM media_items WHERE library_id = ? AND item_type IN ('game', 'game_series') ORDER BY title`) @@ -233,6 +242,10 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] { wideCoverUrl: meta.wideCoverUrl ?? null, gameFiles, platforms, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } if (row.parent_key && seriesMap.has(row.parent_key)) { seriesMap.get(row.parent_key)!.games.push(game) diff --git a/src/lib/movies.ts b/src/lib/movies.ts index b7cb469..bae8dc8 100644 --- a/src/lib/movies.ts +++ b/src/lib/movies.ts @@ -72,6 +72,10 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie ? fileApiUrl(libraryId, path.join(dirName, backdropFile)) : null, videoPath: videoRelPath, + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, }) } @@ -90,6 +94,10 @@ export function moviesFromDb(libraryId: string): Movie[] { genres: string | null metadata: string | null file_path: string | null + user_rating: number | null + ai_description: string | null + extracted_text: string | null + extracted_text_translated: string | null }> return rows.map((row) => { @@ -108,6 +116,10 @@ export function moviesFromDb(libraryId: string): Movie[] { backdropUrl: meta.backdropUrl ?? null, videoPath: row.file_path ?? '', manuallyEdited: meta.manuallyEdited === true, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } }) } diff --git a/src/lib/tv.ts b/src/lib/tv.ts index bb67b65..bc9ff03 100644 --- a/src/lib/tv.ts +++ b/src/lib/tv.ts @@ -81,6 +81,10 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[ ? fileApiUrl(libraryId, path.join(dirName, backdropFile)) : null, seasonCount, + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, }) } @@ -181,6 +185,10 @@ export function scanTvEpisodes( rating: null, thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath), videoPath: videoRelPath, + userRating: null, + aiDescription: null, + extractedText: null, + extractedTextTranslated: null, }) } @@ -204,6 +212,10 @@ type DbRow = { genres: string | null metadata: string | null file_path: string | null + user_rating: number | null + ai_description: string | null + extracted_text: string | null + extracted_text_translated: string | null } export function tvSeriesFromDb(libraryId: string): TvSeries[] { @@ -227,6 +239,10 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] { backdropUrl: meta.backdropUrl ?? null, seasonCount: meta.seasonCount ?? 0, manuallyEdited: meta.manuallyEdited === true, + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } }) } @@ -290,6 +306,10 @@ export function tvEpisodesFromDb( rating: meta.rating ?? null, thumbnailUrl: meta.thumbnailUrl ?? null, videoPath: row.file_path ?? '', + userRating: row.user_rating ?? null, + aiDescription: row.ai_description ?? null, + extractedText: row.extracted_text ?? null, + extractedTextTranslated: row.extracted_text_translated ?? null, } }) .sort((a, b) => { diff --git a/src/types/index.ts b/src/types/index.ts index 7ea3bb4..f2825f3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,11 +1,17 @@ export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv' +export type RatingOperator = 'gte' | 'eq' | 'lte' + export interface ComicSeries { id: string item_key?: string title: string coverUrl: string | null issueCount: number + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface ComicIssue { @@ -17,6 +23,10 @@ export interface ComicIssue { coverUrl: string | null filePath: string isStandalone: boolean + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface Library { @@ -45,6 +55,10 @@ export interface Game { wideCoverUrl: string | null gameFiles: GameFile[] platforms: GamePlatform[] + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface GameSeries { @@ -65,6 +79,7 @@ export interface FileEntry { url: string | null thumbnailUrl: string | null hasExtractedText?: boolean + userRating?: number | null } export interface Movie { @@ -80,6 +95,10 @@ export interface Movie { backdropUrl: string | null videoPath: string manuallyEdited?: boolean + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface TvSeries { @@ -94,6 +113,10 @@ export interface TvSeries { backdropUrl: string | null seasonCount: number manuallyEdited?: boolean + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface TvSeason { @@ -117,6 +140,10 @@ export interface TvEpisode { rating: number | null thumbnailUrl: string | null videoPath: string + userRating: number | null + aiDescription: string | null + extractedText: string | null + extractedTextTranslated: string | null } export interface DirectoryListing { From b5d144c8ccb9e25a8a29eeb60e7204d94e4e0b41 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:17:43 -0400 Subject: [PATCH 2/2] add ratings to doom scroll --- src/components/DoomScrollView.tsx | 395 +++++++++++++++++++++++++++--- 1 file changed, 364 insertions(+), 31 deletions(-) diff --git a/src/components/DoomScrollView.tsx b/src/components/DoomScrollView.tsx index d750160..32db788 100644 --- a/src/components/DoomScrollView.tsx +++ b/src/components/DoomScrollView.tsx @@ -41,14 +41,29 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const [autoPlayEnabled, setAutoPlayEnabled] = useState(false) const [autoPlaySeconds, setAutoPlaySeconds] = useState(5) + // Tools overlay visibility + const [showToolsOverlay, setShowToolsOverlay] = useState(false) + + // Rating state + const [userRating, setUserRatingState] = useState(null) + const [ratingHover, setRatingHover] = useState(null) + const [savingRating, setSavingRating] = useState(false) + // Text overlay state const [extractedText, setExtractedText] = useState(null) + const [editedExtractedText, setEditedExtractedText] = useState('') + const [savingText, setSavingText] = useState(false) const [translatedText, setTranslatedText] = useState(null) const [showTextOverlay, setShowTextOverlay] = useState(false) const [showOriginal, setShowOriginal] = useState(false) const [extracting, setExtracting] = useState(false) const [extractError, setExtractError] = useState(null) const [extractPending, setExtractPending] = useState(false) + const [retranslating, setRetranslating] = useState(false) + const [translatePending, setTranslatePending] = useState(false) + const [ocrLanguageInput, setOcrLanguageInput] = useState('') + const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng') + const [sourceLanguage, setSourceLanguage] = useState('') const videoRef = useRef(null) const extractPollRef = useRef | null>(null) @@ -128,27 +143,50 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, return () => clearTimeout(id) }, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext]) - // Fetch extracted text for current item; clear any in-flight poll on item change + // Fetch OCR settings once on mount + useEffect(() => { + fetch('/api/ai-settings/ocr') + .then((r) => r.json()) + .then((d: { ocrMode: string; ocrLanguages: string }) => { + setDefaultOcrLanguages(d.ocrLanguages) + }) + .catch(() => {}) + }, []) + + // Fetch extracted text + rating for current item; clear any in-flight poll on item change useEffect(() => { if (extractPollRef.current) { clearInterval(extractPollRef.current) extractPollRef.current = null } setExtractedText(null) + setEditedExtractedText('') setTranslatedText(null) setShowTextOverlay(false) setShowOriginal(false) setExtracting(false) setExtractError(null) setExtractPending(false) + setRetranslating(false) + setTranslatePending(false) + setUserRatingState(null) + setRatingHover(null) if (!current?.itemKey) return - fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`) + const key = current.itemKey + fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`) .then((r) => r.json()) .then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => { setExtractedText(data.extractedText) + setEditedExtractedText(data.extractedText ?? '') setTranslatedText(data.extractedTextTranslated) }) .catch(() => {}) + fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`) + .then((r) => r.json()) + .then((data: { userRating: number | null }) => { + setUserRatingState(data.userRating) + }) + .catch(() => {}) }, [current?.itemKey]) // Clean up poll on unmount @@ -196,7 +234,58 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, } }, [navigate, onClose, extractedText]) - const handleExtractText = async () => { + // ── Polling helper ────────────────────────────────────────────────────────── + const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => { + if (!current?.itemKey) return + const itemKey = current.itemKey + if (extractPollRef.current) clearInterval(extractPollRef.current) + const deadline = Date.now() + 5 * 60 * 1000 + extractPollRef.current = setInterval(async () => { + if (Date.now() > deadline) { + clearInterval(extractPollRef.current!) + extractPollRef.current = null + setExtractPending(false) + setTranslatePending(false) + return + } + try { + const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) + const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() + const textChanged = data.extractedText !== snapshotText + const translationChanged = data.extractedTextTranslated !== snapshotTranslated + if (textChanged || translationChanged) { + clearInterval(extractPollRef.current!) + extractPollRef.current = null + setExtractedText(data.extractedText) + setEditedExtractedText(data.extractedText ?? '') + setTranslatedText(data.extractedTextTranslated) + setExtractPending(false) + setTranslatePending(false) + if (data.extractedText) setShowTextOverlay(true) + } + } catch { /* ignore */ } + }, 2000) + }, [current?.itemKey]) + + // ── Rating actions ─────────────────────────────────────────────────────────── + const handleSetRating = useCallback(async (star: number) => { + if (!current?.itemKey) return + const next = userRating === star ? null : star + setSavingRating(true) + try { + const res = await fetch('/api/ratings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemKey: current.itemKey, userRating: next }), + }) + if (res.ok) setUserRatingState(next) + } finally { + setSavingRating(false) + } + }, [current?.itemKey, userRating]) + + // ── Text extraction ────────────────────────────────────────────────────────── + const callExtract = useCallback(async (modeOverride: string) => { if (!current?.itemKey) return const itemKey = current.itemKey setExtracting(true) @@ -206,30 +295,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, const res = await fetch('/api/ai-tagging/extract-text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ itemKey }), + body: JSON.stringify({ + itemKey, + ocrMode: modeOverride, + ...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }), + }), }) if (res.status === 202) { - // Job queued — poll until it completes (up to 5 min) setExtractPending(true) - const deadline = Date.now() + 5 * 60 * 1000 - extractPollRef.current = setInterval(async () => { - if (Date.now() > deadline) { - if (extractPollRef.current) clearInterval(extractPollRef.current) - setExtractPending(false) - return - } - try { - const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`) - const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json() - if (data.extractedText) { - if (extractPollRef.current) clearInterval(extractPollRef.current) - setExtractPending(false) - setExtractedText(data.extractedText) - setTranslatedText(data.extractedTextTranslated) - setShowTextOverlay(true) - } - } catch { /* ignore */ } - }, 2000) + startPolling(extractedText, translatedText) return } if (!res.ok) { @@ -237,16 +311,67 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, throw new Error((data as { error?: string }).error ?? 'Extraction failed') } const result = await res.json() - setExtractedText(result.extractedText || null) - setTranslatedText(result.translatedText || null) - if (result.extractedText) setShowTextOverlay(true) + const newText: string | null = result.extractedText || null + const newTranslated: string | null = result.translatedText || null + setExtractedText(newText) + setEditedExtractedText(newText ?? '') + setTranslatedText(newTranslated) + if (newText) setShowTextOverlay(true) } catch (err) { setExtractError(err instanceof Error ? err.message : 'Extraction failed') setTimeout(() => setExtractError(null), 4000) } finally { setExtracting(false) } - } + }, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling]) + + // ── Save edited extracted text ─────────────────────────────────────────────── + const handleSaveExtractedText = useCallback(async () => { + if (!current?.itemKey) return + setSavingText(true) + try { + await fetch('/api/ai-tagging/fields', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }), + }) + setExtractedText(editedExtractedText) + } finally { + setSavingText(false) + } + }, [current?.itemKey, editedExtractedText]) + + // ── Translation ────────────────────────────────────────────────────────────── + const handleTranslate = useCallback(async () => { + if (!current?.itemKey) return + setRetranslating(true) + setTranslatePending(false) + try { + const res = await fetch('/api/ai-tagging/translate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + itemKey: current.itemKey, + ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }), + }), + }) + if (res.status === 202) { + setTranslatePending(true) + startPolling(extractedText, translatedText) + return + } + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error((data as { error?: string }).error ?? 'Translation failed') + } + const result = await res.json() + setTranslatedText(result.translatedText || null) + } catch { + // ignore + } finally { + setRetranslating(false) + } + }, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling]) return (
@@ -333,6 +458,193 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose, ) : null}
+ {/* Tools overlay — anchored lower-left, above the bottom bar */} + {showToolsOverlay && current?.itemKey && ( +
e.stopPropagation()} + > + {/* ── Rating ──────────────────────────────────────────── */} +
+

+ Rating +

+
setRatingHover(null)}> + {[1, 2, 3, 4, 5].map((star) => { + const filled = (ratingHover ?? userRating ?? 0) >= star + return ( + + ) + })} +
+
+ + {/* ── Text Extraction (images only) ───────────────────── */} + {current.mediaType === 'image' && ( +
+
+

+ Text Extraction +

+ +
+ +
+ + setOcrLanguageInput(e.target.value)} + placeholder={defaultOcrLanguages} + className="text-xs px-2 py-0.5 rounded-full outline-none" + style={{ + backgroundColor: 'rgba(255,255,255,0.07)', + border: '1px solid rgba(255,255,255,0.15)', + color: 'rgba(255,255,255,0.85)', + width: 120, + }} + title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default." + /> +
+ + {extractError && ( +

{extractError}

+ )} + + {/* Extracted text editor */} + {extractedText !== null && ( +
+

Extracted Text

+