refacto: réécriture des textes, messages et commentaires

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 6a3cb21adf
commit 586eccbbb3
118 files changed
+1621 -2159

No files matched your search

+8 -12
View File
@@ -3,26 +3,22 @@
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
<div class="relative w-full max-w-sm">
<!-- Logo -->
<div class="flex items-center justify-center gap-3 mb-8">
<FankarrLogo class="w-9 h-9" />
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
</div>
<!-- Card -->
<div class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
<div class="flex flex-col gap-1">
<p class="tag-label text-accent">
{{ isSetup ? 'Première connexion' : 'Connexion' }}
</p>
<p v-if="isSetup" class="tag-label text-accent">Premier lancement</p>
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
{{ isSetup ? 'Créer le compte administrateur' : 'Connexion' }}
</h1>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="auth-username" class="field-label">Identifiant</label>
<label for="auth-username" class="field-label">Nom d'utilisateur</label>
<input id="auth-username" v-model="username" type="text" placeholder="admin" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
@@ -30,14 +26,14 @@
<input id="auth-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<div v-if="isSetup">
<label class="field-label mb-1.5">Confirmer</label>
<label class="field-label mb-1.5">Confirmer le mot de passe</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : isSetup ? 'Créer le compte' : 'Se connecter' }}
{{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
</div>
</div>
@@ -65,9 +61,9 @@ const isSetup = computed(() => !auth.setup)
async function submit() {
error.value = null
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit faire au moins 6 caractères'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis.'; return }
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas.'; return }
submitting.value = true
const err = isSetup.value
+40 -48
View File
@@ -22,12 +22,11 @@
</label>
</header>
<div class="grid lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
<div class="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
<!-- ── Colonne principale-->
<div class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
<!-- ── Colonne principale ── -->
<div data-tour="dashboard-main" class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
<!-- Dernier import -->
<section v-if="hero" class="relative sm:min-h-[344px] rounded-card overflow-hidden bg-card max-lg:order-1">
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" alt="" class="absolute inset-0 w-full h-full object-cover blur-[34px] saturate-[1.3] scale-[1.35] opacity-60" />
<div class="absolute inset-0 bg-linear-to-r from-main/97 via-main/84 via-48% to-main/35" />
@@ -40,13 +39,13 @@
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-2 top-3">NOUVEAU</span>
</RouterLink>
<div class="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importé' : 'Ajouté' }} {{ formatRelative(hero.at) }}</span>
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importée' : 'Ajoutée' }} {{ formatRelative(hero.at) }}</span>
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col">
<span class="font-display text-[28px] leading-[1.05] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
<span v-if="heroTitle.suffix" class="font-display text-xl leading-snug font-semibold text-accent">{{ heroTitle.suffix }}</span>
</RouterLink>
<p class="text-meta text-secondary">
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ plural(hero.episodes, 'épisode') }} {{ auth.isAdmin ? (hero.episodes > 1 ? 'ajoutés' : 'ajouté') : 'à regarder' }}.
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : `${plural(hero.episodes, 'épisode')} à regarder` }}.
</p>
</div>
</div>
@@ -67,7 +66,7 @@
</RouterLink>
<p class="text-[15px] leading-[1.6] text-secondary max-w-[420px] text-pretty">
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>
<span class="text-primary">{{ plural(hero.episodes, 'épisode') }}{{ auth.isAdmin ? (hero.episodes > 1 ? ' ajoutés' : ' ajouté') : '' }}</span>
<span class="text-primary">{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : plural(hero.episodes, 'épisode') }}</span>
{{ auth.isAdmin ? 'à la médiathèque.' : 'à regarder.' }}
</p>
<div class="flex items-center gap-5 mt-1.5 flex-wrap">
@@ -75,7 +74,7 @@
Voir la série <ArrowRight :size="16" />
</RouterLink>
<RouterLink :to="auth.isAdmin ? '/activity' : '/series'" class="text-body text-secondary hover:text-primary transition-colors">
{{ auth.isAdmin ? 'Tous les imports récents' : 'Toutes les nouveautés' }}
{{ auth.isAdmin ? 'Tous les imports récents' : 'Parcourir la médiathèque' }}
</RouterLink>
</div>
</div>
@@ -94,16 +93,15 @@
<p class="text-body text-secondary max-w-[460px]">
{{ auth.isAdmin
? 'Les séries importées apparaîtront ici dès la fin de leur téléchargement.'
: 'Les séries ajoutées à la médiathèque apparaîtront ici. En attendant, parcourez le catalogue et demandez ce qui vous manque.' }}
: 'Les séries ajoutées apparaîtront ici. En attendant, parcourez la médiathèque et demandez celles qui vous intéressent.' }}
</p>
<RouterLink to="/series" class="btn-secondary w-fit mt-1">Parcourir la médiathèque</RouterLink>
</div>
</section>
<!-- Récemment ajoutés -->
<section v-if="recentList.length > 0" class="flex flex-col gap-3.5 max-lg:order-4">
<div class="flex items-baseline justify-between">
<h2 class="section-title">Récemment ajoutés</h2>
<h2 class="section-title">Récemment ajoutées</h2>
<RouterLink to="/series" class="text-[13px] text-secondary hover:text-primary transition-colors flex items-center gap-1">
Médiathèque <ChevronRight :size="14" />
</RouterLink>
@@ -120,12 +118,11 @@
</div>
</section>
<!-- Chiffres -->
<section data-tour="dashboard-stats" class="border-t border-card pt-[18px] max-lg:order-5">
<div v-if="auth.isAdmin" class="flex flex-col sm:flex-row gap-3 sm:gap-0">
<div class="flex-1 flex items-baseline gap-2.5">
<span class="font-display text-[34px] font-bold text-primary">{{ stats.catalogue }}</span>
<span class="text-[13px] text-muted">séries au catalogue</span>
<span class="text-[13px] text-muted">{{ stats.catalogue > 1 ? 'séries au catalogue' : 'série au catalogue' }}</span>
</div>
<div class="hidden sm:block w-px bg-card mx-6" />
<div class="flex-1 flex items-baseline gap-2.5">
@@ -146,12 +143,11 @@
</div>
</section>
<!-- Panneau debug -->
<section v-if="devMode" class="flex flex-col gap-3 max-lg:order-6">
<div class="flex items-center gap-2.5">
<h2 class="section-title">Diagnostic</h2>
<span class="pill pill-wait">DEV</span>
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Rafraîchir</button>
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="d in debugCards" :key="d.label" class="card flex flex-col gap-1">
@@ -160,11 +156,11 @@
<span class="text-meta text-muted">{{ d.hint }}</span>
</div>
</div>
<div v-else class="card text-meta text-muted py-4 text-center">Chargement des stats…</div>
<div v-else class="card text-meta text-muted py-4 text-center">Chargement…</div>
</section>
</div>
<!-- ── Admin --- -->
<!-- ── Colonne droite : admin ── -->
<div v-if="auth.isAdmin" class="flex flex-col gap-7 min-w-0 max-lg:contents">
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
@@ -177,10 +173,9 @@
<div v-if="loadingTodo" class="flex items-center gap-2 text-muted text-meta px-[18px] py-5">
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
</div>
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur.</p>
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur ni dossier à renommer.</p>
<template v-else>
<!-- Demandes -->
<div v-for="req in visibleRequests" :key="req.id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px]" />
@@ -193,7 +188,7 @@
<span v-if="req.hasTorrents === false" class="text-meta text-err flex items-center gap-1.5"><TriangleAlert :size="13" /> Aucun torrent disponible</span>
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
<button @click="approve(req)" :disabled="busy[req.id]" class="btn-sm max-sm:flex-1" :class="req.hasTorrents === false ? 'btn-secondary' : 'btn-primary'">
{{ req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
{{ req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
</button>
<button @click="rejectTarget = req" class="text-meta text-secondary hover:text-err transition-colors max-sm:hidden">Refuser</button>
<button @click="rejectTarget = req" class="btn-icon btn-sm sm:hidden hover:text-err" title="Refuser" aria-label="Refuser la demande"><X :size="15" /></button>
@@ -201,7 +196,6 @@
</div>
</div>
<!-- Imports en erreur -->
<div v-for="err in visibleErrors" :key="err.hash" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<span class="w-10 h-10 rounded-field bg-err/12 text-err flex items-center justify-center shrink-0"><TriangleAlert :size="18" :stroke-width="1.75" /></span>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -213,18 +207,17 @@
{{ busy[err.hash] ? 'Import…' : 'Réessayer' }}
</button>
<button v-if="err.serieId" @click="openManualImport(err)" class="text-meta text-secondary hover:text-primary transition-colors max-sm:h-10 max-sm:px-2">Import manuel</button>
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir dans Activité</RouterLink>
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir l'activité</RouterLink>
</div>
</div>
</div>
<!-- Dossiers à renommer -->
<div v-for="f in visibleFolders" :key="f.serie_id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<span class="w-10 h-10 rounded-field bg-accent/10 text-accent flex items-center justify-center shrink-0"><Folder :size="18" :stroke-width="1.75" /></span>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="tag-label">Dossier à renommer</span>
<RouterLink :to="`/series/${f.serie_id}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ f.serie_title }}</RouterLink>
<span class="text-meta text-secondary break-words">{{ f.current.map(basename).join(', ') }} → {{ basename(f.expected) }}</span>
<span class="text-meta text-secondary break-words">Actuel : {{ f.current.map(basename).join(', ') }} · Attendu : {{ basename(f.expected) }}</span>
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] max-sm:-ml-[54px]">
<button @click="renameFolder(f)" :disabled="busy[`folder-${f.serie_id}`]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
{{ busy[`folder-${f.serie_id}`] ? 'Renommage…' : 'Renommer le dossier' }}
@@ -259,13 +252,13 @@
<div class="progress-bar" :class="{ 'bg-muted': t.state === 'paused', 'bg-err': t.state === 'error' }" :style="{ width: `${Math.min(100, t.progress)}%` }" />
</div>
<span class="text-xs text-muted">
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0 && formatDuration(t.eta) !== '∞'"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
</span>
</div>
</section>
</div>
<!-- ── Colonne droite : invité ─────────────────────────── -->
<!-- ── Colonne droite : invité ── -->
<div v-else class="flex flex-col gap-7 min-w-0 max-lg:contents">
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
@@ -380,7 +373,7 @@ const manualImport = ref<{ serieId: number; serieName: string; seasons: any[]
let interval: ReturnType<typeof setInterval> | null = null
// ── Séries et utilitaires ─────────────────────────────
// ── Séries et utilitaires ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
const titleOf = (id: number, fallback: string) => seriesById.value.get(id)?.title ?? fallback
@@ -395,7 +388,7 @@ const recentList = computed(() => recentWithSerie.value.slice(1, 7))
const heroTitle = computed(() => splitTitle(hero.value?.serie.title ?? ''))
const heroMeta = computed(() => [hero.value?.serie.year, statusLabel(hero.value?.serie.status)].filter(Boolean).map(String))
// ── Chiffres
// ── Chiffres ──
const stats = computed(() => ({
catalogue : catalogueCount.value || seriesStore.series.length,
imported : seriesStore.series.filter(s => s.download_state === 'complete').length,
@@ -412,8 +405,7 @@ function torrentStateLabel(state: string) {
return ({ paused: 'en pause', checking: 'vérification', error: 'erreur du client' } as Record<string, string>)[state] ?? state
}
// ── À traiter (admin)
// Les plus anciennes d'abord
// ── À traiter (admin) ──
const pendingRequests = computed(() => requests.value.filter(r => r.status === 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
@@ -459,7 +451,7 @@ const overflowLinks = computed(() => {
return links
})
// ── Invité ────────────────────────────────────────────────────
// ── Invité ──
const myPendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length)
const guestStatus: Record<string, { label: string; class: string }> = {
@@ -476,7 +468,7 @@ const notYetHere = computed(() => {
.slice(0, 3)
})
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function fetchSettings() {
if (!auth.isAdmin) return
const res = await fetch('/api/settings', { credentials: 'include' })
@@ -493,12 +485,12 @@ async function fetchDebug() {
}
const debugCards = computed(() => debug.value ? [
{ label: 'Heap utilisé', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
{ label: 'RSS', value: `${debug.value.memory.rss} Mo`, hint: 'mémoire système' },
{ label: 'Uptime', value: debug.value.uptime, hint: 'depuis le démarrage' },
{ label: 'Cache GitHub', value: debug.value.cache.entries, hint: `entrées (TTL ${debug.value.cache.ttlHours} h)` },
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
{ label: 'Worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', hint: `${debug.value.requests.notifs} import(s) en mémoire`, accent: debug.value.worker.running },
{ label: 'Mémoire utilisée', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
{ label: 'Mémoire système (RSS)', value: `${debug.value.memory.rss} Mo`, hint: 'occupée par le serveur' },
{ label: 'Durée de fonctionnement', value: debug.value.uptime, hint: 'depuis le démarrage' },
{ label: 'Entrées en cache', value: debug.value.cache.entries, hint: `gardées ${debug.value.cache.ttlHours} h` },
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
{ label: "Tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', hint: `${plural(debug.value.requests.notifs, 'import récent', 'imports récents')} en mémoire`, accent: debug.value.worker.running },
] : [])
async function fetchCatalogue() {
@@ -545,7 +537,7 @@ async function fetchFolders() {
} catch {}
}
// ── Actions
// ── Actions ──
async function patchRequest(req: SerieRequest, body: object) {
busy[req.id] = true
try {
@@ -553,7 +545,7 @@ async function patchRequest(req: SerieRequest, body: object) {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
await fetchRequests()
reqStore.refreshPending()
return true
@@ -580,9 +572,9 @@ async function retryImport(err: { hash: string; torrent: any }) {
body: JSON.stringify({ hash: err.torrent.hash, save_path: err.torrent.save_path, name: err.torrent.name }),
})
const d = await res.json()
if (!res.ok) toast(d.error ?? "Erreur lors de l'import", 'error')
else if (d.errors?.length) toast(`${d.done} importé(s), ${d.errors.length} erreur(s)`, 'error')
else toast(`${d.done} fichier(s) importé(s) ✓`, 'success')
if (!res.ok) toast(d.error ?? "Impossible d'importer ce torrent", 'error')
else if (d.errors?.length) toast(`${plural(d.done, 'fichier importé', 'fichiers importés')}, ${plural(d.errors.length, 'erreur')}`, 'error')
else toast(plural(d.done, 'fichier importé', 'fichiers importés'), 'success')
await Promise.all([fetchTorrents(), fetchRecent()])
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[err.hash] = false }
@@ -609,14 +601,14 @@ async function openManualImport(err: { serieId: number | null; title: string })
}
async function renameFolder(f: { serie_id: number; expected: string }) {
if (!confirm(`Déplacer le contenu vers « ${basename(f.expected)} » ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer le dossier en « ${basename(f.expected)} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
const key = `folder-${f.serie_id}`
busy[key] = true
try {
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${plural(d.moved, 'fichier')} ${d.moved > 1 ? 'déplacés' : 'déplacé'})`, 'success')
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
await fetchFolders()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[key] = false }
@@ -630,8 +622,8 @@ async function requestSerie(s: Serie) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieId: s.id, serieName: s.title, seasons: [] }),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
toast(`${s.title} demandée ✓`, 'success')
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
toast(`« ${s.title} » demandée`, 'success')
await fetchRequests()
reqStore.refreshPending()
} catch { toast('Impossible de contacter le serveur', 'error') }
+20 -22
View File
@@ -2,25 +2,24 @@
<div class="px-4 md:px-10 pt-[26px] pb-12">
<div class="max-w-[1400px] flex flex-col gap-4">
<!-- En-tête -->
<header data-tour="activity-header" class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
<div class="flex items-baseline gap-3.5 flex-wrap">
<h1 class="page-title tracking-[0.01em]">Activité</h1>
<span class="flex items-center gap-[7px] text-meta text-secondary">
<span class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-ok animate-pulse' : 'bg-muted'" />
{{ polling ? 'En direct' : 'En pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
{{ polling ? 'Actualisation auto' : 'Actualisation en pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
</span>
</div>
<div class="flex items-center gap-2.5">
<button @click="importAll" :disabled="importingAll || toImportCount === 0" class="btn-primary pointer-fine:h-[38px]">
<Loader v-if="importingAll" :size="15" class="animate-spin" />
<Upload v-else :size="15" :stroke-width="2.25" />
Importer tout<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
Tout importer<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Rafraîchir" aria-label="Rafraîchir">
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
</button>
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Mettre en pause' : 'Reprendre'" :aria-label="polling ? 'Mettre en pause' : 'Reprendre'">
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'" :aria-label="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'">
<Pause v-if="polling" :size="16" />
<Play v-else :size="16" />
</button>
@@ -42,26 +41,22 @@
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
/>
<!-- Pas de clients -->
<div v-if="noClients" class="card flex flex-col items-center gap-3 py-16 text-center">
<p class="font-display text-xl font-bold text-primary">Aucun client torrent configuré</p>
<p class="text-body text-muted">Ajoutez un client pour suivre les téléchargements FanKarr.</p>
<RouterLink to="/settings/download-client" class="btn-secondary btn-sm mt-1">Configurer un client</RouterLink>
</div>
<!-- Chargement -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="visibleTorrents.length === 0" class="card flex flex-col items-center gap-2 py-16 text-center">
<p class="font-display text-xl font-bold text-primary">{{ emptyState.title }}</p>
<p class="text-body text-muted">{{ emptyState.hint }}</p>
</div>
<!-- Une ligne par torrent -->
<div v-else class="flex flex-col gap-2.5">
<TorrentCard
v-for="t in visibleTorrents"
@@ -90,6 +85,7 @@ import { RouterLink } from 'vue-router'
import { Loader, Pause, Play, RefreshCw, Upload } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useSeriesStore } from '@/stores/series'
import { plural } from '@/utils/format'
import TorrentCard from '@/components/downloads/TorrentCard.vue'
import DownloadsToolbar, { type ActivityTab } from '@/components/downloads/DownloadsToolbar.vue'
@@ -120,7 +116,7 @@ const columnOptions = [
{ key: 'size', label: 'Taille / téléchargé' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'uploaded', label: 'Envoyé (total)' },
{ key: 'upspeed', label: 'Vitesse d’envoi' },
{ key: 'upspeed', label: "Vitesse d'envoi" },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
@@ -133,7 +129,7 @@ const sortOptions = [
let pollInterval: ReturnType<typeof setInterval> | null = null
let clockInterval: ReturnType<typeof setInterval> | null = null
// ── Onglets ───────────────────────────────────────────────────
// ── Onglets ──
const isActive = (t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)
const isDone = (t: any) => t.state === 'seeding' || t.state === 'unknown'
const isToImport = (t: any) => isDone(t) && t.organizeState !== 'done'
@@ -185,13 +181,13 @@ const visibleTorrents = computed(() => {
})
const emptyState = computed(() => ({
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents de la catégorie « fankai » apparaîtront ici.' },
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents envoyés par FanKarr apparaîtront ici.' },
'done' : { title: 'Aucun torrent terminé', hint: hideImported.value ? 'Tous les torrents terminés sont importés.' : '' },
'to-import': { title: 'Rien à importer', hint: 'Tous les torrents terminés sont dans la médiathèque.' },
'errors' : { title: 'Aucune erreur', hint: 'Ni le client ni l’import ne signalent de problème.' },
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents de la catégorie « fankai » apparaîtront ici.' })
'errors' : { title: 'Aucune erreur', hint: "Ni le client ni l'import ne signalent de problème." },
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents envoyés par FanKarr apparaîtront ici.' })
// ── Présentation ──────────────────────────────────────────────
// ── Présentation ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number | undefined) => (id != null ? seriesById.value.get(id)?.poster_image : null) ?? null
@@ -211,7 +207,7 @@ function setSort(val: string) {
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
}
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function fetchTorrents() {
loading.value = true
try {
@@ -236,7 +232,7 @@ function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorre
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
// ── Actions ───────────────────────────────────────────────────
// ── Actions ──
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
@@ -244,8 +240,8 @@ async function importTorrent(torrent: any) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
})
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
if (res.ok) { toast(`« ${torrent.name} » importé`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? `Impossible d'importer « ${torrent.name} »`, 'error') }
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { importing.value[torrent.hash] = false }
}
@@ -266,15 +262,17 @@ async function importAll() {
}
importingAll.value = false
await fetchTorrents()
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
errors === 0
? toast(plural(done, 'torrent importé', 'torrents importés'), 'success')
: toast(`${plural(done, 'torrent importé', 'torrents importés')}, ${plural(errors, 'erreur')}`, 'error')
}
async function deleteTorrent(torrent: any, withFiles: boolean) {
deleting.value[torrent.hash] = true
try {
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Erreur lors de la suppression', 'error')
if (res.ok) { toast(`« ${torrent.name} » supprimé`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Impossible de supprimer le torrent', 'error')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { deleting.value[torrent.hash] = false }
}
+10 -15
View File
@@ -3,33 +3,28 @@
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
<div class="relative w-full max-w-sm">
<!-- Logo -->
<div class="flex items-center justify-center gap-3 mb-8">
<FankarrLogo class="w-9 h-9" />
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
</div>
<!-- Chargement -->
<div v-if="status === 'loading'" class="card px-7 py-8 text-center text-body text-muted">
Vérification du lien…
</div>
<!-- Lien invalide -->
<div v-else-if="status === 'invalid'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
<span class="w-11 h-11 rounded-full bg-err/10 text-err flex items-center justify-center"><TriangleAlert :size="20" /></span>
<p class="font-display text-xl font-bold text-primary">Lien invalide</p>
<p class="text-body text-muted">{{ invalidReason }}</p>
</div>
<!-- Succès -->
<div v-else-if="status === 'done'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
<span class="w-11 h-11 rounded-full bg-ok/10 text-ok flex items-center justify-center"><Check :size="20" :stroke-width="2.5" /></span>
<p class="font-display text-xl font-bold text-primary">Compte créé</p>
<p class="text-body text-muted">Tu peux maintenant te connecter.</p>
<p class="text-body text-muted">Vous pouvez maintenant vous connecter.</p>
<RouterLink to="/auth" class="btn-primary w-full h-11 mt-1">Se connecter</RouterLink>
</div>
<!-- Formulaire d'inscription -->
<div v-else class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
<div class="flex flex-col gap-1">
<p class="tag-label text-accent">Invitation FanKarr</p>
@@ -39,22 +34,22 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="invite-username" class="field-label">Identifiant</label>
<input id="invite-username" v-model="username" type="text" placeholder="username" class="field" @keyup.enter="submit" />
<label for="invite-username" class="field-label">Nom d'utilisateur</label>
<input id="invite-username" v-model="username" type="text" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="invite-password" class="field-label">Mot de passe</label>
<input id="invite-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="invite-confirm" class="field-label">Confirmer</label>
<label for="invite-confirm" class="field-label">Confirmer le mot de passe</label>
<input id="invite-confirm" v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : 'Créer le compte' }}
{{ submitting ? 'Création…' : 'Créer le compte' }}
</button>
</div>
</div>
@@ -88,7 +83,7 @@ onMounted(async () => {
const res = await fetch(`/api/invites/${code}/info`)
const data = await res.json()
if (!res.ok || !data.valid) {
invalidReason.value = data.reason ?? 'Lien invalide'
invalidReason.value = data.reason ?? "Ce lien d'invitation n'est pas valide. Demandez-en un nouveau à l'administrateur."
status.value = 'invalid'
return
}
@@ -99,10 +94,10 @@ onMounted(async () => {
async function submit() {
error.value = null
if (!username.value || !password.value || !confirm.value) {
error.value = 'Tous les champs sont requis'; return
error.value = 'Tous les champs sont requis.'; return
}
if (password.value !== confirm.value) {
error.value = 'Les mots de passe ne correspondent pas'; return
error.value = 'Les mots de passe ne correspondent pas.'; return
}
submitting.value = true
@@ -111,10 +106,10 @@ async function submit() {
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
submitting.value = false
if (!res.ok) { error.value = data.error; return }
if (!res.ok) { error.value = data.error ?? 'Impossible de créer le compte.'; return }
status.value = 'done'
}
</script>
+31 -35
View File
@@ -17,7 +17,7 @@
>
<Loader v-if="bulkBusy" :size="15" class="animate-spin" />
<Check v-else :size="15" :stroke-width="2.25" />
Approuver {{ approvableCount > 1 ? `les ${approvableCount}` : 'la demande' }}<span v-if="approvableCount > 1" class="max-sm:hidden"> demandes</span> avec torrent
Approuver et télécharger ({{ approvableCount }})
</button>
<div ref="headerMenuRef" class="relative">
<button @click="headerMenuOpen = !headerMenuOpen" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="headerMenuOpen">
@@ -36,7 +36,6 @@
</RouterLink>
</header>
<!-- Cmpteurs -->
<div class="chip-row">
<button
v-for="f in visibleFilters" :key="f.value"
@@ -49,7 +48,6 @@
</button>
</div>
<!-- Liste -->
<div v-if="loading" class="flex items-center gap-2 text-muted text-body py-8">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
@@ -58,7 +56,7 @@
<div v-else-if="filtered.length === 0" class="card flex flex-col items-center gap-2 py-12 text-center">
<p class="font-display text-xl font-bold text-primary">Aucune demande{{ activeFilter !== 'all' ? ' avec ce statut' : '' }}</p>
<p class="text-body text-muted">
{{ auth.isAdmin ? 'Les demandes arrivent de FanKarr ou du plugin Jellyfin.' : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
{{ auth.isAdmin ? "Les demandes des invités (depuis FanKarr ou le plugin Jellyfin) s'afficheront ici." : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
</p>
</div>
@@ -90,27 +88,25 @@
<span class="text-body" :class="req.status === 'rejected' ? 'text-muted' : 'text-secondary'">{{ scopeLine(req) }}</span>
<!-- Admin : demandeurs et avertissement torrent -->
<div v-if="auth.isAdmin && (req.status === 'pending' || req.status === 'approved')" class="flex items-center gap-2 flex-wrap">
<span
v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-[7px] h-[26px] pl-1 pr-2.5 rounded-full bg-hover text-meta text-secondary"
:title="`Demandé ${formatRelative(r.requestedAt)}`"
:title="`Demande envoyée ${formatRelative(r.requestedAt)}`"
>
<span class="w-[18px] h-[18px] rounded-full bg-border text-primary text-[10px] font-bold flex items-center justify-center">{{ r.username.charAt(0).toUpperCase() }}</span>
{{ r.username }}<span v-if="req.requesters.length > 1 && requesterScope(r)" class="text-muted">· {{ requesterScope(r) }}</span>
</span>
<span v-if="req.status === 'pending' && req.hasTorrents === false" class="flex items-center gap-[7px] text-meta text-err">
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible — approuver ne lancera aucun téléchargement.
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible : l'approbation ne lancera pas de téléchargement.
</span>
</div>
<p v-if="auth.isAdmin && req.status === 'approved'" class="text-meta text-secondary">
Téléchargement lancé à l'approbation.
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir dans Activité</RouterLink>
{{ req.hasTorrents === false ? 'Approuvée sans torrent : rien n\'a été téléchargé.' : 'Téléchargement lancé à l\'approbation.' }}
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir l'activité</RouterLink>
</p>
<!-- Invité : où en est la demande -->
<p v-if="!auth.isAdmin && req.status !== 'rejected'" class="text-meta text-muted">{{ guestStatusLine[req.status] }}</p>
<p v-if="req.status === 'rejected'" class="text-meta text-err">
@@ -119,7 +115,6 @@
</div>
</div>
<!-- Actions admin -->
<div v-if="auth.isAdmin" class="flex max-sm:items-center flex-col max-sm:flex-row gap-2 shrink-0 sm:w-52">
<button
v-if="req.status === 'pending' || req.status === 'rejected'"
@@ -131,14 +126,14 @@
: 'border border-border text-primary font-semibold hover:bg-hover'"
>
<Check v-if="req.status === 'pending' && req.hasTorrents !== false" :size="15" :stroke-width="2.25" />
{{ req.status === 'rejected' ? 'Approuver malgré tout' : req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
{{ req.status === 'rejected' ? 'Approuver finalement' : req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
</button>
<button
v-else-if="req.status === 'approved'"
@click="complete(req)"
:disabled="busy[req.id]"
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors disabled:opacity-50"
>Marquer disponible</button>
>Marquer comme disponible</button>
<RouterLink
v-else
:to="`/series/${req.serieId}`"
@@ -150,8 +145,8 @@
v-if="req.status === 'pending' || req.status === 'approved'"
@click="rejectTarget = req"
class="flex-1 h-[34px] max-sm:flex-none max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors flex items-center justify-center"
:aria-label="req.status === 'approved' ? 'Annuler la demande' : 'Refuser la demande'"
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">{{ req.status === 'approved' ? 'Annuler' : 'Refuser' }}</span></button>
aria-label="Refuser la demande"
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">Refuser</span></button>
<button
v-else
@click="remove(req)"
@@ -182,7 +177,6 @@
</div>
</div>
<!-- Actions invité -->
<div v-else class="flex flex-col max-sm:flex-row gap-2 shrink-0 sm:w-[190px]">
<RouterLink
:to="`/series/${req.serieId}`"
@@ -199,7 +193,7 @@
@click="requestAgain(req)"
:disabled="busy[req.id]"
class="h-[34px] max-sm:h-11 max-sm:flex-1 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-primary hover:bg-hover transition-colors disabled:opacity-50"
>Redemander</button>
>Demander à nouveau</button>
</div>
</article>
</div>
@@ -266,7 +260,7 @@ const filtered = computed(() => {
const approvableCount = computed(() => requests.value.filter(r => r.status === 'pending' && r.hasTorrents).length)
// ── Présentation ──────────────────────────────────────────────
// ── Présentation ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
const titleOf = (req: SerieRequest) => seriesById.value.get(req.serieId)?.title ?? req.serieName
@@ -288,31 +282,31 @@ function dateLabel(req: SerieRequest) {
return `ajoutée ${formatRelative(req.updatedAt)}`
}
// « Saisons 3 et 4 · 8 épisodes concernés », « 2 épisodes ciblés », « Saison 3 · demandée par marco »
// « Saisons 3 et 4 · 8 épisodes demandés », « 2 épisodes demandés », « Saison 3 · demandée par marco »
function scopeLine(req: SerieRequest) {
const episodes = mergedEpisodes(req)
const parts = [episodes.length > 0 ? `${plural(episodes.length, 'épisode')} ${episodes.length > 1 ? 'ciblés' : 'ciblé'}` : seasonsLabel(mergedSeasons(req))]
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(`${plural(req.episodeCount, 'épisode')} ${req.episodeCount > 1 ? 'concernés' : 'concerné'}`)
const parts = [episodes.length > 0 ? plural(episodes.length, 'épisode demandé', 'épisodes demandés') : seasonsLabel(mergedSeasons(req))]
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(plural(req.episodeCount, 'épisode demandé', 'épisodes demandés'))
if (auth.isAdmin) {
if (req.status !== 'pending') parts.push(`demandée par ${req.requesters.map(r => r.username).join(', ')}`)
} else if (req.requesters.length > 1) {
parts.push('demandée aussi par quelqu’un d’autre')
parts.push("demandée aussi par quelqu'un d'autre")
}
return parts.join(' · ')
}
function requesterScope(r: Requester) {
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'ép.', 'ép.')
return r.seasons.map(s => s === 0 ? 'SP' : `S${s}`).join(', ')
return r.seasons.map(s => s === 0 ? 'Spéciaux' : `S${s}`).join(', ')
}
const guestStatusLine: Record<string, string> = {
pending : 'L’administrateur n’a pas encore répondu.',
pending : "L'administrateur n'a pas encore répondu.",
approved : 'Demande approuvée : la série arrive bientôt.',
completed: 'Prête à regarder dans la médiathèque.',
}
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function load() {
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) requests.value = await res.json()
@@ -327,14 +321,14 @@ onMounted(() => {
})
onUnmounted(() => document.removeEventListener('click', closeCardMenu))
// ── Menus ─────────────────────────────────────────────────────
// ── Menus ──
const cardMenu = ref<string | null>(null)
const headerMenuOpen = ref(false)
const headerMenuRef = ref<HTMLElement | null>(null)
onClickOutside(headerMenuRef, () => { headerMenuOpen.value = false })
function closeCardMenu() { cardMenu.value = null }
// ── Actions admin ─────────────────────────────────────────────
// ── Actions admin ──
async function patch(req: SerieRequest, body: object): Promise<boolean> {
busy[req.id] = true
try {
@@ -342,7 +336,7 @@ async function patch(req: SerieRequest, body: object): Promise<boolean> {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
return true
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
finally { busy[req.id] = false }
@@ -362,7 +356,9 @@ async function approveAllWithTorrents() {
let done = 0
for (const req of targets) if (await patch(req, { action: 'approve' })) done++
bulkBusy.value = false
toast(`${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`, done === targets.length ? 'success' : 'error')
toast(done === targets.length
? `${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`
: `${plural(done, 'demande approuvée', 'demandes approuvées')} sur ${targets.length}`, done === targets.length ? 'success' : 'error')
load()
}
@@ -371,7 +367,7 @@ async function complete(req: SerieRequest) {
}
async function remove(req: SerieRequest) {
if (!confirm(`Supprimer la demande pour "${titleOf(req)}" ?`)) return
if (!confirm(`Supprimer la demande pour « ${titleOf(req)} » ?`)) return
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
load()
}
@@ -379,7 +375,7 @@ async function remove(req: SerieRequest) {
async function removeAll() {
const n = requests.value.length
if (!n) return
if (!confirm(`Supprimer les ${n} demande${n > 1 ? 's' : ''} ?`)) return
if (!confirm(n > 1 ? `Supprimer les ${n} demandes ?` : 'Supprimer la demande ?')) return
await fetch('/api/requests', { method: 'DELETE', credentials: 'include' })
load()
}
@@ -391,13 +387,13 @@ async function confirmReject(message: string) {
if (req && await patch(req, { action: 'reject', rejectionMessage: message || undefined })) load()
}
// ── Actions invité ────────────────────────────────────────────
// ── Actions invité ──
async function withdraw(req: SerieRequest) {
if (!confirm(`Annuler votre demande pour "${titleOf(req)}" ?`)) return
if (!confirm(`Annuler votre demande pour « ${titleOf(req)} » ?`)) return
busy[req.id] = true
try {
const res = await fetch(`/api/requests/${req.id}/mine`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'annuler la demande", 'error'); return }
toast('Demande annulée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -412,7 +408,7 @@ async function requestAgain(req: SerieRequest) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieId: req.serieId, serieName: req.serieName, seasons: mine?.seasons ?? [], episodes: mine?.episodes ?? [] }),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
toast('Nouvelle demande envoyée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
+47 -78
View File
@@ -1,20 +1,17 @@
<template>
<div>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Chargement…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-body text-err">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Bandeau : affiche, titre, progression et actions -->
<SerieHero
:serie="data.serie"
:season-count="seasonCount"
@@ -24,7 +21,6 @@
:watched="auth.isAdmin && rssSync"
>
<template #actions>
<!-- Admin -->
<template v-if="auth.isAdmin">
<div v-if="hasSomethingToDownload" class="relative max-sm:flex-1 max-sm:min-w-0" @click.stop>
<button
@@ -38,7 +34,6 @@
<ChevronDown v-if="data.torrents_integrale.length > 1" :size="13" :stroke-width="2.5" />
</button>
<!-- Le choix -->
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1" class="menu absolute left-0 top-full mt-1.5 w-80 max-w-[90vw] z-20" role="menu">
<button
v-for="(t, i) in data.torrents_integrale"
@@ -75,13 +70,12 @@
class="menu-item text-err hover:text-err"
@click="openUnimportSerieModal(); moreMenuOpen = false"
>
<Trash2 :size="14" class="shrink-0" /> Supprimer la série…
<Trash2 :size="14" class="shrink-0" /> Retirer de la médiathèque…
</button>
</div>
</div>
</template>
<!-- Invité -->
<template v-else>
<button v-if="canRequestMore" @click="openRequestModal()" class="btn-primary max-sm:flex-1 max-sm:min-w-0">
<Clock3 :size="16" :stroke-width="2.25" />
@@ -104,9 +98,9 @@
<div v-if="auth.isAdmin && !data.scraper_synced" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1">
<p class="card-title text-accent">Série absente du scraper</p>
<p class="card-title text-accent">Torrents pas encore disponibles</p>
<p class="text-meta text-secondary">
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
Cette série n'est pas encore dans les données du catalogue. L'import manuel et le renommage restent possibles ; les torrents apparaîtront après la prochaine synchronisation.
</p>
</div>
</div>
@@ -116,9 +110,9 @@
<div class="flex items-start gap-3.5 min-w-0">
<Folder :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1 min-w-0">
<p class="card-title">Dossier différent du titre de la série</p>
<p class="card-title">Le nom du dossier ne correspond plus au titre</p>
<p class="text-meta text-secondary break-all">
{{ staleFolders.map(f => basename(f.path)).join(', ') }} → <span class="text-primary">{{ folderTarget }}</span>
Actuel : {{ staleFolders.map(f => basename(f.path)).join(', ') }} · Attendu : <span class="text-primary">{{ folderTarget }}</span>
</p>
</div>
</div>
@@ -127,7 +121,6 @@
</button>
</div>
<!-- Saisons -->
<SerieSeasonCard
v-for="season in data.seasons"
:key="season.id"
@@ -155,7 +148,6 @@
/>
</div>
<!-- Modal demande (utilisateurs) -->
<Teleport to="body">
<div v-if="requestModal" class="modal-backdrop" @click.self="requestModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="request-title">
@@ -178,7 +170,7 @@
/>
<span class="text-body text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">— {{ season.title }}</span>
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">· {{ season.title }}</span>
</span>
</label>
</div>
@@ -193,29 +185,27 @@
</Teleport>
</template>
<!-- Modal désimport série -->
<Teleport to="body">
<div v-if="unimportSerieModal" class="modal-backdrop" @click.self="unimportSerieModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-serie-title">
<div class="flex flex-col gap-1">
<h2 id="unimport-serie-title" class="card-title">Supprimer la série</h2>
<h2 id="unimport-serie-title" class="card-title">Retirer de la médiathèque</h2>
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded" />
<span class="text-body text-secondary">Supprimer également les fichiers physiques du disque</span>
<span class="text-body text-secondary">Supprimer aussi les fichiers du disque</span>
</label>
<div class="flex items-center justify-end gap-2.5">
<button @click="unimportSerieModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSerie" class="btn-danger">
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
{{ deleteSerieFiles ? 'Retirer et supprimer les fichiers' : 'Retirer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
@@ -249,17 +239,16 @@ const store = useSeriesStore()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── Mode demande (utilisateurs non-admin) ─────────────────────
// ── Demandes (invités) ──
const myRequest = ref<any>(null) // demande existante de l'utilisateur pour cette série
const requestModal = ref(false)
const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
const requestSeasons = ref<number[]>([])
const requestedSeasonNumbers = computed<number[]>(() => {
if (!myRequest.value) return []
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
if (!r) return []
// seasons=[] ET episodes=[] → toutes les saisons demandées
// seasons=[] mais episodes=[…] → seulement des épisodes ciblés, pas toute une saison
// Ni saison ni épisode précisés = série entière
if (r.seasons.length === 0 && (r.episodes ?? []).length === 0) {
return data.value?.seasons?.map((s: any) => s.season_number) ?? []
}
@@ -287,7 +276,6 @@ async function fetchMyRequest() {
}
function openRequestModal(preselectedSeason?: number) {
// Pré-cocher soit la saison cliquée, soit toutes les saisons disponibles
if (preselectedSeason !== undefined) {
requestSeasons.value = [preselectedSeason]
} else {
@@ -326,10 +314,10 @@ async function handleRequestEpisode(_seasonNumber: number, episodeId: number, to
})
if (res.ok) {
myRequest.value = await res.json()
toast('Épisode demandé ✓', 'success')
toast('Épisode demandé', 'success')
} else {
const d = await res.json()
toast(d.error ?? 'Erreur', 'error')
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
@@ -350,10 +338,10 @@ async function submitRequest() {
if (res.ok) {
myRequest.value = await res.json()
requestModal.value = false
toast('Demande envoyée ✓', 'success')
toast('Demande envoyée', 'success')
} else {
const d = await res.json()
toast(d.error ?? 'Erreur', 'error')
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
@@ -392,9 +380,9 @@ export interface ActiveTorrent { hash: string; progress: number; state: string;
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
// Suivi des états précédents pour détecter les transitions (download → seeding)
const _prevTorrentStates = new Map<string, string>() // hash → state
const _prevFileProgresses = new Map<string, number>() // hash:index → progress
// État au poll précédent, pour repérer les téléchargements terminés
const _prevTorrentStates = new Map<string, string>()
const _prevFileProgresses = new Map<string, number>()
const _recentlyOrganized = new Set<string>() // hash:fileIndex ou hash seul
const data = computed(() => store.currentSerie)
@@ -404,21 +392,19 @@ const episodeCount = computed(() => data.value?.seasons.reduce((n, s) => n + s
const organizedCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.filter(e => e.organized).length, 0) ?? 0)
const allOrganized = computed(() => episodeCount.value > 0 && organizedCount.value >= episodeCount.value)
// Invité : reste-t-il des saisons à demander ?
const canRequestMore = computed(() => {
if (!data.value || allOrganized.value) return false
if (!myRequest.value) return true
return data.value.seasons.some(s => s.organized_state !== 'complete' && !requestedSeasonNumbers.value.includes(s.season_number))
})
// ── Helpers intégrale ─────────────────────────────────────────
// ── Intégrales ──
function integraleGroupLabels(): string[] {
const torrents = data.value?.torrents_integrale ?? []
const names = torrents.map((t: any) => t.torrent_name ?? null)
const allPresent = names.every((n: any) => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => {
const raw = t.raw ?? ''
if (raw) return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
@@ -426,9 +412,7 @@ function integraleGroupLabels(): string[] {
})
}
// Épisodes couverts par une intégrale donnée (hash commun)
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
// Épisodes déjà importés : évite de retélécharger une autre version (x264/x265)
const organizedEpisodeIds = computed(() => {
const ids = new Set<number>()
for (const season of data.value?.seasons ?? [])
@@ -450,7 +434,6 @@ function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
return covered
}
// Vrai s'il reste des épisodes/saisons non couverts par cette intégrale
function hasUncoveredByIntegrale(integraleIndex: number): boolean {
if (!data.value) return false
const covered = integraleEpisodeCoverage(integraleIndex)
@@ -460,7 +443,7 @@ function hasUncoveredByIntegrale(integraleIndex: number): boolean {
return false
}
// ── Helpers d'état ────────────────────────────────────────────
// ── État local ──
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
@@ -479,15 +462,14 @@ function extractHash(torrent: any): string | null {
return m ? m[1].toLowerCase() : null
}
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// Fichier d'un pack : le hash ne dit pas si ce fichier précis est en cours
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Fetch ─────────────────────────────────────────────────────
// ── Chargement ──
function load() { store.fetchSerieDetail(Number(route.params.id)) }
async function fetchOrganized() {
@@ -506,13 +488,13 @@ async function fetchFolderStatus() {
}
async function renameFolder() {
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer le dossier en « ${folderTarget.value} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
renamingFolder.value = true
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
await fetchOrganized()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { renamingFolder.value = false }
@@ -544,9 +526,9 @@ async function toggleRssSync() {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieName }),
})
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
if (res.ok) { rssSync.value = true; toast('Surveillance activée : les nouveaux épisodes seront téléchargés automatiquement', 'success') }
}
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
} catch { toast('Impossible de mettre à jour la surveillance', 'error') }
}
async function fetchActiveDownloads() {
@@ -562,7 +544,7 @@ async function fetchActiveDownloads() {
for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash)
// Détecter fichier individuel terminé (progression par fichier)
// Fichier terminé (client avec progression par fichier)
if (t.files) {
for (const f of t.files) {
const key = `${t.hash}:${f.index}`
@@ -575,7 +557,7 @@ async function fetchActiveDownloads() {
}
}
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
// Torrent entier terminé (client sans progression par fichier)
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash)
toOrganize.set(t.hash, t)
@@ -609,7 +591,7 @@ async function triggerOrganize(torrent: any) {
if (res.ok) {
const result = await res.json()
if (result.done > 0) {
toast(`${result.done} épisode(s) importé(s) automatiquement ✓`, 'success')
toast(`${plural(result.done, 'épisode importé', 'épisodes importés')} automatiquement`, 'success')
await fetchOrganized()
load()
}
@@ -619,33 +601,29 @@ async function triggerOrganize(torrent: any) {
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
// ── Download ──────────────────────────────────────────────────
// ── Téléchargement ──
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
removeDownloading(key)
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Erreur inconnue', 'error')
if (result.success) { addDownloaded(key); toast('Téléchargement lancé', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Impossible de lancer le téléchargement', 'error')
}
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
// Construit la liste de tout ce qui doit être téléchargé.
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
// si omis, utilise toutes les intégrales disponibles.
// Reste à envoyer : une intégrale (ou toutes), puis packs et épisodes non couverts
function collectDownloadables(integraleIndex?: number): DlItem[] {
if (!data.value) return []
const result: DlItem[] = []
const covered = new Set<number>()
// Une autre version déjà envoyée couvre déjà ses épisodes
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
})
// ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
: data.value.torrents_integrale.map((t: any, i: number) => ({ t, i }))
@@ -653,7 +631,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const { t, i } of integrales) {
if (!t) continue
// Épisodes apportés par CETTE intégrale
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
if (hash)
@@ -671,10 +648,8 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const id of coveredByThis) covered.add(id)
}
// ── 2. Packs saison non couverts ──────────────────────────────
for (const season of data.value.seasons) {
if (!season.torrents?.length || season.organized_state === 'complete') continue
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
@@ -684,7 +659,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const ep of season.episodes) covered.add(ep.id)
}
// ── 3. Épisodes individuels non couverts ─────────────────────
for (const season of data.value.seasons)
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
@@ -694,9 +668,7 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
return result
}
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
// que le garde-fou versions ne laisserait passer aucun torrent.
// Même calcul que downloadAll : jamais de bouton sans effet
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
async function downloadAll(integraleIndex?: number) {
@@ -707,7 +679,7 @@ async function downloadAll(integraleIndex?: number) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger', 'success')
}
@@ -728,7 +700,6 @@ async function downloadSeason(season: Season, packHash?: string) {
}
} else {
for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
const epTorrent = packHash
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
: ep.torrent
@@ -741,11 +712,11 @@ async function downloadSeason(season: Season, packHash?: string) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
}
// ── Actions ───────────────────────────────────────────────────
// ── Actions ──
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
@@ -757,9 +728,8 @@ async function confirmUnimportSerie() { unimportSerieModal.value = false; await
async function unimportSeason(season: any, deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la saison', 'error'); return }
toast(deleteFile ? 'Saison retirée et fichiers supprimés' : 'Saison retirée de la médiathèque', 'success')
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
removeDownloaded(`season-${season.id}`)
await fetchOrganized(); load()
@@ -769,9 +739,8 @@ async function unimportSeason(season: any, deleteFile: boolean) {
async function unimportSerie(deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la série', 'error'); return }
toast(deleteFile ? 'Série retirée et fichiers supprimés' : 'Série retirée de la médiathèque', 'success')
downloaded.value = []
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -786,7 +755,7 @@ async function renameEpisode(ep: any, _season: any) {
body: JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
})
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur rename', 'error'); return }
if (!res.ok) { toast(d.error ?? "Impossible de renommer l'épisode", 'error'); return }
if (d.renamed) { toast(`Renommé : ${d.new_name}`, 'success'); await fetchOrganized() }
else toast(d.message ?? 'Nom déjà correct', 'success')
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -797,8 +766,8 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
epActionLoading.value[ep.id] = true
try {
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
clearEpDownloaded(ep)
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
+28 -30
View File
@@ -16,12 +16,11 @@
:selectable="auth.isAdmin"
/>
<!-- Grille -->
<div data-tour="series-grid" class="pt-1.5">
<div v-if="store.loadingSeries && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Chargement du catalogue…</p>
<p class="text-body">Chargement de la médiathèque…</p>
</div>
<div v-else-if="store.error && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64">
@@ -42,17 +41,16 @@
>
<template v-for="item in gridItems" :key="item.key">
<!-- Tuile -->
<!-- Tuile lettre (tri alphabétique) -->
<div
v-if="item.type === 'letter'"
class="@container aspect-[2/3] rounded-poster border border-border-light bg-linear-to-b from-card to-main max-sm:hidden flex flex-col justify-between px-[11%] pt-[11%] pb-[10%] overflow-hidden select-none"
aria-hidden="true"
>
<span class="font-display text-[80cqw] leading-[0.9] font-extrabold text-accent">{{ item.letter }}</span>
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ item.count }} série{{ item.count > 1 ? 's' : '' }}</span>
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ plural(item.count, 'série') }}</span>
</div>
<!-- Affiche -->
<component
v-else
:is="selecting ? 'button' : RouterLink"
@@ -90,7 +88,7 @@
<Check :size="13" :stroke-width="3" />
</span>
<span v-if="item.state.watched" title="Surveillée" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
<span v-if="item.state.watched" title="Surveillée : nouveaux épisodes téléchargés automatiquement" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
<Rss :size="12" :stroke-width="2.5" />
</span>
<span v-else-if="item.state.requested" class="absolute right-2 top-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-accent text-[11.5px] font-bold flex items-center gap-1.5">
@@ -141,14 +139,13 @@
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-4 z-10 mt-8 mx-auto w-fit max-w-full bg-card border border-border rounded-card px-4 py-3 flex items-center gap-x-3 gap-y-2 flex-wrap shadow-[0_16px_40px_rgb(0_0_0/0.5)]">
<span class="text-body font-bold text-primary">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<span class="text-body font-bold text-primary">{{ plural(selected.size, 'sélectionnée') }}</span>
<button @click="selectAllFiltered" class="text-meta text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Désélectionner</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Tout désélectionner</button>
<span class="hidden sm:block w-px h-6 bg-hover" />
<div class="flex items-center gap-2 flex-wrap">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer</button>
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer les fichiers</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-icon btn-sm" title="Quitter la sélection" aria-label="Quitter la sélection"><X :size="14" /></button>
@@ -169,6 +166,7 @@ import { useToast } from '@/composables/useToast'
import { usePosterSize } from '@/composables/usePosterSize'
import { useLibrarySearch } from '@/composables/useLibrarySearch'
import { isNew, statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
import SeriesToolbar, { type LibraryFilter } from '@/components/series/SeriesToolbar.vue'
defineOptions({ name: 'SeriesView' })
@@ -185,13 +183,13 @@ const activeFilter = ref('all')
const activeSort = ref('alpha')
const sortOptions = [
{ label: 'A → Z', value: 'alpha' },
{ label: 'Z → A', value: 'alpha-desc' },
{ label: 'Récentes', value: 'recent' },
{ label: 'Importées d’abord', value: 'imported' },
{ label: 'De A à Z', value: 'alpha' },
{ label: 'De Z à A', value: 'alpha-desc' },
{ label: 'Année de sortie', value: 'recent' },
{ label: "Importées d'abord", value: 'imported' },
]
// ── Filtres à compteurs ──────────────────────────────────────
// ── Filtres à compteur ──
const myRequested = ref<Set<number>>(new Set())
const predicates: Record<string, (s: Serie) => boolean> = {
@@ -223,7 +221,7 @@ const filters = computed<LibraryFilter[]>(() => (auth.isAdmin
{ value: 'all', label: 'Toutes' },
{ value: 'complete', label: 'Prêtes à regarder' },
{ value: 'partial', label: 'Partielles' },
{ value: 'downloading', label: 'Bientôt', attention: true },
{ value: 'downloading', label: 'Bientôt là', attention: true },
{ value: 'not-here', label: 'Pas encore là' },
{ value: 'mine', label: 'Mes demandes', attention: true },
]
@@ -263,7 +261,7 @@ function normalize(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
// ── État des séries ─────────────────────────────────────
// ── État des séries ──
const errorsBySerie = ref<Map<number, number>>(new Map())
const staleFolders = ref<Set<number>>(new Set())
@@ -307,7 +305,7 @@ function posterState(s: Serie): PosterState {
dim : notHere,
isNew : s.download_state !== 'none' && isNew(s.last_imported_at),
progress : null,
chip : s.download_state === 'downloading' ? { label: 'Bientôt', tone: 'accent', icon: 'soon' }
chip : s.download_state === 'downloading' ? { label: 'Bientôt là', tone: 'accent', icon: 'soon' }
: partial ?? (notHere ? { label: 'Pas encore là', tone: 'muted' } : null),
}
}
@@ -320,13 +318,13 @@ function posterState(s: Serie): PosterState {
dim : s.download_state === 'none' && !s.has_torrents,
isNew : isNew(s.last_imported_at),
progress : errors ? null : downloadProgress(s),
chip : errors ? { label: `${errors} erreur${errors > 1 ? 's' : ''}`, tone: 'err', icon: 'error', title: 'Import en erreur' }
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Dossier différent du titre de la série' }
chip : errors ? { label: plural(errors, 'erreur'), tone: 'err', icon: 'error', title: 'Import en erreur' }
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Nom de dossier différent du titre' }
: partial ?? (s.download_state === 'none' && !s.has_torrents ? { label: 'Sans torrent', tone: 'muted' } : null),
}
}
// Grille──
// ── Grille ──
function letterOf(title: string) {
const c = normalize(title).charAt(0).toUpperCase()
return /[A-Z]/.test(c) ? c : '#'
@@ -353,7 +351,7 @@ const gridItems = computed<GridItem[]>(() => {
return items
})
// ── Sélection multiple ───────────────────────────────────────
// ── Sélection multiple ──
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
const bulkBusy = ref(false)
@@ -377,9 +375,9 @@ async function bulkRename() {
body: JSON.stringify({ serie_ids: [...selected.value] }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
const errs = data.errors?.length ?? 0
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
toast(`${plural(data.done, 'fichier renommé', 'fichiers renommés')}${errs ? ` · ${plural(errs, 'erreur')}` : ''}`, errs ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -396,12 +394,12 @@ async function bulkSync(enabled: boolean) {
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
if (!res.ok) { toast(data.error ?? 'Impossible de mettre à jour la surveillance', 'error'); return }
for (const s of targets) s.rss_synced = enabled
const n = data.changed
toast(enabled
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
? `${plural(n, 'série ajoutée', 'séries ajoutées')} à la surveillance`
: `${plural(n, 'série retirée', 'séries retirées')} de la surveillance`, 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -409,7 +407,7 @@ async function bulkSync(enabled: boolean) {
}
}
// ── Polling downloads + notifs import ────────────────────────
// ── Suivi des téléchargements et des imports ──
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
let notifsPrimed = false
@@ -441,8 +439,8 @@ async function fetchOrganizeNotifs() {
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
? `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}, ${plural(n.errors, 'erreur')}`
: `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
+9 -12
View File
@@ -1,7 +1,6 @@
<template>
<div class="min-h-screen bg-main flex flex-col md:flex-row">
<!-- Rail des étapes -->
<aside class="md:w-[280px] md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-card flex flex-col shrink-0">
<div class="flex items-center gap-[11px] pl-5 pr-3 pt-[22px] pb-5">
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
@@ -29,7 +28,6 @@
</button>
</div>
<!-- Progression mobile -->
<div class="md:hidden px-5 pb-4">
<div class="progress" role="progressbar" :aria-valuenow="index + 1" aria-valuemin="1" :aria-valuemax="steps.length">
<div class="progress-bar" :style="{ width: `${((index + 1) / steps.length) * 100}%` }" />
@@ -70,7 +68,6 @@
</p>
</aside>
<!-- Contenu -->
<main class="flex-1 min-w-0">
<div v-if="!loaded" class="flex items-center justify-center py-32">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
@@ -103,7 +100,7 @@
Passer
</button>
<button @click="next(true)" :disabled="busy" class="btn-primary">
{{ busy ? '…' : index === 0 ? 'Commencer' : 'Suivant' }}
{{ busy ? 'Enregistrement…' : index === 0 ? 'Commencer' : 'Suivant' }}
<ArrowRight v-if="!busy" :size="15" />
</button>
</footer>
@@ -142,9 +139,9 @@ interface StepDef {
const steps: StepDef[] = [
{
id: 'welcome', label: 'Bienvenue', hint: 'Ce qui vous attend', component: SetupWelcomeStep,
id: 'welcome', label: 'Bienvenue', hint: 'Présentation', component: SetupWelcomeStep,
title: 'Bienvenue dans FanKarr',
description: 'Quelques minutes pour préparer FanKarr : où ranger vos séries, quel client torrent utiliser et comment les retrouver dans votre lecteur.',
description: 'Cet assistant règle les dossiers, le client torrent, le serveur multimédia et le catalogue.',
},
{
id: 'paths', label: 'Dossiers', hint: 'Téléchargements et médiathèque', component: SetupPathsStep,
@@ -153,28 +150,28 @@ const steps: StepDef[] = [
},
{
id: 'client', label: 'Client torrent', hint: 'qBittorrent, Transmission…', component: SetupClientStep,
title: 'Client de téléchargement',
title: 'Client torrent',
description: 'FanKarr envoie les torrents à votre client puis surveille leur progression pour les importer.',
},
{
id: 'import', label: 'Import', hint: 'Automatisme et nommage', component: SetupImportStep,
id: 'import', label: 'Import', hint: 'Import automatique et nommage', component: SetupImportStep,
title: 'Options d\'import',
description: 'Réglez ce qui se passe quand un téléchargement se termine.',
},
{
id: 'media-server', label: 'Serveur multimédia', hint: 'Jellyfin, Plex', component: SetupMediaServerStep, optional: true,
title: 'Jellyfin et Plex',
description: 'Connectez votre serveur multimédia pour y retrouver vos séries. Vous pouvez passer cette étape.',
description: 'Connectez votre serveur multimédia pour y retrouver vos séries.',
},
{
id: 'catalog', label: 'Catalogue', hint: 'Séries Fankai', component: SetupCatalogStep,
title: 'Catalogue Fankai',
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met à jour tout seul ensuite.',
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met ensuite à jour automatiquement.',
},
{
id: 'recap', label: 'Récapitulatif', hint: 'Vérifier et terminer', component: SetupRecapStep,
title: 'Tout est prêt ?',
description: 'Vérifiez la configuration avant de commencer. Chaque point reste modifiable.',
title: 'Récapitulatif',
description: 'Vérifiez la configuration avant de commencer.',
},
]
+7 -11
View File
@@ -8,7 +8,6 @@
</button>
</Teleport>
<!-- ── Version ────────────────────────────────────────────── -->
<SettingsSection title="Version">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -18,7 +17,7 @@
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
</span>
<span class="text-meta text-muted">
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
<template v-if="debug">Serveur démarré il y a {{ uptime }} · </template>mises à jour vérifiées toutes les 6 h
</span>
</div>
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -27,8 +26,7 @@
</div>
</SettingsSection>
<!-- ── Diag ─────────────────────────────────────────── -->
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
<SettingsSection title="Diagnostic" description="État du serveur, utile pour signaler un bug.">
<template #actions>
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</template>
@@ -41,7 +39,6 @@
<p v-else class="text-meta text-muted">Chargement…</p>
</SettingsSection>
<!-- ── Dev ──────────────────────────────────────── -->
<SettingsSection title="Développement">
<template #actions>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -49,11 +46,10 @@
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
description="Affiche aussi ce diagnostic en bas de l'accueil."
/>
</SettingsSection>
<!-- ── Assistant et visite ────────────────────────────────── -->
<SettingsSection title="Assistant et visite guidée">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -85,7 +81,7 @@ const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
let loaded = false
// ── Version ───────────────────────────────────────────────────
// ── Version ──
const GITHUB_REPO = 'masutayunikon/FanKarr'
const currentVersion = ref('')
const latestVersion = ref('')
@@ -120,7 +116,7 @@ async function checkForUpdates() {
}
}
// ── Diag ────────────────────────────────────────────────
// ── Diagnostic ──
const debug = ref<any>(null)
async function fetchDebug() {
@@ -139,8 +135,8 @@ const diagnostics = computed(() => debug.value ? [
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
{ label: `entrées en cache (gardées ${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: "tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', accent: debug.value.worker.running },
] : [])
onMounted(async () => {
+21 -26
View File
@@ -8,14 +8,12 @@
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Données manquantes -->
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
@@ -23,17 +21,16 @@
<p class="card-title text-accent">Données manquantes</p>
<p class="text-meta text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
Synchronisez le catalogue pour utiliser FanKarr.
</p>
</div>
</div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
{{ updating ? 'Synchronisation…' : 'Synchroniser' }}
</button>
</div>
<!-- Sources -->
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
<SettingsSection title="Sources" description="Les séries et les torrents viennent du scraper GitHub. L'API Fankai complète les séries qui n'y figurent pas encore.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -41,36 +38,35 @@
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
</span>
<span class="text-meta text-muted">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
{{ status.empty ? 'Aucune donnée chargée' : plural(status.count, 'série disponible', 'séries disponibles') }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
</span>
</div>
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
<span class="text-meta text-muted shrink-0">Données gardées en cache 1 h</span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">API Fankai</span>
<span class="text-meta text-muted">
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
<template v-if="apiOnlyCount > 0">{{ plural(apiOnlyCount, 'série vient', 'séries viennent') }} uniquement de l'API : fiche et images, mais pas de torrents.</template>
<template v-else>Fournit les titres à jour et les séries absentes du scraper.</template>
</span>
</div>
</div>
</SettingsSection>
<!-- Surveillance RSS -->
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés automatiquement au client. Vérification toutes les 6 heures.">
<template #actions>
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
<Loader v-if="runningRss" :size="14" class="animate-spin" />
<RefreshCw v-else :size="14" />
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
{{ runningRss ? 'Vérification…' : 'Vérifier maintenant' }}
</button>
</template>
<div class="flex items-baseline gap-[9px]">
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ watched.length > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
</div>
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
<RouterLink
@@ -79,17 +75,16 @@
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
>{{ s.serieName }}</RouterLink>
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
+ {{ plural(watched.length - visibleWatched.length, 'autre') }}
</button>
</div>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou en sélectionnant plusieurs séries dans la médiathèque.</p>
</SettingsSection>
<!-- Surveillances orphelines -->
<SettingsSection
v-if="syncOrphans.length > 0"
:title="`Surveillances orphelines · ${syncOrphans.length}`"
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
:title="`Séries surveillées disparues du catalogue · ${syncOrphans.length}`"
description="Ces séries n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
>
<div class="flex flex-col rounded-field bg-main border border-border-light">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
@@ -111,7 +106,7 @@ import { RouterLink } from 'vue-router'
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useSeriesStore } from '@/stores/series'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
@@ -153,10 +148,10 @@ async function fetchSyncOrphans() {
async function removeSyncOrphan(serieId: number) {
try {
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
if (!res.ok) { toast('Impossible de retirer la surveillance', 'error'); return }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
watched.value = watched.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
toast('Surveillance retirée', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
@@ -167,8 +162,8 @@ async function runRss() {
try {
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de vérifier les séries surveillées', 'error'); return }
toast(data.sent > 0 ? `${plural(data.sent, 'épisode envoyé', 'épisodes envoyés')} au client` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -183,11 +178,11 @@ async function update() {
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
toast(`${count} séries chargées ✓`, 'success')
toast(`Catalogue synchronisé : ${plural(count, 'série')}`, 'success')
fetchSyncOrphans()
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
toast(error ?? 'Impossible de synchroniser le catalogue', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+21 -20
View File
@@ -7,11 +7,10 @@
</button>
</Teleport>
<!-- Clients configurés -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Clients configurés</h3>
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
<p class="text-meta text-muted">FanKarr range ses torrents dans une catégorie dédiée (« fankai » par défaut) et ne touche pas aux autres.</p>
</div>
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
@@ -57,11 +56,10 @@
</div>
</section>
<!-- Types pris en charge -->
<section class="bg-card rounded-card overflow-hidden">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Types pris en charge</h3>
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
<h3 class="card-title">Clients compatibles</h3>
<p class="text-meta text-muted">Fonctions disponibles selon le client. Vous pouvez en configurer plusieurs.</p>
</div>
<div class="overflow-x-auto border-t border-hover">
<table class="w-full min-w-[720px] text-meta">
@@ -73,7 +71,7 @@
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppression des fichiers</th>
</tr>
</thead>
<tbody>
@@ -91,9 +89,9 @@
</div>
</td>
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Immédiat' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Via FanKarr' : row.torrentUpload === 'url' ? 'Lien direct' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
</tr>
@@ -101,13 +99,12 @@
</table>
</div>
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
<span><span class="text-ok">Immédiat</span> : seuls les fichiers choisis sont téléchargés, dès la réception des métadonnées.</span>
<span><span class="text-ok">Via FanKarr</span> : FanKarr récupère le fichier .torrent et le transmet au client (fonctionne avec les liens protégés).</span>
<span><span class="text-secondary">Lien direct</span> : le client récupère lui-même le fichier .torrent.</span>
</div>
</section>
<!-- Modale ajout / modification -->
<Teleport to="body">
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
@@ -155,28 +152,32 @@ function matrixHealth(type: string) {
const clientMatrix = [
{
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe · Clé API (5.2+)", fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
{
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: 'Adresse XML-RPC', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'utorrent', label: 'uTorrent', version: '3.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
},
{
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
}
},
{
id: 'deluge', label: 'Deluge', version: 'Interface web',
auth: 'Mot de passe', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
]
const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
@@ -211,7 +212,7 @@ async function testClient(uuid: string) {
try {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
toast(ok ? 'Connexion réussie' : (message ?? 'Échec de la connexion'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -229,7 +230,7 @@ async function deleteClient(client: SavedTorrentClient) {
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
toast('Impossible de supprimer le client', 'error')
}
}
+71 -72
View File
@@ -5,18 +5,17 @@
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]" title="Repère les épisodes présents sur le disque mais pas encore suivis">
<Loader v-if="scanning" :size="15" class="animate-spin" />
<ScanSearch v-else :size="15" />
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
</Teleport>
<!-- ── Nommage ────────────────────────────────────────────── -->
<SettingsSection title="Nommage">
<template #description>
FanKarr renomme les fichiers d’après le catalogue Fankai, en
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
FanKarr renomme les fichiers d'après le catalogue Fankai.
<span class="text-secondary">{{ nfoSupport ? 'NFO activés : chaque fichier prend le nom de son NFO.' : 'NFO désactivés : noms du type Titre.S01E01.' }}</span>
</template>
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
@@ -24,7 +23,8 @@
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
<span class="text-meta text-muted">
<template v-if="loading">Analyse des fichiers importés…</template>
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
<template v-else-if="seriesNeedingRename > 1">{{ totalNeedsRename }} épisodes répartis sur {{ seriesNeedingRename }} séries ne portent pas le nom attendu.</template>
<template v-else-if="totalNeedsRename > 0">{{ plural(totalNeedsRename, 'épisode ne porte', 'épisodes ne portent') }} pas le nom attendu.</template>
<template v-else>Tous les fichiers portent le nom attendu.</template>
</span>
</div>
@@ -33,7 +33,7 @@
</button>
</div>
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
{{ plural(renameResult.done, 'fichier renommé', 'fichiers renommés') }}<template v-if="renameResult.errors.length > 0"> · {{ plural(renameResult.errors.length, 'erreur') }}</template>
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
</p>
@@ -43,7 +43,7 @@
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
<span class="text-meta text-muted">
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
<template v-else>{{ staleFolders.length > 1 ? `${staleFolders.length} dossiers ne portent pas le titre de leur série` : 'Un dossier ne porte pas le titre de sa série' }} : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
</span>
</div>
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -54,24 +54,23 @@
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
<span class="text-sm font-medium text-primary">Tout resynchroniser</span>
<span class="text-meta text-muted">Recharge entièrement le catalogue, met à jour les identifiants d'épisodes et renomme les fichiers. Utile si des épisodes ne sont plus reconnus.</span>
</div>
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
{{ forcingRefresh ? 'Resynchronisation…' : 'Lancer' }}
</button>
</div>
</SettingsSection>
<!-- ── Métadonnées ────────────────────────────────────────── -->
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
<SettingsSection title="Métadonnées" description="Fichiers NFO et images, pour Infuse ou les lecteurs sans agent Fankai.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
<span class="text-meta text-muted">
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
<template v-if="!nfoSupport">Compare les NFO locaux à ceux du dépôt Fankai. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux à ceux du dépôt Fankai (toutes les heures). Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux à ceux du dépôt Fankai et télécharge ceux qui ont changé (toutes les heures).</template>
</span>
</div>
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -90,22 +89,22 @@
</div>
</SettingsSection>
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
<SettingsSection title="Cohérence de la médiathèque" description="Compare les épisodes suivis par FanKarr au contenu du disque et au catalogue.">
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">{{ trackedFiles > 1 ? 'fichiers suivis' : 'fichier suivi' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">{{ orphans.length > 1 ? 'épisodes disparus du catalogue' : 'épisode disparu du catalogue' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">{{ missingFiles > 1 ? 'fichiers introuvables' : 'fichier introuvable' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis la dernière analyse</span></span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
<span class="text-sm font-medium text-primary">Retirer les épisodes disparus du catalogue</span>
<span class="text-meta text-muted">
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
<template v-if="orphans.length === 0">Aucun épisode suivi n'a disparu du catalogue.</template>
<template v-else-if="orphans.length === 1">Retire du suivi l'épisode qui n'existe plus dans le catalogue. Aucun fichier n'est supprimé.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n'existent plus dans le catalogue. Aucun fichier n'est supprimé.</template>
</span>
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
@@ -113,7 +112,7 @@
</button>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ removingOrphans ? '…' : 'Nettoyer' }}
{{ removingOrphans ? 'Retrait…' : 'Retirer' }}
</button>
</div>
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
@@ -121,7 +120,7 @@
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? 'Chemin inconnu' }}</span>
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
</div>
@@ -131,7 +130,7 @@
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
<span class="text-meta text-muted">À utiliser si Fankai a renommé une série et que ses épisodes ne sont plus reconnus.</span>
</div>
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ migrating ? 'Migration…' : 'Migrer' }}
@@ -139,12 +138,11 @@
</div>
</SettingsSection>
<!-- ── Fichiers importés, série par série ──────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
<div class="flex flex-col gap-0.5">
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ plural(series.length, 'série') }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode. Renommez ou retirez les épisodes un par un.</p>
</div>
<button
v-if="totalNeedsRename > 0"
@@ -162,7 +160,7 @@
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
<p class="text-body text-primary">Aucune série importée</p>
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
<p class="text-meta text-muted">Importez des séries depuis la page Activité ou par l'import manuel d'une fiche série.</p>
</div>
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
@@ -176,13 +174,13 @@
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
</button>
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ plural(serie.total, 'épisode') }}</span>
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ plural(missingIn(serie), 'introuvable') }}</span>
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
<div class="flex items-center gap-1.5 ml-auto">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
</div>
</div>
@@ -210,10 +208,10 @@
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
{{ renamingEp[ep.episode_id] ? 'Renommage…' : 'Renommer' }}
</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer le fichier</button>
</div>
</div>
</div>
@@ -228,12 +226,12 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
@@ -259,7 +257,7 @@ const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
@@ -277,17 +275,16 @@ const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function load() {
loading.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
if (!res.ok) { toast('Impossible de charger les fichiers importés', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
orphans.value = data.orphans ?? []
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally {
loading.value = false
@@ -313,8 +310,8 @@ async function scan() {
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
if (!res.ok) { toast(data.error ?? "Impossible d'analyser la médiathèque", 'error'); return }
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -327,17 +324,17 @@ async function renameFolders() {
const targets = staleFolders.value
if (!targets.length) return
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer ${targets.length > 1 ? 'les dossiers' : 'le dossier'} en ${names} ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
renamingFolders.value = true
let moved = 0
try {
for (const f of targets) {
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'impossible de renommer le dossier'}`, 'error'); continue }
moved += d.moved ?? 0
}
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
if (moved > 0) toast(plural(moved, 'fichier déplacé', 'fichiers déplacés'), 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -351,7 +348,7 @@ async function checkNfo() {
try {
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
const data = await res.json()
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Impossible de lancer la vérification des NFO'), res.ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -364,8 +361,8 @@ async function migrateIds() {
try {
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de migrer les identifiants', 'error'); return }
toast(data.updated > 0 ? plural(data.updated, 'identifiant migré', 'identifiants migrés') : 'Aucun identifiant à migrer', 'success')
if (data.updated > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -391,7 +388,8 @@ async function renameAll(serieId?: number) {
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
} catch {
@@ -411,7 +409,7 @@ async function renameSingle(ep: any, serie: any) {
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast(data.error ?? "Impossible de renommer l'épisode", 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
@@ -431,12 +429,13 @@ async function forceMetadataRefresh() {
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de tout resynchroniser', 'error'); return }
const parts = [plural(data.count, 'série rechargée', 'séries rechargées')]
if (data.migrated > 0) parts.push(plural(data.migrated, 'identifiant migré', 'identifiants migrés'))
if (data.deduped > 0) parts.push(plural(data.deduped, 'doublon retiré', 'doublons retirés'))
parts.push(plural(data.renamed, 'fichier renommé', 'fichiers renommés'))
if (data.errors > 0) parts.push(plural(data.errors, 'erreur'))
toast(parts.join(' · '), data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -446,13 +445,13 @@ async function forceMetadataRefresh() {
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de la médiathèque ? Les vidéos ne sont pas touchées.')) return
purgingNfo.value = true
try {
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de purger les NFO', 'error'); return }
toast(`${plural(data.deleted, 'fichier supprimé', 'fichiers supprimés')}${data.errors.length > 0 ? ` (${plural(data.errors.length, 'erreur')})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -461,15 +460,16 @@ async function purgeNfo() {
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
if (!confirm(deleteFile
? `Retirer « ${serie.serie_title} » et supprimer ses fichiers du disque ? Cette action est irréversible.`
: `Retirer « ${serie.serie_title} » de la médiathèque ? Les fichiers restent sur le disque.`)) return
renamingAll.value = true
try {
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer les fichiers de la série' : 'Impossible de retirer la série'), 'error'); return }
toast(deleteFile ? 'Épisodes retirés et fichiers supprimés' : 'Épisodes retirés de la médiathèque', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -481,15 +481,14 @@ async function unimportSerie(serie: any, deleteFile: boolean) {
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -499,7 +498,7 @@ async function unimportEp(ep: any, deleteFile: boolean) {
}
async function removeOrphans(episodeIds?: number[]) {
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
if (!episodeIds && !confirm(`Retirer du suivi ${plural(orphans.value.length, 'épisode disparu', 'épisodes disparus')} du catalogue ? Aucun fichier n'est supprimé.`)) return
removingOrphans.value = true
try {
const res = await fetch('/api/organized-summary/orphans', {
@@ -509,8 +508,8 @@ async function removeOrphans(episodeIds?: number[]) {
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de retirer les épisodes disparus', 'error'); return }
toast(`${plural(data.removed, 'épisode retiré', 'épisodes retirés')} du suivi`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
+16 -18
View File
@@ -2,41 +2,38 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
</Teleport>
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
</SettingsSection>
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
<SettingsSection
title="Synchronisation des utilisateurs"
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures."
>
<div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span>
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
<span v-else-if="syncResult" class="text-meta text-secondary">
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
</span>
</div>
</SettingsSection>
<!-- ── API publique ───────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">API publique</h3>
<p class="text-meta text-muted">
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
Adresse : <span class="text-secondary">{{ apiBase }}</span> · Authentification : jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
</p>
</div>
<div
@@ -57,6 +54,7 @@ import { ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import { plural } from '@/utils/format'
const hasToken = ref(false)
const syncing = ref(false)
@@ -66,12 +64,12 @@ const syncError = ref<string | null>(null)
const apiBase = `${window.location.origin}/api/v1`
const apiEndpoints = [
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
{ method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/series/:id', desc: 'Obtenir la fiche complète (saisons et épisodes)' },
{ method: 'POST', path: '/requests', desc: 'Créer ou compléter une demande au nom de l\'utilisateur' },
{ method: 'GET', path: '/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
]
async function syncUsers() {
@@ -81,7 +79,7 @@ async function syncUsers() {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin.'
syncing.value = false
}
</script>
+8 -15
View File
@@ -8,7 +8,6 @@
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
</Teleport>
<!-- Filtres -->
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button
@@ -43,26 +42,23 @@
class="segmented-item"
:class="{ 'is-active': limit === n }"
:aria-pressed="limit === n"
>{{ n === 2000 ? 'Tout' : n }}</button>
>{{ n.toLocaleString('fr-FR') }}</button>
</div>
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
</div>
</div>
<!-- Chargement -->
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
</div>
<!-- Table -->
<section v-else class="bg-card rounded-card overflow-hidden">
<div class="overflow-x-auto">
<div class="min-w-[640px]">
@@ -90,9 +86,8 @@
</div>
</section>
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
<p class="text-meta text-muted">{{ plural(entries.length, 'entrée affichée', 'entrées affichées') }} · fichier de {{ formatSize(fileSize) }}</p>
<!-- Confirmation -->
<Teleport to="body">
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
@@ -115,7 +110,7 @@
import { ref, computed, onMounted } from 'vue'
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatSize } from '@/utils/format'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -130,13 +125,12 @@ const limit = ref(100)
const levels = [
{ label: 'Tout', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Informations', value: 'info' },
{ label: 'Avertissements', value: 'warn' },
{ label: 'Erreurs', value: 'error' },
{ label: 'Debug', value: 'debug' },
{ label: 'Débogage', value: 'debug' },
]
// Compteurs et sources
const counts = computed(() => {
const c: Record<string, number> = {}
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
@@ -172,7 +166,6 @@ async function doClear() {
await load()
}
// Fichier texte
function exportLogs() {
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
@@ -196,8 +189,8 @@ function fullDate(iso: string): string {
function levelInfo(level: string): { label: string; class: string } {
return ({
debug: { label: 'Debug', class: 'pill-muted' },
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
debug: { label: 'Débogage', class: 'pill-muted' },
info : { label: 'Information', class: 'border-transparent px-0 text-muted' },
warn : { label: 'Avertissement', class: 'pill-wait' },
error: { label: 'Erreur', class: 'pill-err' },
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
+18 -28
View File
@@ -2,20 +2,18 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ plural(dirtyCount, 'modification non enregistrée', 'modifications non enregistrées') }}</span>
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Dossiers -->
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
<div class="grid md:grid-cols-2 gap-4">
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
@@ -39,18 +37,16 @@
</div>
</SettingsSection>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1">
<p class="card-title text-accent">Chemins non configurés</p>
<p class="text-meta text-secondary">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
L'import automatique reste désactivé tant que ces deux dossiers ne sont pas choisis.
</p>
</div>
</div>
<!-- Import -->
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
@@ -66,40 +62,39 @@
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
</div>
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
<span class="text-meta text-muted">Recommandé : le hardlink laisse le fichier en partage dans le client sans occuper deux fois l'espace. Les deux dossiers doivent être sur le même disque.</span>
</div>
<div class="h-px bg-hover" />
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
description="Importe les téléchargements terminés (vérification toutes les 5 minutes)."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO et métadonnées"
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
label="Fichiers NFO et images"
description="À chaque import, ajoute les fichiers de description (.nfo) et les images. Utile pour Infuse ou les lecteurs sans agent Fankai."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
description="Le torrent quitte le client une fois ses fichiers déplacés et n'est plus partagé."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier a disparu"
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
label="Retirer les épisodes dont le fichier a disparu"
description="Lors de l'analyse, retire de la médiathèque les épisodes dont le fichier n'existe plus."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » plutôt que « Saison »"
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
label="Dossiers de saison en anglais"
description="« Season 01 » au lieu de « Saison 1 »."
/>
</SettingsSection>
<!-- Serveurs média -->
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
<SettingsSection title="Serveurs multimédias" description="Les applications qui liront la médiathèque.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -120,7 +115,6 @@
</div>
</SettingsSection>
<!-- Scan -->
<div class="flex items-center gap-3 flex-wrap">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<Loader v-if="scanning" :size="15" class="animate-spin" />
@@ -128,13 +122,12 @@
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
<span v-if="lastScan" class="text-meta text-muted">
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
Dernière analyse {{ formatRelative(lastScan.at) }} · {{ plural(lastScan.found, 'fichier trouvé', 'fichiers trouvés') }}<template v-if="lastScan.added"> · {{ plural(lastScan.added, 'ajouté') }}</template>
</span>
</div>
</template>
<!-- Choix de dossier -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@@ -142,14 +135,12 @@
@cancel="picker.open = false"
/>
<!-- Assistant Plex -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
@@ -178,7 +169,7 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
@@ -198,7 +189,7 @@ const savedSnapshot = ref('')
const pathFields = [
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Dossier racine lu par Jellyfin, Plex ou Kodi.' },
] as const
function onNfoToggle(val: boolean) {
@@ -231,7 +222,6 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
function isRootPath(p: string): boolean { return !p || p === '/' }
// Réglages modifiés
const dirtyCount = computed(() => {
if (!savedSnapshot.value) return 0
const saved = JSON.parse(savedSnapshot.value)
@@ -259,7 +249,7 @@ async function save() {
body: JSON.stringify(form.value),
})
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
else toast('Erreur lors de la sauvegarde', 'error')
else toast("Impossible d'enregistrer les réglages", 'error')
} finally {
saving.value = false
}
@@ -272,9 +262,9 @@ async function scan() {
if (res.ok) {
const data = await res.json()
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
} else {
toast("Erreur lors de l'analyse", 'error')
toast("Impossible d'analyser la médiathèque", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+9 -13
View File
@@ -1,8 +1,7 @@
<template>
<div class="flex flex-col gap-4">
<!-- ── Compte ─────────────────────────────────────────────── -->
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
<SettingsSection title="Compte" description="Le nom d'utilisateur sert à la connexion et apparaît sur vos demandes.">
<div class="flex items-center gap-[18px]">
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
{{ auth.username?.charAt(0).toUpperCase() }}
@@ -21,7 +20,6 @@
</div>
</SettingsSection>
<!-- ── Mot de passe ──────────────────────────────────────── -->
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
<div class="grid sm:grid-cols-3 gap-4">
<div class="flex flex-col gap-[7px]">
@@ -48,14 +46,12 @@
</SettingsSection>
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
<!-- ── Apparence ───────────────────────────────────────── -->
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
<SettingsSection title="Apparence" description="Couleur d'accent de l'interface. Ce choix ne s'applique qu'à ce navigateur.">
<ThemePicker />
</SettingsSection>
<!-- ── Jeton d'API ─────────────────────────────────────── -->
<div data-tour="profile-token">
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<SettingsSection title="Jeton d'API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<div class="flex items-center gap-2.5 flex-wrap">
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
{{ showToken ? myToken : maskedToken }}
@@ -74,8 +70,8 @@
</button>
</div>
<p class="text-xs text-muted">
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l'ancien.
Authentification par l'en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
</p>
</SettingsSection>
</div>
@@ -101,7 +97,7 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
// ── Mot de passe ──────────────────────────────────────────────
// ── Mot de passe ──
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
@@ -120,7 +116,7 @@ async function changePassword() {
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
}
if (newPassword.value.length < 6) {
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
pwdError.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return
}
changingPwd.value = true
@@ -138,12 +134,12 @@ async function changePassword() {
confirmPassword.value = ''
setTimeout(() => { pwdSuccess.value = false }, 3000)
} else {
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
pwdError.value = data.error ?? 'Impossible de changer le mot de passe.'
}
changingPwd.value = false
}
// ── Jeton d'API 2───────────────────────────────────────────────
// ── Jeton d'API ──
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
+12 -11
View File
@@ -1,11 +1,12 @@
<template>
<div class="flex flex-col md:flex-row min-h-full">
<!-- h-auto! annule le h-full du layout, sinon le menu sticky défile avec la page -->
<div class="flex flex-col md:flex-row min-h-full h-auto!">
<nav
v-if="groups.length > 1"
ref="navRef"
data-tour="settings-menu"
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-dvh md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
>
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
@@ -56,15 +57,15 @@ watch(() => route.path, async () => {
}, { immediate: true })
const pages: Record<string, { title: string; description: string }> = {
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
'/settings/profile' : { title: 'Mon profil', description: "Votre compte, votre mot de passe et l'apparence de l'application." },
'/settings/users' : { title: 'Utilisateurs et invitations', description: "Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l'activité ni les paramètres." },
'/settings/media-management' : { title: 'Gestion des médias', description: "Dossiers, mode d'import et serveurs multimédias." },
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
}
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
+30 -35
View File
@@ -7,7 +7,6 @@
</button>
</Teleport>
<!-- ── Comptes ────────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
@@ -18,7 +17,7 @@
</div>
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
<span>Utilisateur</span><span>Rôle</span><span>Dernière connexion</span><span>Jeton d'API</span><span />
</div>
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
@@ -41,7 +40,7 @@
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
</span>
<span class="text-[13px] text-secondary order-4 md:order-none">
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
<span class="md:hidden text-muted">Dernière connexion : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
</span>
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
@@ -72,10 +71,9 @@
</div>
</section>
<!-- ── Invitations ────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ plural(activeInvites, 'lien actif', 'liens actifs') }}</span></h3>
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
</div>
@@ -116,10 +114,9 @@
</div>
</section>
<!-- ── Téléchargement auto ────────────── -->
<SettingsSection
title="Téléchargement automatique"
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
title="Approbation automatique"
description="Les demandes de ces utilisateurs sont approuvées et téléchargées sans attendre votre validation."
>
<template #actions>
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -129,7 +126,7 @@
:model-value="autoDownloadAll"
@update:model-value="setAutoDownloadAll"
label="Tous les utilisateurs"
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
description="Toutes les demandes sont approuvées et téléchargées automatiquement."
/>
<template v-if="!autoDownloadAll">
@@ -143,7 +140,7 @@
<button
type="button" role="switch"
:aria-checked="autoDownloadUserIds.includes(u.id)"
:aria-label="`Téléchargement automatique pour ${u.username}`"
:aria-label="`Approbation automatique pour ${u.username}`"
@click="toggleAutoDownloadUser(u.id)"
class="switch"
><span class="switch-knob" /></button>
@@ -152,7 +149,6 @@
</template>
</SettingsSection>
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
<Teleport to="body">
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
@@ -162,13 +158,13 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="user-name" class="field-label">Identifiant</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
<label for="user-name" class="field-label">Nom d'utilisateur</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" autocomplete="off" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" />
</div>
<div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span>
@@ -184,14 +180,13 @@
<div class="flex gap-2.5 justify-end">
<button @click="userModal = null" class="btn-ghost">Annuler</button>
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
{{ userModal.loading ? (userModal.mode === 'create' ? 'Création…' : 'Enregistrement…') : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Modal créer invitation ─────────────────────────────── -->
<Teleport to="body">
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
@@ -199,12 +194,12 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="invite-note" class="field-label">Note (facultative)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
<label for="invite-note" class="field-label">Message pour la personne invitée (facultatif)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-[7px]">
<label for="invite-uses" class="field-label">Utilisations max</label>
<label for="invite-uses" class="field-label">Nombre d'utilisations</label>
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
</div>
<div class="flex flex-col gap-[7px]">
@@ -212,7 +207,7 @@
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
</div>
</div>
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
<p class="text-xs text-muted">Laissez un champ vide pour ne pas fixer de limite.</p>
</div>
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
@@ -220,7 +215,7 @@
<div class="flex gap-2.5 justify-end">
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
{{ inviteModal.loading ? 'Création…' : 'Créer le lien' }}
</button>
</div>
</div>
@@ -267,13 +262,13 @@ const copied = ref<string | null>(null)
const pendingByUser = ref<Map<string, number>>(new Map())
const userMenu = ref<string | null>(null)
// ── Téléchargement auto des demandes ──────────────────────────
// ── Approbation automatique ──
const autoDownloadAll = ref(false)
const autoDownloadUserIds = ref<string[]>([])
const autoDownloadSaved = ref(false)
let autoDownloadTimer: ReturnType<typeof setTimeout> | null = null
// ── Modals ────────────────────────────────────────────────────
// ── Modales ──
const userModal = ref<{
mode : 'create' | 'edit'
id? : string
@@ -292,7 +287,7 @@ const inviteModal = ref<{
loading : boolean
} | null>(null)
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function loadUsers() {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) users.value = await res.json()
@@ -361,7 +356,7 @@ onMounted(() => {
})
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Users ─────────────────────────────────────────────────────
// ── Utilisateurs ──
function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
}
@@ -376,8 +371,8 @@ async function submitUserModal() {
m.error = null
m.loading = true
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
if (!m.username) { m.error = "Nom d'utilisateur requis."; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis.'; m.loading = false; return }
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
const method = m.mode === 'create' ? 'POST' : 'PATCH'
@@ -385,15 +380,15 @@ async function submitUserModal() {
if (m.password) body.password = m.password
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible d\'enregistrer le compte.'; m.loading = false; return }
userModal.value = null
loadUsers()
}
async function confirmDelete(u: User) {
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
loadUsers()
}
@@ -408,7 +403,7 @@ function userSubtitle(u: User) {
}
function maskToken(token: string) {
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : 'Aucun jeton'
}
async function copyToken(u: User) {
@@ -419,7 +414,7 @@ async function copyToken(u: User) {
} catch {}
}
// ── Invites ───────────────────────────────────────────────────
// ── Invitations ──
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
function openCreateInvite() {
@@ -436,9 +431,9 @@ async function submitInviteModal() {
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible de créer l\'invitation.'; m.loading = false; return }
inviteModal.value = null
loadInvites()
}
@@ -461,7 +456,7 @@ async function copyInvite(code: string) {
} catch {}
}
// ── Helpers ───────────────────────────────────────────────────
// ── Utilitaires ──
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}