refacto: réécriture des textes, messages et commentaires
This commit is contained in:
1 parent
6a3cb21adf
commit
586eccbbb3
118 files changed
+1621
-2159
No files matched your search
+8
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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' }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 <jeton></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 <jeton></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)
|
||||
|
||||
@@ -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: '' })
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user