main pages
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Tv, AlertCircle } from '../lib/icons'
|
||||
import { useLibraryItems } from '../hooks/use-jellyfin'
|
||||
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
|
||||
import PosterCard from '../components/ui/PosterCard'
|
||||
|
||||
export default function DuplicatesPage() {
|
||||
const navigate = useNavigate()
|
||||
const serverUrl = getStoredServerUrl()
|
||||
const { data, isLoading } = useLibraryItems(undefined, {
|
||||
includeItemTypes: ['Movie', 'Series'],
|
||||
sortBy: ['SortName'],
|
||||
sortOrder: ['Ascending'],
|
||||
limit: 5000,
|
||||
})
|
||||
const items = data?.Items || []
|
||||
|
||||
const duplicates = useMemo(() => {
|
||||
// Group by TMDB ID first (most reliable)
|
||||
const byTmdb = new Map<string, typeof items>()
|
||||
const byNameYear = new Map<string, typeof items>()
|
||||
|
||||
for (const item of items) {
|
||||
const tmdb = item.ProviderIds?.Tmdb
|
||||
if (tmdb) {
|
||||
const list = byTmdb.get(String(tmdb)) || []
|
||||
list.push(item)
|
||||
byTmdb.set(String(tmdb), list)
|
||||
continue
|
||||
}
|
||||
// Fallback: normalized name + year + type
|
||||
const key = `${(item.Name || '').toLowerCase().trim()}|${item.ProductionYear || 'none'}|${item.Type}`
|
||||
const list = byNameYear.get(key) || []
|
||||
list.push(item)
|
||||
byNameYear.set(key, list)
|
||||
}
|
||||
|
||||
const groups: { key: string; label: string; items: typeof items }[] = []
|
||||
for (const [tmdb, list] of byTmdb) {
|
||||
if (list.length >= 2) {
|
||||
const first = list[0]
|
||||
groups.push({
|
||||
key: `tmdb-${tmdb}`,
|
||||
label: `${first.Name} (${first.ProductionYear || '?'})`,
|
||||
items: list,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [, list] of byNameYear) {
|
||||
if (list.length >= 2) {
|
||||
const first = list[0]
|
||||
groups.push({
|
||||
key: `name-${first.Id}`,
|
||||
label: `${first.Name} (${first.ProductionYear || '?'}) - no TMDB match`,
|
||||
items: list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="px-7 pt-4 pb-12">
|
||||
<header className="mb-7">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
||||
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Tools</span>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
||||
Duplicate finder
|
||||
</h1>
|
||||
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
|
||||
Scans your library for items that appear more than once - either by shared TMDB ID or by matching name + year.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading && items.length === 0 && (
|
||||
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
||||
<p className="text-[13px] text-text-2 font-medium">Scanning your library...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{duplicates.length === 0 && !isLoading && (
|
||||
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
|
||||
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
|
||||
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
|
||||
<AlertCircle size={20} className="text-success" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No duplicates found</p>
|
||||
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
||||
Every movie and series in your library has a unique identity. Nice and tidy.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
{duplicates.map((group, gi) => (
|
||||
<section key={group.key}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{group.items[0]?.Type === 'Series' ? (
|
||||
<Tv size={13} className="text-accent" />
|
||||
) : (
|
||||
<Film size={13} className="text-accent" />
|
||||
)}
|
||||
<h2 className="text-[14px] font-semibold text-text-1 tracking-tight">{group.label}</h2>
|
||||
<span className="text-[11px] text-text-4 tabular-nums">{group.items.length} copies</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
||||
{group.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.Id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
|
||||
>
|
||||
<PosterCard
|
||||
item={item}
|
||||
priority={gi < 3 && i < 6}
|
||||
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user