Compare commits
2 Commits
6744a2736b
...
dab3ec5f84
| Author | SHA1 | Date |
|---|---|---|
|
|
dab3ec5f84 | |
|
|
6fe6a43cf0 |
|
|
@ -20,10 +20,10 @@ export async function GET(request: Request) {
|
||||||
|
|
||||||
// Get media files from database for this path
|
// Get media files from database for this path
|
||||||
const mediaFiles = db.prepare(`
|
const mediaFiles = db.prepare(`
|
||||||
SELECT id, path, type, thumbnail
|
SELECT id, path, type, thumbnail, avg_rating, star_count
|
||||||
FROM media
|
FROM media
|
||||||
WHERE path LIKE ?
|
WHERE path LIKE ?
|
||||||
`).all(`${dirPath}%`) as Array<{id: number, path: string, type: string, thumbnail: string | null}>;
|
`).all(`${dirPath}%`) as Array<{id: number, path: string, type: string, thumbnail: string | null, avg_rating: number, star_count: number}>;
|
||||||
|
|
||||||
const result = files.map((file) => {
|
const result = files.map((file) => {
|
||||||
const filePath = path.join(dirPath, file);
|
const filePath = path.join(dirPath, file);
|
||||||
|
|
@ -48,6 +48,8 @@ export async function GET(request: Request) {
|
||||||
type: mediaFile?.type || type,
|
type: mediaFile?.type || type,
|
||||||
thumbnail: mediaFile?.thumbnail,
|
thumbnail: mediaFile?.thumbnail,
|
||||||
id: mediaFile?.id,
|
id: mediaFile?.id,
|
||||||
|
avg_rating: mediaFile?.avg_rating || 0,
|
||||||
|
star_count: mediaFile?.star_count || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { scanAllLibraries } from "@/lib/scanner";
|
import { scanAllLibraries, scanSelectedLibrary } from "@/lib/scanner";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
await scanAllLibraries();
|
const body = await request.json();
|
||||||
return NextResponse.json({ message: "Scan complete" });
|
const { libraryId } = body;
|
||||||
|
|
||||||
|
if (libraryId) {
|
||||||
|
// Scan specific library
|
||||||
|
await scanSelectedLibrary(libraryId);
|
||||||
|
return NextResponse.json({ message: `Library ${libraryId} scan complete` });
|
||||||
|
} else {
|
||||||
|
// Scan all libraries
|
||||||
|
await scanAllLibraries();
|
||||||
|
return NextResponse.json({ message: "All libraries scan complete" });
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react";
|
import { useState, useEffect, Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { Folder, File, Image, Film, Play, ChevronLeft, Home } from "lucide-react";
|
import { Folder, File, Image, Film, Play, ChevronLeft, Home, Star } from "lucide-react";
|
||||||
|
import { StarRating } from "@/components/star-rating";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import PhotoViewer from "@/components/photo-viewer";
|
import PhotoViewer from "@/components/photo-viewer";
|
||||||
|
|
@ -18,6 +19,8 @@ interface FileSystemItem {
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
|
avg_rating?: number;
|
||||||
|
star_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
|
|
@ -364,6 +367,19 @@ const FolderViewerPage = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">{item.name}</p>
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 truncate mb-1">{item.name}</p>
|
||||||
|
|
||||||
|
{/* Star Rating for media files */}
|
||||||
|
{isMediaFile(item) && (item.avg_rating || 0) > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<StarRating
|
||||||
|
rating={item.avg_rating || 0}
|
||||||
|
count={item.star_count || 0}
|
||||||
|
size="sm"
|
||||||
|
showCount={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</p>
|
<p className="text-xs text-slate-600 dark:text-slate-400">{formatFileSize(item.size)}</p>
|
||||||
{!item.isDirectory && (
|
{!item.isDirectory && (
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Header } from "@/components/ui/header";
|
import { Header } from "@/components/ui/header";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Trash2, Plus, Folder, HardDrive } from "lucide-react";
|
import { Trash2, Plus, Folder, HardDrive, Scan, CheckSquare, Square, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
interface Library {
|
interface Library {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -20,6 +20,8 @@ const SettingsPage = () => {
|
||||||
const [newLibraryPath, setNewLibraryPath] = useState("");
|
const [newLibraryPath, setNewLibraryPath] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [scanStatus, setScanStatus] = useState<string>("");
|
const [scanStatus, setScanStatus] = useState<string>("");
|
||||||
|
const [selectedLibraries, setSelectedLibraries] = useState<number[]>([]);
|
||||||
|
const [scanProgress, setScanProgress] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLibraries();
|
fetchLibraries();
|
||||||
|
|
@ -82,17 +84,37 @@ const SettingsPage = () => {
|
||||||
|
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
|
||||||
|
const toggleLibrarySelection = (libraryId: number) => {
|
||||||
|
setSelectedLibraries(prev =>
|
||||||
|
prev.includes(libraryId)
|
||||||
|
? prev.filter(id => id !== libraryId)
|
||||||
|
: [...prev, libraryId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAllLibraries = () => {
|
||||||
|
if (selectedLibraries.length === libraries.length) {
|
||||||
|
setSelectedLibraries([]);
|
||||||
|
} else {
|
||||||
|
setSelectedLibraries(libraries.map(lib => lib.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const scanLibraries = async () => {
|
const scanLibraries = async () => {
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
setScanStatus("Scanning libraries...");
|
setScanStatus("Scanning all libraries...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/scan", {
|
const res = await fetch("/api/scan", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setScanStatus("Scan completed successfully!");
|
setScanStatus("All libraries scan completed successfully!");
|
||||||
setTimeout(() => setScanStatus(""), 3000);
|
setTimeout(() => setScanStatus(""), 3000);
|
||||||
} else {
|
} else {
|
||||||
setScanStatus("Scan failed. Please try again.");
|
setScanStatus("Scan failed. Please try again.");
|
||||||
|
|
@ -104,6 +126,80 @@ const SettingsPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scanSelectedLibraries = async () => {
|
||||||
|
if (selectedLibraries.length === 0) return;
|
||||||
|
|
||||||
|
setIsScanning(true);
|
||||||
|
const libraryMap = new Map(libraries.map(lib => [lib.id, lib.path]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
setScanStatus(`Scanning ${selectedLibraries.length} selected library${selectedLibraries.length > 1 ? 'ies' : ''}...`);
|
||||||
|
|
||||||
|
// Set all selected libraries as scanning
|
||||||
|
const progressMap: Record<number, boolean> = {};
|
||||||
|
selectedLibraries.forEach(id => progressMap[id] = true);
|
||||||
|
setScanProgress(progressMap);
|
||||||
|
|
||||||
|
// Scan each library sequentially to avoid overwhelming the server
|
||||||
|
for (const libraryId of selectedLibraries) {
|
||||||
|
const res = await fetch("/api/scan", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ libraryId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update progress for this library
|
||||||
|
setScanProgress(prev => ({ ...prev, [libraryId]: false }));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setScanStatus(`Error scanning library ${libraryMap.get(libraryId)}: ${data.error || 'Unknown error'}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setScanStatus(`${selectedLibraries.length} library${selectedLibraries.length > 1 ? 'ies' : ''} scan completed successfully!`);
|
||||||
|
setSelectedLibraries([]); // Clear selection after scan
|
||||||
|
setTimeout(() => setScanStatus(""), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
setScanStatus("Network error during scan");
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanProgress({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanSpecificLibrary = async (libraryId: number, libraryPath: string) => {
|
||||||
|
setIsScanning(true);
|
||||||
|
setScanProgress(prev => ({ ...prev, [libraryId]: true }));
|
||||||
|
setScanStatus(`Scanning library: ${libraryPath}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/scan", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ libraryId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setScanStatus(`Library "${libraryPath}" scan completed successfully!`);
|
||||||
|
setTimeout(() => setScanStatus(""), 3000);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setScanStatus(`Scan failed: ${data.error || 'Please try again.'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setScanStatus("Network error during scan");
|
||||||
|
} finally {
|
||||||
|
setIsScanning(false);
|
||||||
|
setScanProgress(prev => ({ ...prev, [libraryId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getTotalStorage = () => {
|
const getTotalStorage = () => {
|
||||||
return libraries.reduce((total, lib) => {
|
return libraries.reduce((total, lib) => {
|
||||||
// Rough estimation based on path length
|
// Rough estimation based on path length
|
||||||
|
|
@ -167,27 +263,74 @@ const SettingsPage = () => {
|
||||||
|
|
||||||
{libraries.length > 0 && (
|
{libraries.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider">
|
<div className="flex items-center justify-between">
|
||||||
{libraries.length} {libraries.length === 1 ? 'Library' : 'Libraries'}
|
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider">
|
||||||
</h3>
|
{libraries.length} {libraries.length === 1 ? 'Library' : 'Libraries'}
|
||||||
|
</h3>
|
||||||
|
{libraries.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={selectAllLibraries}
|
||||||
|
className="text-xs text-zinc-400 hover:text-white transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{selectedLibraries.length === libraries.length ? (
|
||||||
|
<>
|
||||||
|
<CheckSquare size={14} />
|
||||||
|
Deselect All
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Square size={14} />
|
||||||
|
Select All
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{libraries.map((lib) => (
|
{libraries.map((lib) => (
|
||||||
<div key={lib.id} className="flex items-center justify-between p-4 bg-zinc-800 rounded-lg border border-zinc-700 group hover:border-zinc-600 transition-all">
|
<div key={lib.id} className="flex items-center justify-between p-4 bg-zinc-800 rounded-lg border border-zinc-700 group hover:border-zinc-600 transition-all">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleLibrarySelection(lib.id)}
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{selectedLibraries.includes(lib.id) ? (
|
||||||
|
<CheckSquare size={16} className="text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Square size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<div className="w-8 h-8 bg-zinc-700 rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
<HardDrive className="h-4 w-4 text-zinc-300" />
|
<HardDrive className="h-4 w-4 text-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-mono text-zinc-100 truncate">{lib.path}</p>
|
<p className="text-sm font-mono text-zinc-100 truncate">{lib.path}</p>
|
||||||
<p className="text-xs text-zinc-500">Ready to scan</p>
|
<p className="text-xs text-zinc-500">
|
||||||
|
{scanProgress[lib.id] ? 'Scanning...' : 'Ready to scan'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => deleteLibrary(lib.id)}
|
<button
|
||||||
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-red-900/20 rounded-lg transition-all"
|
onClick={() => scanSpecificLibrary(lib.id, lib.path)}
|
||||||
>
|
disabled={isScanning}
|
||||||
<Trash2 size={16} />
|
className="p-2 text-zinc-400 hover:text-green-400 hover:bg-green-900/20 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
</button>
|
title={`Scan ${lib.path}`}
|
||||||
|
>
|
||||||
|
{scanProgress[lib.id] ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Scan size={16} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteLibrary(lib.id)}
|
||||||
|
className="p-2 text-zinc-400 hover:text-red-500 hover:bg-red-900/20 rounded-lg transition-all"
|
||||||
|
title="Delete library"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -215,25 +358,56 @@ const SettingsPage = () => {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-white">Media Scanner</h2>
|
<h2 className="text-xl font-bold text-white">Media Scanner</h2>
|
||||||
<p className="text-sm text-zinc-400">Discover new media files</p>
|
<p className="text-sm text-zinc-400">Discover new media files in your libraries</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
{libraries.length > 0 && (
|
||||||
onClick={scanLibraries}
|
<div className="space-y-3">
|
||||||
disabled={isScanning || libraries.length === 0}
|
{selectedLibraries.length > 0 && (
|
||||||
className="w-full px-4 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white font-medium rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
<button
|
||||||
>
|
onClick={scanSelectedLibraries}
|
||||||
{isScanning ? (
|
disabled={isScanning}
|
||||||
<>
|
className="w-full px-4 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white font-medium rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
>
|
||||||
Scanning...
|
{isScanning ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
||||||
<>Scan Libraries</>
|
Scanning {selectedLibraries.length} library{selectedLibraries.length > 1 ? 'ies' : ''}...
|
||||||
)}
|
</>
|
||||||
</button>
|
) : (
|
||||||
|
<>
|
||||||
|
<Scan size={16} />
|
||||||
|
Scan {selectedLibraries.length} Selected Library{selectedLibraries.length > 1 ? 'ies' : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={scanLibraries}
|
||||||
|
disabled={isScanning}
|
||||||
|
className={`w-full px-4 py-3 font-medium rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 ${
|
||||||
|
selectedLibraries.length > 0
|
||||||
|
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
|
||||||
|
: 'bg-gradient-to-r from-green-500 to-emerald-600 text-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isScanning ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
|
||||||
|
Scanning...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Scan size={16} />
|
||||||
|
Scan All Libraries
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{scanStatus && (
|
{scanStatus && (
|
||||||
<div className={`p-3 rounded-lg ${scanStatus.includes('success') ? 'bg-green-900/20 border border-green-800' : 'bg-zinc-800 border border-zinc-700'}`}>
|
<div className={`p-3 rounded-lg ${scanStatus.includes('success') ? 'bg-green-900/20 border border-green-800' : 'bg-zinc-800 border border-zinc-700'}`}>
|
||||||
|
|
@ -246,7 +420,7 @@ const SettingsPage = () => {
|
||||||
<p className="text-sm text-zinc-500 text-center">
|
<p className="text-sm text-zinc-500 text-center">
|
||||||
{libraries.length === 0
|
{libraries.length === 0
|
||||||
? "Add at least one library to enable scanning"
|
? "Add at least one library to enable scanning"
|
||||||
: "Scan will discover new videos and photos"}
|
: `Select libraries above or use individual scan buttons`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Film, Play, Clock, HardDrive, Search, Filter } from "lucide-react";
|
import { Film, Play, Clock, HardDrive, Search, Filter, Star } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import VideoViewer from "@/components/video-viewer";
|
import VideoViewer from "@/components/video-viewer";
|
||||||
|
import { StarRating } from "@/components/star-rating";
|
||||||
|
|
||||||
interface Video {
|
interface Video {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -225,6 +226,19 @@ const VideosPage = () => {
|
||||||
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
|
<h3 className="font-medium text-foreground text-sm line-clamp-2 mb-2 group-hover:text-primary transition-colors">
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{/* Star Rating */}
|
||||||
|
{(video.avg_rating > 0 || video.star_count > 0) && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<StarRating
|
||||||
|
rating={video.avg_rating || 0}
|
||||||
|
count={video.star_count}
|
||||||
|
size="sm"
|
||||||
|
showCount={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<HardDrive className="h-3 w-3" />
|
<HardDrive className="h-3 w-3" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
rating: number;
|
||||||
|
count?: number;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showCount?: boolean;
|
||||||
|
interactive?: boolean;
|
||||||
|
onRate?: (rating: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StarRating({
|
||||||
|
rating,
|
||||||
|
count = 0,
|
||||||
|
size = 'md',
|
||||||
|
showCount = false,
|
||||||
|
interactive = false,
|
||||||
|
onRate,
|
||||||
|
className
|
||||||
|
}: StarRatingProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-3 w-3',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRate = (newRating: number) => {
|
||||||
|
if (interactive && onRate) {
|
||||||
|
onRate(newRating);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
disabled={!interactive}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
interactive ? "cursor-pointer hover:text-yellow-400" : "cursor-default",
|
||||||
|
star <= Math.round(rating) ? "text-yellow-400" : "text-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={cn(
|
||||||
|
sizeClasses[size],
|
||||||
|
star <= Math.round(rating) ? "fill-yellow-400" : "fill-transparent"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{showCount && count > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
({count})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -114,4 +114,12 @@ export const scanAllLibraries = async () => {
|
||||||
for (const library of libraries) {
|
for (const library of libraries) {
|
||||||
await scanLibrary(library);
|
await scanLibrary(library);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanSelectedLibrary = async (libraryId: number) => {
|
||||||
|
const library = db.prepare("SELECT * FROM libraries WHERE id = ?").get(libraryId) as { id: number; path: string } | undefined;
|
||||||
|
if (!library) {
|
||||||
|
throw new Error(`Library with ID ${libraryId} not found`);
|
||||||
|
}
|
||||||
|
await scanLibrary(library);
|
||||||
};
|
};
|
||||||
Loading…
Reference in New Issue