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

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

No files matched your search

+3 -4
View File
@@ -48,7 +48,7 @@ export interface Torrent {
url: string
}
// Parse "Nom@url|Nom@url" → Torrent[]
// "Nom@url|Nom@url" en liste de torrents
export function parseTorrents(raw: string | null): Torrent[] {
if (!raw) return []
return raw.split('|').flatMap(item => {
@@ -58,7 +58,6 @@ export function parseTorrents(raw: string | null): Torrent[] {
})
}
// Préfixe les images relatives
export function imageUrl(path: string | null): string | null {
if (!path) return null
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
@@ -67,14 +66,14 @@ export function imageUrl(path: string | null): string | null {
export async function fetchProductions(): Promise<Production[]> {
const res = await fetch(`${BASE}/productions`)
if (!res.ok) throw new Error('Erreur chargement catalogue')
if (!res.ok) throw new Error('Impossible de charger le catalogue.')
const data = await res.json()
return data.pageProps.data
}
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
if (!res.ok) throw new Error('Erreur chargement production')
if (!res.ok) throw new Error('Impossible de charger la série.')
const data = await res.json()
return data.pageProps.data
}
+3 -3
View File
@@ -167,7 +167,7 @@ body {
.pill-err { @apply border-err/35 text-err; }
.pill-muted { @apply border-border-light text-muted; }
/* Filtres et onglets compteurs */
/* Filtres et onglets à compteur */
.chip {
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
@@ -210,7 +210,7 @@ body {
@apply translate-x-5 bg-on-accent;
}
/* Sélecteur */
/* Sélecteur segmenté */
.segmented {
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
}
@@ -236,7 +236,7 @@ body {
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
}
/* Menus déroulants et pop */
/* Menus déroulants et modales */
.menu {
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
}
+5 -11
View File
@@ -3,7 +3,6 @@
<div class="modal-backdrop" @click.self="$emit('cancel')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
<!-- Header -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
<div class="min-w-0">
<p class="card-title">Choisir un dossier</p>
@@ -12,7 +11,6 @@
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
<House :size="15" />
@@ -25,22 +23,20 @@
aria-label="Chemin"
/>
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
OK
Aller
</button>
</div>
<!-- Retour parent -->
<div v-if="parent !== null" class="px-5 shrink-0">
<button
@click="navigateTo(parent)"
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
>
<ArrowLeft :size="14" />
..
Dossier parent
</button>
</div>
<!-- Liste dossiers -->
<div class="overflow-y-auto flex-1">
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
@@ -59,10 +55,8 @@
</button>
</div>
<!-- Footer -->
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
<span class="text-meta text-muted truncate">{{ current }}</span>
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
@@ -93,7 +87,7 @@ function selectCurrent() {
}
function joinPath(base: string, name: string): string {
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
// Lecteur Windows (ex. "C:\") : déjà absolu
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.replace(/[\\/]+$/, '')
@@ -107,7 +101,7 @@ async function navigateTo(p: string) {
try {
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
if (!res.ok) {
error.value = (await res.json()).error ?? 'Erreur'
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
return
}
const data = await res.json()
@@ -117,7 +111,7 @@ async function navigateTo(p: string) {
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
error.value = 'Impossible de lire ce dossier.'
} finally {
loading.value = false
}
+31 -45
View File
@@ -3,47 +3,42 @@
<div class="modal-backdrop" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
<!-- En-tête -->
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div class="min-w-0 flex flex-col gap-1">
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">— {{ serieName }}</span></h3>
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">· {{ serieName }}</span></h3>
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
</div>
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Chargement -->
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Scan du dossier…</p>
<p class="text-body">Analyse du dossier…</p>
</div>
<!-- Dossier vide / introuvable -->
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
<p class="text-meta text-muted">
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
</p>
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Rescanner</button>
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</button>
</div>
<!-- Erreur -->
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
<p class="text-body text-err">{{ scanError }}</p>
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
</div>
<!-- Liste des fichiers -->
<template v-else-if="items.length > 0 && step !== 'done'">
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
<p class="text-meta text-muted">
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
· <span class="text-accent">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
<span v-if="unmatchedCount > 0"> · {{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span>
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
</p>
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Rescanner</button>
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
</div>
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
@@ -80,7 +75,7 @@
@change="onEpisodeChange(i)"
:aria-label="`Épisode associé à ${item.file.name}`"
>
<option :value="null">— Non associé —</option>
<option :value="null">Aucun épisode</option>
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option
v-for="ep in season.episodes"
@@ -88,8 +83,8 @@
:value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
>
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
</option>
</optgroup>
</select>
@@ -108,14 +103,13 @@
:disabled="newMatchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
</button>
</div>
</div>
</template>
<!-- Résultat -->
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
<span
class="w-12 h-12 rounded-full flex items-center justify-center"
@@ -127,16 +121,16 @@
<div class="flex flex-col gap-1">
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
<p class="text-body text-secondary">
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
<span v-if="importResult.errors.length > 0" class="text-err">
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
· {{ plural(importResult.errors.length, 'erreur') }}
</span>
</p>
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
<p class="tag-label text-err mb-1.5">Erreurs</p>
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
<span class="text-primary">{{ e.file }}</span> — {{ e.error }}
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
</p>
</div>
<div class="flex gap-2.5">
@@ -155,6 +149,7 @@ import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
import { matchEpisodeFile } from '@/utils/episode-match'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -171,7 +166,7 @@ const emit = defineEmits<{
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const step = ref<'list' | 'done'>('list')
const scanning = ref(false)
const importing = ref(false)
@@ -189,7 +184,7 @@ const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
interface FileItem {
file : { name: string; path: string; size: number }
episode_id : number | null
originalEpisodeId : number | null // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
hash : string | null
alreadyImported : boolean
willRename : boolean
@@ -197,12 +192,12 @@ interface FileItem {
const items = ref<FileItem[]>([])
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ──────────────────────────────────────────────────
// ── Utilitaires ──
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
@@ -236,13 +231,6 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
function onEpisodeChange(i: number) {
const item = items.value[i]
if (!item) return
@@ -252,8 +240,7 @@ function onEpisodeChange(i: number) {
item.alreadyImported = false
item.willRename = newEpId !== null
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
// Le fichier choisi remplace ceux déjà importés pour cet épisode
if (newEpId !== null) {
items.value = items.value.filter(other =>
other === item || !(other.episode_id === newEpId && other.alreadyImported)
@@ -261,7 +248,7 @@ function onEpisodeChange(i: number) {
}
}
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function scan() {
scanning.value = true
scanError.value = ''
@@ -271,7 +258,7 @@ async function scan() {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
if (!res.ok) {
const data = await res.json()
scanError.value = data.error ?? 'Erreur lors du scan'
scanError.value = data.error ?? "Impossible d'analyser le dossier."
return
}
const data = await res.json()
@@ -281,7 +268,6 @@ async function scan() {
const organizedByPath = buildOrganizedByPath()
items.value = files.map(f => {
// Vérifier si déjà importé via dest_path
const existingEpId = organizedByPath.get(f.path) ?? null
if (existingEpId) {
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
@@ -291,7 +277,7 @@ async function scan() {
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
})
} catch {
scanError.value = 'Impossible de contacter le serveur'
scanError.value = 'Impossible de contacter le serveur.'
} finally {
scanning.value = false
}
@@ -306,7 +292,7 @@ async function doImport() {
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
if (payload.length === 0) {
importError.value = 'Aucun nouveau fichier à importer'
importError.value = 'Aucun nouveau fichier à importer.'
importing.value = false
return
}
@@ -320,26 +306,26 @@ async function doImport() {
})
const data = await res.json()
if (!res.ok) {
importError.value = data.error ?? 'Erreur inconnue'
toast(data.error ?? 'Erreur lors de l\'import', 'error')
importError.value = data.error ?? "Impossible d'importer les fichiers."
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
return
}
importResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) {
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
}
if ((data.errors ?? []).length > 0) {
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
}
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
// Le parent recharge les épisodes importés
emit('imported')
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
importError.value = 'Impossible de contacter le serveur.'
toast('Impossible de contacter le serveur', 'error')
} finally {
importing.value = false
@@ -348,7 +334,7 @@ async function doImport() {
function backToList() {
step.value = 'list'
scan() // re-scan → les fichiers importés passent en "déjà importé"
scan() // nouvelle analyse pour marquer les fichiers importés
}
onMounted(scan)
-1
View File
@@ -28,7 +28,6 @@
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
<!-- Nbre de messages regroupés -->
<span
v-if="t.count && t.count > 1"
class="shrink-0 pill h-5 px-2 text-[10.5px]"
@@ -1,6 +1,5 @@
<template>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
<!-- Onglets à compteurs -->
<div class="chip-row">
<button
v-for="t in tabs" :key="t.value"
@@ -32,7 +31,7 @@
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
/>
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer le filtre" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
<X :size="13" />
</button>
</label>
@@ -46,7 +45,6 @@
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
</label>
<!-- Tri, colonnes, seeds -->
<div ref="optionsRef" class="relative">
<button
@click="optionsOpen = !optionsOpen"
@@ -79,7 +77,7 @@
<div class="h-px bg-hover my-1" />
<label class="menu-item cursor-pointer justify-between">
Seeds uniquement
Masquer les états inconnus
<button
type="button" role="switch" :aria-checked="seedingOnly"
@click="emit('update:seedingOnly', !seedingOnly)"
+16 -24
View File
@@ -2,7 +2,6 @@
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
<div class="flex items-center gap-4 flex-1 min-w-0">
<!-- Affiche -->
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
@@ -12,17 +11,15 @@
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
<!-- Nom, état, client -->
<div class="flex items-center gap-2.5 min-w-0">
<div class="relative group/eps min-w-0">
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
</p>
<!-- Épisodes du torrent -->
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
<div class="menu p-3 gap-0">
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
<p class="tag-label mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
<p class="tag-label mb-2">{{ plural(episodesWithProgress.length, 'épisode') }}</p>
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
@@ -31,7 +28,7 @@
class="pill h-5 px-2 text-[10.5px]"
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
>
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
{{ ep.priority === 0 ? 'Exclu' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
</span>
</div>
</div>
@@ -43,20 +40,18 @@
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }}&nbsp;%</span>
</div>
<!-- Progression, vitesse, ETA -->
<template v-if="!isDone">
<div class="progress">
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
</div>
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">reste {{ formatDuration(torrent.eta) }}</span>
<span v-if="torrent.state === 'downloading' && torrent.eta > 0 && formatDuration(torrent.eta) !== '∞'">reste {{ formatDuration(torrent.eta) }}</span>
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
</div>
</template>
<!-- Terminé : erreurs d'import ou détails -->
<template v-else>
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
@@ -75,20 +70,19 @@
<span>
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
</span>
<span v-if="torrent.state === 'unknown'" class="text-accent">État non remonté par le client</span>
<span v-if="torrent.state === 'unknown'" class="text-accent">Le client n'indique pas l'état de ce torrent</span>
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
<span v-if="columns.uploaded && torrent.uploaded">↑ {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
<span v-if="columns.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
<span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
</div>
</template>
</div>
</div>
<!-- État d'import et actions -->
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
<template v-if="!isDone">
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import auto à la fin' : 'Import manuel à la fin') : 'Série non reconnue' }}</span>
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import automatique à la fin' : 'À importer une fois terminé') : 'Série non reconnue' }}</span>
</template>
<template v-else>
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
@@ -109,30 +103,29 @@
</button>
</template>
<!-- Supprimer -->
<div class="relative">
<button
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
:disabled="deleting"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
:title="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
:title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
:aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
>
<Trash2 :size="14" />
</button>
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
<p class="text-body text-primary font-medium">
{{ isDone ? 'Supprimer ce torrent ?' : 'Annuler ce téléchargement ?' }}
{{ isDone ? 'Supprimer ce torrent du client ?' : 'Arrêter ce téléchargement ?' }}
</p>
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
Supprimer aussi les fichiers
Supprimer aussi les fichiers du disque
</label>
<div class="flex gap-2">
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
{{ deleting ? '…' : 'Confirmer' }}
{{ deleting ? 'Suppression…' : isDone ? 'Supprimer' : 'Arrêter' }}
</button>
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">Annuler</button>
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">{{ isDone ? 'Garder' : 'Continuer' }}</button>
</div>
</div>
</div>
@@ -145,7 +138,7 @@
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
import { formatDuration, formatRelative, formatSize, formatSpeed } from '@/utils/format'
import { formatDuration, formatRelative, formatSize, formatSpeed, plural } from '@/utils/format'
const props = defineProps<{
torrent : any
@@ -172,7 +165,7 @@ const isImported = computed(() => isDone.value && props.torrent.organizeState ==
const clientLine = computed(() => [
props.columns.client ? props.torrent.client_name : null,
isDone.value && props.columns.ratio ? `R ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
isDone.value && props.columns.ratio ? `Ratio ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
].filter(Boolean).join(' · '))
@@ -184,7 +177,6 @@ const errorSummary = computed(() => {
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
})
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
const episodesWithProgress = computed(() => {
const eps: any[] = props.torrent.episodes ?? []
if (!eps.length) return []
@@ -208,5 +200,5 @@ const badge = computed(() => ({
checking : { label: 'Vérification', class: 'pill-wait' },
error : { label: 'Erreur', class: 'pill-err' },
unknown : { label: 'État inconnu', class: 'pill-wait' },
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'Inconnu', class: 'pill-muted' })
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'État inconnu', class: 'pill-muted' })
</script>
-1
View File
@@ -13,7 +13,6 @@
<Search :size="19" :stroke-width="1.75" />
</button>
<!-- Menu du compte -->
<div ref="menuRef" class="relative">
<button
data-tour="help"
+1 -3
View File
@@ -40,7 +40,6 @@
</template>
</nav>
<!-- Nouvelle version disponible -->
<a
v-if="auth.isAdmin && updateAvailable"
:href="latestReleaseUrl"
@@ -51,12 +50,11 @@
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span class="flex-1 min-w-0">
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
<span class="block text-[11.5px] text-muted truncate">{{ latestVersion }} est disponible</span>
<span class="block text-[11.5px] text-muted truncate">Version {{ latestVersion }}</span>
</span>
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
</a>
<!-- Utilisateur et déconnexion -->
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
{{ initial }}
@@ -8,7 +8,7 @@
</div>
<div class="flex flex-col gap-[7px]">
<label for="reject-message" class="field-label">Motif (facultatif)</label>
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Raison du refus…" @keyup.enter="emit('confirm', message)" />
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Visible par l'invité" @keyup.enter="emit('confirm', message)" />
</div>
<div class="flex gap-2.5 justify-end">
<button @click="emit('close')" class="btn-ghost">Annuler</button>
+15 -11
View File
@@ -1,13 +1,16 @@
<template>
<section class="relative overflow-hidden">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
alt=""
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50 pointer-events-none"
/>
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
<section class="relative">
<!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<img
v-if="serie.poster_image"
:src="serie.poster_image"
alt=""
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
/>
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
</div>
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
@@ -39,7 +42,7 @@
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
>{{ statusLabel(serie.status) }}</span>
<span v-if="episodeCount" class="text-body text-secondary">
<template v-if="seasonCount">{{ seasonCount }} saison{{ seasonCount > 1 ? 's' : '' }} · </template>{{ episodeCount }} épisode{{ episodeCount > 1 ? 's' : '' }}
<template v-if="seasonCount">{{ plural(seasonCount, 'saison') }} · </template>{{ plural(episodeCount, 'épisode') }}
</span>
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
<Rss :size="11" :stroke-width="2.5" /> Surveillée
@@ -75,7 +78,7 @@
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
/>
</div>
<span class="text-meta text-secondary">{{ organizedCount }} épisode{{ organizedCount > 1 ? 's' : '' }} {{ organizedLabel }}{{ organizedCount > 1 ? 's' : '' }} sur {{ episodeCount }}</span>
<span class="text-meta text-secondary">{{ plural(organizedCount, `épisode ${organizedLabel}`, `épisodes ${organizedLabel}s`) }} sur {{ episodeCount }}</span>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
@@ -92,6 +95,7 @@ import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
import { statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
defineProps<{
serie : any
+46 -85
View File
@@ -1,6 +1,5 @@
<template>
<section class="bg-card rounded-card">
<!-- En-tête de saison -->
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
<button
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
@@ -14,13 +13,13 @@
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
<h2 class="font-display text-[17px] font-bold text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">— {{ season.title }}</span>
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">· {{ season.title }}</span>
</h2>
<p class="text-meta text-muted">{{ seasonMeta }}</p>
</div>
<div class="flex items-center gap-2.5 flex-wrap">
<!-- ── Mode demande ──── -->
<!-- ── Mode demande ── -->
<template v-if="requestMode">
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -34,11 +33,11 @@
class="btn-secondary btn-sm"
>
<Clock3 :size="14" :stroke-width="2" />
{{ missingCount === season.episodes.length ? 'Demander la saison' : `Demander ${missingCount > 1 ? `les ${missingCount} manquants` : 'le manquant'}` }}
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
</button>
</template>
<!-- ── Mode téléchargement (admin) ──────── -->
<!-- ── Mode téléchargement (admin) ── -->
<template v-else>
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> Saison complète
@@ -80,7 +79,6 @@
</button>
</template>
<!-- Pack saison unique -->
<button
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
class="btn-secondary btn-sm"
@@ -91,7 +89,6 @@
{{ seasonBtnLabel }}
</button>
<!-- Packs saison multiples -->
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
<Download :size="14" />
@@ -114,13 +111,12 @@
</div>
</div>
<!-- Désimport de la saison -->
<button
v-if="season.organized_count > 0"
@click.stop="openUnimportSeasonModal"
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Désimporter la saison"
aria-label="Désimporter la saison"
title="Retirer la saison de la médiathèque"
aria-label="Retirer la saison de la médiathèque"
>
<Trash2 :size="15" />
</button>
@@ -128,7 +124,6 @@
</div>
</div>
<!-- Épisodes -->
<div v-if="!collapsed">
<div
v-for="ep in season.episodes"
@@ -136,7 +131,6 @@
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
>
<div class="flex items-center gap-3 sm:gap-3.5">
<!-- Vignette -->
<div
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
@@ -145,7 +139,6 @@
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
</div>
<!-- Infos épisode -->
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
@@ -157,13 +150,13 @@
<span
v-if="ep.fankai === false || ep.torrent?.fankai === false"
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
title="Ce fichier n'est pas une version officielle Fankai"
>Hors Fankai</span>
</div>
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
</div>
<!-- ── État et actions, mode demande ─────────────────── -->
<!-- ── État et actions, mode demande ── -->
<template v-if="requestMode">
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
@@ -177,7 +170,7 @@
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
</button>
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
<p class="tag-label px-3 pt-1.5 pb-1">Choisir le torrent</p>
<p class="tag-label px-3 pt-1.5 pb-1">Choisir la version</p>
<button
v-for="(t, i) in (ep.torrents as any[])"
:key="i"
@@ -193,7 +186,7 @@
</div>
</template>
<!-- ── État et actions, admin ────────────────────────── -->
<!-- ── État et actions, admin ── -->
<template v-else>
<template v-if="ep.organized">
<button
@@ -246,14 +239,13 @@
@click.stop="openUnimportModal(ep, season)"
:disabled="epActionLoading[ep.id]"
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
title="Désimporter l'épisode"
aria-label="Désimporter l'épisode"
title="Retirer l'épisode de la médiathèque"
aria-label="Retirer l'épisode de la médiathèque"
>
<Trash2 :size="14" />
</button>
</template>
<!-- Synopsis -->
<button
v-if="ep.plot"
@click.stop="togglePlot(ep.id)"
@@ -266,12 +258,10 @@
</button>
</div>
<!-- Progression de l'épisode -->
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
</div>
<!-- Synopsis -->
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
{{ parseEpChap(ep.plot).cleanPlot }}
</p>
@@ -279,13 +269,12 @@
</div>
</section>
<!-- Modal désimport épisode -->
<Teleport to="body">
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-ep-title" class="card-title">Désimporter l'épisode</h3>
<p class="text-meta text-muted">L'épisode sera retiré de la bibliothèque.</p>
<h3 id="unimport-ep-title" class="card-title">Retirer l'épisode de la médiathèque</h3>
<p class="text-meta text-muted">L'épisode sera retiré de la médiathèque. Le fichier reste sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
@@ -294,20 +283,19 @@
<div class="flex gap-2.5 justify-end">
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimport" class="btn-danger">
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
{{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal désimport saison -->
<Teleport to="body">
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
<div class="flex flex-col gap-1">
<h3 id="unimport-season-title" class="card-title">Désimporter la saison</h3>
<p class="text-meta text-muted">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
<h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
<p class="text-meta text-muted">Les épisodes importés de cette saison seront retirés de la médiathèque. Les fichiers restent sur le disque, sauf si vous cochez la case.</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
@@ -316,7 +304,7 @@
<div class="flex gap-2.5 justify-end">
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSeason" class="btn-danger">
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
{{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
</button>
</div>
</div>
@@ -327,6 +315,7 @@
<script setup lang="ts">
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
import { plural } from '@/utils/format'
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
@@ -341,9 +330,9 @@ const props = defineProps<{
downloadingSeason ?: boolean
nfoSupport ?: boolean
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
requestStatus ?: string // statut de la demande en cours de cet user
requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par l'utilisateur pour cette série
requestStatus ?: string // statut de la demande en cours de l'utilisateur
}>()
const emit = defineEmits<{
@@ -362,7 +351,6 @@ const isSeasonRequested = computed(() =>
props.requestedSeasons?.includes(props.season.season_number) ?? false
)
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
function isEpisodeRequested(episodeId: number): boolean {
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
}
@@ -378,7 +366,7 @@ const deleteSeasonFiles = ref(false)
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
// ── Helpers hash / progress ────────────────────────────────────
// ── Hash et progression ──
function extractHash(torrent: any): string | null {
// Préférer l'infohash direct (présent même si le magnet est absent)
if (torrent?.infohash) return torrent.infohash.toLowerCase()
@@ -393,16 +381,14 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
function isDownloading(key: string) { return props.downloading.includes(key) }
function isDownloaded(key: string) { return props.downloaded.includes(key) }
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.
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
// Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Computed saison ────────────────────────────────────────────
// ── État de la saison ──
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
const hasDownloadable = computed(() =>
props.season.episodes.some((ep: any) =>
@@ -414,8 +400,8 @@ const canDownloadSeason = computed(() =>
)
const seasonBtnLabel = computed(() => {
if (props.season.organized_state === 'complete') return 'Importée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajoutée'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée'
if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
return 'Télécharger la saison'
})
const seasonBtnIcon = computed(() => {
@@ -425,7 +411,7 @@ const seasonBtnIcon = computed(() => {
return h(Download, { size: 14 })
})
// ── Résumés de saison ──────────────────────────────────────────
// ── Résumés de saison ──
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
const downloadableCount = computed(() =>
@@ -433,36 +419,35 @@ const downloadableCount = computed(() =>
)
const downloadableLabel = computed(() => {
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
return downloadableCount.value > 1 ? `les ${downloadableCount.value} manquants` : 'le manquant'
return downloadableCount.value > 1 ? `${downloadableCount.value} épisodes` : "l'épisode"
})
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
const seasonMeta = computed(() => {
const total = props.season.episodes.length
const parts = [`${total} épisode${total > 1 ? 's' : ''}`]
const parts = [plural(total, 'épisode')]
if (props.requestMode) {
const ready = props.season.organized_count ?? 0
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : `${ready} disponible${ready > 1 ? 's' : ''}`)
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : plural(ready, 'disponible'))
return parts.join(' · ')
}
if (availableCount.value > 0) parts.push(`${availableCount.value} disponible${availableCount.value > 1 ? 's' : ''}`)
if (props.season.organized_count > 0) parts.push(`${props.season.organized_count} importé${props.season.organized_count > 1 ? 's' : ''}`)
if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
return parts.join(' · ')
})
// Date · durée · langue · sources · plages d'épisodes et de chapitres, en texte
// Date · durée · langue · versions · plages d'épisodes et de chapitres, en texte
function episodeMeta(ep: any): string {
const { episodes, chapters } = parseEpChap(ep.plot)
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} sources` : ''
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} versions` : ''
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
.filter(Boolean).join(' · ')
}
// ── État épisode ───────────────────────────────────────────────
// ── État de l'épisode ──
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
// Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
function epAnyDownloaded(ep: any): boolean {
if (isDownloaded(`ep-${ep.id}`)) return true
const count = ep.torrents?.length ?? 0
@@ -495,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | null {
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
if (active.files && active.files.length > 0) {
const file = active.files.find(f => f.index === t.file_index)
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
// Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
// Pas de données par fichier : ignorer si pas de session active
if (!sessionDl) continue
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
}
@@ -517,11 +499,9 @@ function epProgress(ep: any): ActiveTorrent | null {
function handleEpBtnClick(ep: any) {
if (ep.organized) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs options → toggle dropdown
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Option unique → téléchargement direct
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
}
@@ -530,25 +510,22 @@ function handleEpBtnClick(ep: any) {
function handleEpRequestClick(ep: any) {
if (isEpisodeRequested(ep.id)) return
if (ep.torrents && ep.torrents.length > 1) {
// Plusieurs torrents → afficher le picker
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
return
}
// Torrent unique ou aucun → émettre directement
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
}
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
// Vert uniquement quand importé dans la bibliothèque
if (ep.organized) return 'done'
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
const prog = epProgress(ep)
if (prog) return 'loading'
if (!ep.torrent || !ep.available) return 'unavailable'
return 'idle'
}
// ── Rename helpers ─────────────────────────────────────────────
// ── Renommage ──
function epExpectedName(ep: any): string {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return ''
@@ -556,9 +533,6 @@ function epExpectedName(ep: any): string {
if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else {
// Préférer le formatted_name du torrent qui correspond au fichier importé.
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
// cherche le premier torrent qui a un formatted_name.
const usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
@@ -575,15 +549,10 @@ function epNeedsRename(ep: any): boolean {
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
// ── Épisodes et chapitres cités dans le synopsis ──
// Cache : le synopsis est analysé plusieurs fois par rendu
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
*/
// Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
season: string | null
@@ -637,7 +606,7 @@ function parseEpChap(plot: string | null | undefined) {
return result
}
// ── Détection langue ───────────────────────────────────────────
// ── Langue ──
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
const sources: string[] = [
ep.formatted_name ?? '',
@@ -653,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
return null
}
// ── Labels de groupe ───────────────────────────────────────────
// Si tous les torrent_name sont présents ET distincts → on les utilise.
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
// ── Libellés des versions ──
function labelFromRaw(raw: string, index: number): string {
if (!raw) return `Option ${index + 1}`
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
@@ -666,18 +633,13 @@ function groupLabels(torrents: any[]): string[] {
const allPresent = names.every(n => 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) => labelFromRaw(t.raw ?? '', i))
}
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
// Un torrent individuel par épisode (hash unique) n'est PAS un pack → ignoré ici,
// le bouton "Saison" simple suffira pour lancer tous les épisodes.
// Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
const uniquePackOptions = computed(() => {
if (props.season.torrents?.length) return []
// Compter combien d'épisodes chaque hash couvre
const hashCount = new Map<string, number>()
const hashTorrent = new Map<string, any>()
for (const ep of props.season.episodes) {
@@ -690,7 +652,6 @@ const uniquePackOptions = computed(() => {
}
}
// Garder uniquement les hash qui couvrent ≥ 2 épisodes (= vrais packs / intégrales)
const packs = [...hashCount.entries()]
.filter(([, count]) => count > 1)
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
+4 -4
View File
@@ -4,7 +4,7 @@
<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">
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
<span class="text-[13px] text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
<span class="text-[13px] text-muted">{{ plural(count, 'série') }}</span>
</div>
<div class="flex items-center gap-2.5 w-full md:w-auto">
@@ -16,7 +16,7 @@
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
@keydown.esc="emit('update:search', '')"
type="text"
:placeholder="selectable ? 'Rechercher dans la médiathèque' : 'Rechercher une série'"
placeholder="Rechercher une série"
aria-label="Rechercher une série"
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
/>
@@ -39,7 +39,6 @@
</div>
</header>
<!-- Filtres, tri et taille -->
<div ref="rowRef" class="relative">
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
@@ -81,7 +80,7 @@
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
:aria-pressed="posterSize === size"
:title="`Affiches ${size}`"
:title="`Taille des affiches : ${size}`"
>
{{ size }}
</button>
@@ -120,6 +119,7 @@
import { ref, computed } from 'vue'
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { plural } from '@/utils/format'
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
@@ -9,8 +9,8 @@
<div class="flex flex-col gap-[7px]">
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Tableau de bord → Clés API'" />
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord → Clés API → Créer une clé.</p>
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Collez la clé API'" />
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
</div>
</div>
@@ -27,6 +27,7 @@
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
<span v-if="saveError" class="text-meta text-err">{{ saveError }}</span>
</div>
</div>
</template>
@@ -46,6 +47,7 @@ const jellyfinAdminToken = ref('')
const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const saveError = ref<string | null>(null)
const testing = ref(false)
const testResult = ref<JellyfinTestResult | null>(null)
@@ -64,15 +66,19 @@ onMounted(async () => {
})
async function save() {
saving.value = true
saved.value = false
saving.value = true
saved.value = false
saveError.value = null
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (res.ok) {
}).catch(() => null)
if (!res?.ok) {
const d = await res?.json().catch(() => ({}))
saveError.value = d?.error ?? 'Impossible d\'enregistrer les réglages Jellyfin.'
} else {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = ''
+26 -28
View File
@@ -3,7 +3,6 @@
<div class="modal-backdrop" @click.self="emit('close')">
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
<!-- Header -->
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
<div>
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
@@ -12,13 +11,13 @@
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
plexStep === 'done' ? 'Configuration terminée' :
'Erreur' }}
'' }}
</p>
</div>
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<!-- Étape 1 : Auth -->
<!-- Étape 1 : connexion -->
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
<button
@@ -30,7 +29,7 @@
</div>
<div>
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
<p class="text-meta text-muted">Google, Apple, ou compte Plex</p>
<p class="text-meta text-muted">Google, Apple ou compte Plex</p>
</div>
</button>
@@ -43,22 +42,20 @@
</div>
<div>
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
<p class="text-meta text-muted">Connexion directe avec vos identifiants</p>
<p class="text-meta text-muted">Sans passer par la page de connexion Plex</p>
</div>
</button>
</div>
<!-- OAuth en attente -->
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
<div>
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
<p class="text-meta text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
<p class="text-meta text-muted mt-1">Connectez-vous dans l'onglet Plex qui vient de s'ouvrir. Rien ne s'est ouvert ? Autorisez les fenêtres pop-up.</p>
</div>
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
</div>
<!-- Formulaire credentials -->
<template v-else-if="plexAuthMethod === 'credentials'">
<div>
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
@@ -69,7 +66,7 @@
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
</div>
<div v-if="plexNeeds2FA">
<label class="field-label mb-1 block">Code 2FA</label>
<label class="field-label mb-1 block">Code de validation (6 chiffres)</label>
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
</div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
@@ -85,9 +82,9 @@
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
</div>
<!-- Étape 2 : Choix serveur -->
<!-- Étape 2 : choix du serveur -->
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
<p class="text-meta text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
<p class="text-meta text-muted">{{ plural(plexServers.length, 'serveur trouvé', 'serveurs trouvés') }}</p>
<div
v-for="(server, i) in plexServers" :key="i"
@click="selectServer(server)"
@@ -96,7 +93,7 @@
>
<div>
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
<p class="text-meta text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
<p class="text-meta text-muted">{{ server.owned ? 'Votre serveur' : 'Partagé avec vous' }}</p>
</div>
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
</div>
@@ -117,7 +114,7 @@
</div>
<div>
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
<p class="text-xs text-muted mb-1.5">Le dossier de la médiathèque tel que Plex le voit (il peut différer du chemin FanKarr).</p>
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
</div>
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
@@ -147,9 +144,9 @@
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
<li>Rafraîchissez les métadonnées</li>
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
<li>Dans le menu ⋯ de votre bibliothèque, <span class="text-primary">Manage Library › Edit › Advanced › Agent</span> : sélectionnez l'agent Fankai</li>
<li>Actualisez les métadonnées</li>
</ol>
</div>
@@ -164,6 +161,7 @@
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { Check, Clock3, User, X } from 'lucide-vue-next'
import { plural } from '@/utils/format'
const props = defineProps<{ mediaPath: string }>()
const emit = defineEmits<{ close: [] }>()
@@ -190,21 +188,21 @@ const plexForm = ref({
libraryPath: props.mediaPath,
})
// ── OAuth ─────────────────────────────────────────────────────
// ── Connexion via Plex ──
async function startOAuth() {
plexError.value = ''
plexLoading.value = true
try {
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
if (!res.ok) { plexError.value = data.error ?? "Impossible d'ouvrir la connexion Plex."; return }
plexOAuthPinId.value = String(data.pinId)
plexAuthMethod.value = 'oauth'
window.open(data.authUrl, '_blank')
let attempts = 0
plexOAuthTimer = setInterval(async () => {
attempts++
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai dépassé. Réessayez.'; return }
try {
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
const d = await r.json()
@@ -218,7 +216,7 @@ async function startOAuth() {
} catch {}
}, 2000)
} catch {
plexError.value = "Impossible de démarrer l'authentification"
plexError.value = "Impossible d'ouvrir la connexion Plex."
} finally {
plexLoading.value = false
}
@@ -230,7 +228,7 @@ function cancelOAuth() {
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
}
// ── Credentials ───────────────────────────────────────────────
// ── Identifiants ──
async function plexConnect() {
plexError.value = ''
plexLoading.value = true
@@ -241,8 +239,8 @@ async function plexConnect() {
})
const data = await res.json()
if (!res.ok) {
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
else plexError.value = data.error ?? 'Erreur de connexion'
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
return
}
plexToken.value = data.token
@@ -250,7 +248,7 @@ async function plexConnect() {
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
else plexStep.value = 'server'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
@@ -266,11 +264,11 @@ async function plexConnectServer() {
const conns = plexSelectedServer.value.connections ?? []
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
plexServerUrl.value = conn?.uri ?? ''
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
if (!plexServerUrl.value) throw new Error('Ce serveur Plex ne propose aucune adresse de connexion.')
plexForm.value.libraryPath = props.mediaPath
plexStep.value = 'library'
} catch (err) {
plexError.value = err instanceof Error ? err.message : 'Erreur'
plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
} finally {
plexLoading.value = false
}
@@ -288,9 +286,9 @@ async function plexSetup() {
plexSteps.value = data.steps ?? []
plexManualSetup.value = data.manualSetup ?? false
plexStep.value = 'done'
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
if (!data.ok) plexError.value = data.error ?? 'Impossible de configurer Plex.'
} catch {
plexError.value = 'Impossible de contacter le serveur'
plexError.value = 'Impossible de contacter le serveur.'
} finally {
plexLoading.value = false
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Accent">
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Couleur de l'interface">
<button
v-for="theme in themes"
:key="theme.id"
+8 -10
View File
@@ -15,7 +15,6 @@
</div>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<SettingsToggle
@@ -39,7 +38,6 @@
</div>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@@ -92,7 +90,7 @@
<button @click="test" :disabled="testing || !type" class="btn-secondary">
<Loader v-if="testing" :size="14" class="animate-spin" />
<Check v-else-if="tested" :size="14" class="text-ok" />
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester' }}
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester la connexion' }}
</button>
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
</div>
@@ -122,10 +120,10 @@ const { add: toast } = useToast()
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
savePath : 'Facultatif. Dossier où le client enregistre les torrents envoyés par FanKarr (ex. /downloads/fankai). Vide, le client utilise son dossier par défaut.',
remotePath : "Si le client tourne sur une autre machine ou dans un autre conteneur : son dossier de téléchargement, tel qu'il le voit (ex. /downloads).",
localPath : 'Le même dossier, tel que FanKarr le voit (ex. /mnt/nas/downloads).',
ignoreCertificateErrors: "Accepte les certificats auto-signés ou invalides en HTTPS. À n'utiliser que sur un réseau de confiance.",
}
const name = ref(props.client?.name ?? '')
@@ -177,7 +175,7 @@ async function test() {
})
const { ok, message } = await res.json()
tested.value = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
toast(ok ? 'Connexion réussie' : (message ?? 'Impossible de se connecter au client torrent'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -199,11 +197,11 @@ async function save() {
}),
})
if (res.ok) {
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
toast(isEdit ? 'Client modifié' : 'Client enregistré', 'success')
emit('saved', await res.json(), isEdit)
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
toast(error ?? "Impossible d'enregistrer le client", 'error')
}
} finally {
saving.value = false
+9 -8
View File
@@ -15,7 +15,7 @@
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Catalogue non téléchargé' : `${status.count} séries disponibles` }}
{{ status.empty ? 'Catalogue non synchronisé' : plural(status.count, 'série disponible', 'séries disponibles') }}
</p>
<p class="text-meta text-muted mt-0.5">
{{ status.empty
@@ -24,7 +24,7 @@
</p>
</div>
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0">
{{ updating ? 'Téléchargement…' : status.empty ? 'Télécharger' : 'Mettre à jour' }}
{{ updating ? 'Synchronisation…' : status.empty ? 'Synchroniser le catalogue' : 'Synchroniser' }}
</button>
</section>
@@ -35,8 +35,8 @@
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
<p class="text-meta text-muted mt-0.5">
<template v-if="scanResult">{{ scanResult.found }} fichiers analysés · {{ scanResult.added }} référencés.</template>
<template v-else>Analysez la médiathèque pour référencer les fichiers présents, sans les déplacer.</template>
<template v-if="scanResult">{{ plural(scanResult.found, 'fichier trouvé', 'fichiers trouvés') }}, {{ plural(scanResult.added, 'ajouté', 'ajoutés') }} à la médiathèque.</template>
<template v-else>Analysez la médiathèque pour reconnaître les épisodes déjà présents. Aucun fichier n'est déplacé.</template>
</p>
</div>
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
@@ -45,7 +45,7 @@
</section>
<p v-if="warnEmpty" class="text-body text-accent" role="alert">
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
Le catalogue est encore vide. Synchronisez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
</p>
</template>
@@ -56,6 +56,7 @@
import { ref, onMounted } from 'vue'
import { Library, ScanSearch } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { plural } from '@/utils/format'
import type { SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
@@ -77,9 +78,9 @@ async function update() {
if (res.ok) {
status.value = { exists: true, count: data.count, empty: data.count === 0 }
warnEmpty.value = false
toast(`${data.count} séries chargées ✓`, 'success')
toast(`Catalogue synchronisé : ${plural(data.count, 'série')}`, 'success')
} else {
toast(data.error ?? 'Erreur lors de la mise à jour', 'error')
toast(data.error ?? 'Impossible de synchroniser le catalogue', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -96,7 +97,7 @@ async function scan() {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
} 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')
+2 -5
View File
@@ -6,7 +6,6 @@
</div>
<template v-else>
<!-- Clients existants -->
<div v-if="clients.length > 0" class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid" class="card flex items-center gap-3">
<span
@@ -25,7 +24,6 @@
</div>
</div>
<!-- Formulaire -->
<div v-if="formOpen" class="card flex flex-col gap-4">
<p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
<TorrentClientForm
@@ -41,7 +39,6 @@
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
</button>
<!-- Rappel chemins -->
<div class="flex items-start gap-3 px-4 py-3 rounded-field border border-border bg-card/50">
<Info :size="15" class="text-muted mt-0.5 shrink-0" />
<p class="text-meta text-muted leading-relaxed">
@@ -58,8 +55,8 @@
<div class="flex flex-col gap-0.5">
<p class="text-body text-accent font-medium">Aucun client configuré</p>
<p class="text-meta text-muted">
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même,
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement.
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour passer cette étape.
Vous pourrez en ajouter un plus tard dans Paramètres › Clients torrent.
</p>
</div>
</div>
+8 -8
View File
@@ -5,23 +5,23 @@
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé, puis vérifie toutes les 5 minutes. Sinon, l'import se lance à la main depuis Activité."
description="Importe les téléchargements terminés (vérification toutes les 5 minutes). Désactivé, vous lancez l'import depuis la page Activité."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » au lieu de « Saison »"
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »."
label="Dossiers de saison en anglais"
description="« Season 01 » au lieu de « Saison 1 »."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier disparaît"
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant."
label="Retirer les épisodes supprimés du disque"
description="À la prochaine analyse, un épisode dont le fichier a disparu est retiré de la médiathèque."
/>
<SettingsToggle
v-if="settings.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client une fois le fichier déplacé."
description="Le torrent ne peut plus être partagé une fois le fichier déplacé : autant le retirer du client."
/>
</div>
@@ -29,7 +29,7 @@
<SettingsToggle
v-model="form.nfoSupport"
label="Fichiers NFO et images"
description="Télécharge NFO et visuels à côté de chaque épisode lors de l'import."
description="Enregistre à côté de chaque épisode un fichier .nfo (titre, résumé…) et ses images."
/>
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-field border border-border bg-main/50 text-meta text-muted leading-relaxed">
<Info :size="13" class="shrink-0 mt-0.5" />
@@ -67,7 +67,7 @@ const error = ref<string | null>(null)
async function submit(): Promise<boolean> {
error.value = null
if (await postSettings(form)) return true
error.value = "Erreur lors de l'enregistrement."
error.value = "Impossible d'enregistrer les réglages d'import."
return false
}
@@ -1,7 +1,6 @@
<template>
<div class="flex flex-col gap-4">
<!-- Jellyfin -->
<section class="card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -13,7 +12,7 @@
<span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
</p>
<p class="text-meta text-muted mt-0.5 leading-relaxed">
Permet à vos utilisateurs Jellyfin de se connecter à FanKarr et de faire des demandes depuis le plugin FanKarr Search.
Vos utilisateurs Jellyfin se connectent à FanKarr et font leurs demandes depuis le plugin FanKarr Search.
</p>
</div>
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
@@ -31,7 +30,7 @@
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="syncResult" class="text-meta text-ok">
{{ syncResult.created }} créé{{ syncResult.created > 1 ? 's' : '' }} · {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
{{ plural(syncResult.created, 'créé', 'créés') }} · {{ plural(syncResult.skipped, 'déjà existant', 'déjà existants') }}
</span>
<span v-if="syncError" class="text-meta text-err">{{ syncError }}</span>
</div>
@@ -40,7 +39,6 @@
</template>
</section>
<!-- Plex -->
<section class="card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -53,7 +51,7 @@
</p>
<p class="text-meta text-muted mt-0.5 leading-relaxed">
Crée une bibliothèque Plex sur votre médiathèque, reliée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43 ou plus récent pour la configuration automatique.
Nécessite Plex Media Server 1.43 ou plus récent pour la configuration automatique.
</p>
</div>
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary btn-sm shrink-0">
@@ -63,7 +61,7 @@
<p v-if="!settings.mediaPath" class="text-meta text-accent">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
</section>
<p class="text-meta text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
<p class="text-meta text-muted">Pas encore de Jellyfin ni de Plex ? Passez cette étape : vous pourrez les configurer plus tard dans les paramètres.</p>
</div>
@@ -76,6 +74,7 @@ import { Server, Tv } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
import { setupContextKey, type SetupSettings } from './setup'
import { plural } from '@/utils/format'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
@@ -101,7 +100,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'
} catch {
syncError.value = 'Impossible de contacter le serveur'
} finally {
+8 -10
View File
@@ -18,7 +18,7 @@
class="field"
spellcheck="false"
/>
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Parcourir — ${field.label}`">
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Choisir le dossier : ${field.label}`">
<FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
</button>
</div>
@@ -34,15 +34,14 @@
</div>
<p v-if="result?.relation === 'same'" class="text-meta text-err flex items-start gap-1.5">
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque doit être distincte du dossier de téléchargements.
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque et le dossier des téléchargements doivent être différents.
</p>
<p v-else-if="result?.relation === 'nested'" class="text-meta text-accent flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" />
Un dossier est à l'intérieur de l'autre : ça fonctionne, mais le scan de la médiathèque verra aussi les téléchargements.
L'un des dossiers est dans l'autre. C'est possible, mais l'analyse de la médiathèque trouvera aussi les téléchargements.
</p>
</div>
<!-- Mode d'import -->
<div class="card flex flex-col gap-3">
<label class="field-label">Mode d'import</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
@@ -62,7 +61,6 @@
</button>
</div>
<!-- Résultat du test hardlink -->
<div v-if="result && form.organizeMode === 'hardlink'" class="text-meta">
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-ok flex items-center gap-1.5">
<Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
@@ -71,7 +69,7 @@
<p class="text-accent flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
</p>
<p class="text-muted">Sans hardlink, FanKarr copiera les fichiers (espace disque doublé).</p>
<p class="text-muted">Les hardlinks ne fonctionnent pas entre ces dossiers : en mode Copier, chaque fichier prendra deux fois la place.</p>
<button @click="form.organizeMode = 'copy'" class="btn-secondary btn-sm self-start">Passer en mode Copier</button>
</div>
<p v-else class="text-muted">{{ result.hardlink.message }}</p>
@@ -104,7 +102,7 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
{
key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete',
help: 'Là où votre client torrent dépose les fichiers terminés. Sert aussi à tester les hardlinks.',
help: 'Dossier où votre client torrent place les fichiers terminés.',
},
{
key: 'mediaPath', label: 'Médiathèque Fankai', required: true,
@@ -114,9 +112,9 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
]
const modes = [
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Aucune copie : le fichier reste en partage dans le client torrent.' },
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Le fichier apparaît dans la médiathèque sans être copié ; le client continue de le partager.' },
{ id: 'copy' as const, label: 'Copier', text: 'Duplique le fichier. Fonctionne partout, prend deux fois la place.' },
{ id: 'move' as const, label: 'Déplacer', text: 'Retire le fichier du client : le partage s\'arrête.' },
{ id: 'move' as const, label: 'Déplacer', text: "Retire le fichier du client : le partage s'arrête." },
]
const form = reactive({
@@ -175,7 +173,7 @@ async function submit(): Promise<boolean> {
if (!res.ok) { error.value = 'Corrigez les dossiers signalés avant de continuer.'; return false }
if (!(await postSettings({ ...form, mediaPath: res.mediaPath.resolved, completePath: res.completePath?.resolved ?? '' }))) {
error.value = "Erreur lors de l'enregistrement."
error.value = "Impossible d'enregistrer les dossiers."
return false
}
return true
+9 -8
View File
@@ -21,14 +21,14 @@
<div class="flex flex-col sm:flex-row gap-2">
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary">
<Compass :size="15" />
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
{{ context.relaunch ? 'Revoir la visite guidée' : 'Terminer avec la visite guidée' }}
</button>
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
</button>
</div>
<p v-if="!context.relaunch" class="text-meta text-muted -mt-3">
La visite guidée présente chaque écran en une minute. Vous pourrez la relancer depuis le bouton « ? » de la barre latérale.
La visite guidée présente les écrans principaux en une minute. Vous pourrez la relancer avec le bouton « ? ».
</p>
</div>
@@ -38,6 +38,7 @@
import { ref, reactive, inject, onMounted } from 'vue'
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
import { plural } from '@/utils/format'
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
interface Row { label: string; value: string; state: RowState; step: SetupStepId }
@@ -55,7 +56,7 @@ const stateClass: Record<RowState, string> = {
neutral: 'bg-hover text-muted',
}
const modeLabel = { hardlink: 'Hardlink', copy: 'Copie', move: 'Déplacement' }
const modeLabel = { hardlink: 'Hardlink', copy: 'Copier', move: 'Déplacer' }
const row = (label: string, step: SetupStepId, value = '…', state: RowState = 'loading'): Row => ({ label, step, value, state })
@@ -64,11 +65,11 @@ const rows = reactive({
mode : row("Mode d'import", 'paths'),
client : row('Client torrent', 'client'),
import : row('Import automatique', 'import',
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis Activité',
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis la page Activité',
props.settings.autoImport ? 'ok' : 'neutral'),
nfo : row('NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
nfo : row('Fichiers NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
jellyfin: row('Jellyfin', 'media-server'),
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant Plex lancé' : 'Non configuré', 'neutral'),
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant ouvert, non vérifié' : 'Non configuré', 'neutral'),
catalog : row('Catalogue Fankai', 'catalog'),
})
@@ -90,7 +91,7 @@ async function loadPaths() {
set(rows.paths, 'Médiathèque non configurée', 'error')
} else {
set(rows.paths,
`${s.mediaPath}${s.completePath ? ` ← ${s.completePath}` : ' · téléchargements non renseignés'}`,
`Médiathèque : ${s.mediaPath} · ${s.completePath ? `Téléchargements : ${s.completePath}` : 'dossier des téléchargements non renseigné'}`,
!res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
}
@@ -139,7 +140,7 @@ async function loadCatalog() {
try {
const { count, empty } = await getJson('/api/torrents/status')
if (empty) set(rows.catalog, 'Catalogue vide', 'warn')
else set(rows.catalog, `${count} séries disponibles`, 'ok')
else set(rows.catalog, plural(count, 'série disponible', 'séries disponibles'), 'ok')
} catch {
set(rows.catalog, 'Impossible de vérifier', 'warn')
}
+5 -8
View File
@@ -1,7 +1,6 @@
<template>
<div class="flex flex-col gap-6">
<!-- Fonctionnement -->
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<li v-for="(item, i) in flow" :key="item.title" class="card flex gap-3">
<span class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
@@ -14,7 +13,6 @@
</li>
</ol>
<!-- Ce qu'on va configurer -->
<div class="card flex flex-col gap-3">
<p class="field-label">Au programme</p>
<ul class="flex flex-col gap-2">
@@ -22,14 +20,13 @@
<Check :size="14" class="text-accent mt-0.5 shrink-0" />
<span class="text-secondary">
{{ item.label }}
<span v-if="item.optional" class="text-muted text-meta">(optionnel)</span>
<span v-if="item.optional" class="text-muted text-meta">(facultatif)</span>
</span>
</li>
</ul>
<p class="text-meta text-muted">Comptez environ 5 minutes.</p>
</div>
<!-- Docker -->
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-field border border-accent/30 bg-accent-muted">
<HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
<div class="flex flex-col gap-1">
@@ -54,16 +51,16 @@ defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>
const flow = [
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' },
{ icon: Download, title: 'Téléchargement', text: 'Un clic envoie le torrent à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' },
{ icon: Download, title: 'Téléchargement', text: 'Le torrent est envoyé à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois le téléchargement terminé, le fichier est renommé et rangé par série et par saison.' },
{ icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
]
const checklist = [
{ label: 'Dossier de téléchargements et médiathèque' },
{ label: 'Dossier des téléchargements et médiathèque' },
{ label: 'Client torrent' },
{ label: 'Import automatique et nommage' },
{ label: 'Jellyfin ou Plex', optional: true },
{ label: 'Téléchargement du catalogue' },
{ label: 'Synchronisation du catalogue' },
]
</script>
+1 -1
View File
@@ -171,7 +171,7 @@ async function showStep() {
if (el) {
const r = el.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) {
el.scrollIntoView({ block: 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
el.scrollIntoView({ block: r.height > window.innerHeight ? 'start' : 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
}
}
await nextTick()
+1 -2
View File
@@ -1,10 +1,9 @@
/** Copie du texte dans le presse-papiers avec fallback pour HTTP non-localhost. */
export async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text)
return
}
// Fallback : execCommand (HTTP non sécurisé)
// Hors HTTPS, navigator.clipboard est absent : repli sur execCommand
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
+3 -11
View File
@@ -11,31 +11,23 @@ interface Toast {
const toasts = ref<Toast[]>([])
let nextId = 0
const DURATION = 3500
const MAX_VISIBLE = 4
function add(message: string, type: Toast['type'] = 'info') {
// Cherche un toast récent du même type à grouper
const existing = toasts.value.find(t => t.type === type)
const existing = toasts.value.find(t => t.type === type && t.message === message)
if (existing) {
// Grouper — incrémenter le count et mettre à jour le message
clearTimeout(existing.timer)
existing.count++
existing.message = buildGroupMessage(type, existing.count)
existing.timer = setTimeout(() => remove(existing.id), DURATION)
return
}
// Nouveau toast
const id = nextId++
const timer = setTimeout(() => remove(id), DURATION)
toasts.value.push({ id, message, type, count: 1, timer })
}
function buildGroupMessage(type: Toast['type'], count: number): string {
if (type === 'success') return `${count} opérations réussies`
if (type === 'error') return `${count} erreurs`
return `${count} notifications`
if (toasts.value.length > MAX_VISIBLE) remove(toasts.value[0]!.id)
}
function remove(id: number) {
-3
View File
@@ -29,7 +29,6 @@ function isNewer(latest: string, current: string): boolean {
async function checkForUpdates() {
try {
// Version locale
const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) {
const { version } = await vRes.json()
@@ -38,7 +37,6 @@ async function checkForUpdates() {
if (currentVersion.value === 'dev') return
// Dernière release GitHub
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' },
})
@@ -57,7 +55,6 @@ export function useUpdateCheck() {
onMounted(() => {
if (users++ > 0) return
checkForUpdates()
// Revérifier toutes les 6h
timer = setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
})
+1 -4
View File
@@ -54,7 +54,7 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
}
})
// Première visite : une seule fois par session, après l'assistant pour un admin
// Visite guidée auto : une fois par session, après l'assistant pour un admin
let tourAutoStarted = false
watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
if (tourAutoStarted || tour.active) return
@@ -64,7 +64,6 @@ watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
}
}, { immediate: true })
// Compteurs de pour la barre latérale
let pendingTimer: ReturnType<typeof setInterval> | null = null
let downloadsTimer: ReturnType<typeof setInterval> | null = null
@@ -84,7 +83,6 @@ onUnmounted(() => {
watch(() => route.path, () => reqStore.refreshPending())
// Mobile
const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings'))
const navItems = computed<NavItem[]>(() => {
@@ -120,6 +118,5 @@ const navItems = computed<NavItem[]>(() => {
return items
})
// Mobile 2
const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings')))
</script>
+5 -11
View File
@@ -12,7 +12,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
// ─── Pages publiques hors layout ─────────────────────────
// ── Pages hors layout (connexion, invitation, assistant) ──
{
path: '/auth',
component: () => import('@/views/AuthView.vue'),
@@ -30,7 +30,7 @@ const router = createRouter({
meta: { adminOnly: true },
},
// ─── App principale avec layout sidebar ──────────────────
// ── Pages avec barre latérale ──
{
path: '/',
component: AppLayout,
@@ -38,7 +38,6 @@ const router = createRouter({
{ path: '', redirect: '/dashboard' },
{ path: 'downloads', redirect: '/activity' },
// Accueil
{
path: 'dashboard',
name: 'dashboard',
@@ -64,7 +63,6 @@ const router = createRouter({
component: () => import('@/views/DownloadsView.vue'),
},
// Paramètres
{
path: 'settings',
component: () => import('@/views/settings/SettingsLayout.vue'),
@@ -98,7 +96,7 @@ const router = createRouter({
{
path: 'logs',
name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
component: () => import('@/views/settings/LogsView.vue'),
},
{
path: 'advanced',
@@ -120,7 +118,6 @@ const router = createRouter({
],
},
// Demandes
{
path: 'requests',
name: 'requests',
@@ -133,25 +130,22 @@ const router = createRouter({
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.name === 'setup') return { top: 0 }
// Retour depuis le détail vers la liste — ne pas scroller en haut
// Retour à la liste : garder la position de défilement
if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 }
},
})
// ─── Guard auth ───────────────────────────────────────────────
// ── Contrôle d'accès ──
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (auth.loading) await auth.checkStatus()
// Redirige vers /auth si non connecté
if (!to.meta.public && !auth.loggedIn) return '/auth'
// Redirige vers l'accueil si déjà connecté et tente d'accéder à /auth
if (to.path === '/auth' && auth.loggedIn) return '/dashboard'
// Redirige les non-admins hors des pages admin
if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard'
// Premier lancement : l'assistant de configuration est obligatoire
+2 -2
View File
@@ -43,7 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
await checkStatus()
return null
}
return data.error
return data.error ?? 'Impossible de se connecter.'
}
async function setupAccount(u: string, password: string): Promise<string | null> {
@@ -63,7 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
tourSeen.value = false
return null
}
return data.error
return data.error ?? 'Impossible de créer le compte.'
}
async function logout() {
+2 -2
View File
@@ -18,8 +18,8 @@ export const useCatalogStore = defineStore('catalog', () => {
error.value = null
try {
productions.value = await fetchProductions()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de charger le catalogue.'
} finally {
loading.value = false
}
+9 -10
View File
@@ -88,18 +88,17 @@ export const useSeriesStore = defineStore('series', () => {
error.value = null
try {
const res = await fetch('/api/series')
if (!res.ok) throw new Error(`Erreur ${res.status}`)
if (!res.ok) { error.value = 'Impossible de charger les séries.'; return }
const data = await res.json()
series.value = data.series
// Précache des posters en arrière-plan
for (const s of data.series as Serie[]) {
if (s.poster_image && !_imageCache.has(s.poster_image)) {
_imageCache.add(s.poster_image)
new Image().src = s.poster_image
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de contacter le serveur.'
} finally {
loadingSeries.value = false
}
@@ -111,10 +110,10 @@ export const useSeriesStore = defineStore('series', () => {
currentSerie.value = null
try {
const res = await fetch(`/api/series/${id}`)
if (!res.ok) throw new Error(`Erreur ${res.status}`)
if (!res.ok) { error.value = 'Impossible de charger la série.'; return }
currentSerie.value = await res.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} catch {
error.value = 'Impossible de contacter le serveur.'
} finally {
loadingDetail.value = false
}
@@ -129,9 +128,9 @@ export const useSeriesStore = defineStore('series', () => {
})
const data = await res.json()
if (res.ok) return { success: true }
return { success: false, error: data.error }
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }
return { success: false, error: data.error ?? "Impossible d'envoyer le torrent au client" }
} catch {
return { success: false, error: 'Impossible de contacter le serveur' }
}
}
+20 -20
View File
@@ -12,58 +12,58 @@ export interface TourStep {
const welcome: TourStep = {
id: 'welcome',
title: 'Bienvenue dans FanKarr',
body: 'Une visite rapide des écrans principaux. Utilisez les flèches ← → pour naviguer et Échap pour quitter.',
body: 'Une visite rapide des écrans principaux. Au clavier : touches fléchées pour avancer ou revenir, Échap pour quitter.',
}
const help = (isAdmin: boolean): TourStep => ({
id: 'help', target: ['help'], placement: 'right',
title: 'Besoin d\'aide ?',
title: "Besoin d'aide ?",
body: isAdmin
? 'Ce bouton relance la visite ou l\'assistant de configuration à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.'
: 'Ce bouton relance la visite à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.',
? "Ce bouton relance la visite ou l'assistant de configuration. Sur mobile, ces options sont dans le menu de votre avatar, en haut de l'Accueil et des Paramètres."
: "Ce bouton relance la visite. Sur mobile, elle se trouve dans le menu de votre avatar, en haut de l'Accueil et des Paramètres.",
})
const adminSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil',
body: 'Le dernier import en grand, les séries ajoutées récemment, et les chiffres du catalogue : séries dans la médiathèque et séries surveillées.',
body: 'Le dernier import, les séries ajoutées récemment et les chiffres clés : séries au catalogue, dans la médiathèque et surveillées.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'À traiter',
body: 'Demandes en attente, imports en erreur et dossiers à renommer : chaque ligne porte son action. Les demandes arrivent de FanKarr ou du plugin Jellyfin.',
body: "Demandes en attente, imports en erreur et dossiers à renommer, chacun avec son bouton d'action. Les demandes viennent de FanKarr ou du plugin Jellyfin.",
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Tout le catalogue Fankai. L\'état est écrit sur l\'affiche : importée, partielle, en téléchargement, erreur, dossier à renommer ou sans torrent. Cliquez pour ouvrir la fiche.',
body: "Tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement…). Cliquez dessus pour ouvrir sa fiche.",
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Chaque filtre affiche son nombre de séries ; « Plus » garde les filtres secondaires. Triez, et changez la taille des affiches.',
body: 'Chaque filtre indique son nombre de séries, les filtres secondaires sont sous « Plus ». Vous pouvez aussi trier et changer la taille des affiches.',
},
{
id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
title: 'Actions groupées',
body: 'Sélectionnez plusieurs séries pour les renommer ou les surveiller. Une série surveillée télécharge seule ses nouveaux épisodes.',
body: 'Sélectionnez plusieurs séries pour renommer leurs fichiers ou les surveiller. FanKarr télécharge automatiquement les nouveaux épisodes des séries surveillées.',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
title: 'Demandes',
body: '« Approuver et télécharger » lance le téléchargement ; une demande sans torrent est signalée avant d\'être approuvée. Les demandes avec torrent s\'approuvent aussi en une fois.',
body: 'Approuvez ou refusez les demandes des invités. « Approuver et télécharger » envoie aussi le torrent au client ; les demandes sans torrent sont signalées. Le bouton du haut approuve toutes celles qui ont un torrent.',
},
{
id: 'activity', route: '/activity', target: ['activity-header'], placement: 'bottom',
title: 'Activité',
body: 'Une ligne par torrent, du téléchargement à l\'import. Les onglets séparent ce qui est en cours, à importer ou en erreur ; une erreur d\'import s\'affiche dans sa ligne, avec de quoi relancer.',
body: "Un torrent par ligne, du téléchargement à l'import. Les onglets séparent les torrents en cours, à importer et en erreur. En cas d'erreur d'import, un bouton « Réessayer » apparaît sur la ligne.",
},
{
id: 'settings', route: '/settings/download-client', target: ['settings-menu', 'nav-settings'], placement: 'right',
title: 'Paramètres',
body: 'Les réglages sont rangés en quatre groupes : Compte (profil, utilisateurs), Médias (dossiers, séries, catalogue), Services (clients torrent, Jellyfin) et Système (journaux, avancé).',
body: 'Quatre groupes de réglages : Compte, Médias, Services (clients torrent, Jellyfin) et Système.',
},
help(true),
]
@@ -71,24 +71,24 @@ const adminSteps: TourStep[] = [
const userSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
title: 'Accueil',
body: 'Les nouveautés de la médiathèque et le nombre de séries prêtes à regarder.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
title: 'Mes demandes',
body: 'L\'état de vos dernières demandes. Juste en dessous, « Pas encore là » propose des séries à demander en un clic.',
title: 'Vos dernières demandes',
body: "Leur état en un coup d'œil. Juste en dessous, « Pas encore là » suggère des séries que vous pouvez demander.",
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez la fiche pour demander la série, une saison ou un épisode.',
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez leur fiche pour demander la série, une saison ou un épisode.',
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Retrouvez une série et filtrez : prêtes à regarder, bientôt là, pas encore là ou vos demandes.',
body: 'Cherchez une série ou filtrez : « Prêtes à regarder », « Bientôt là », « Pas encore là » ou « Mes demandes ».',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
@@ -97,8 +97,8 @@ const userSteps: TourStep[] = [
},
{
id: 'profile-token', route: '/settings/profile', target: ['profile-token'], placement: 'top',
title: 'Jeton d’API',
body: 'Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte pour faire vos demandes depuis Jellyfin. Le choix de l’accent de couleur se fait juste à côté.',
title: "Jeton d'API",
body: "Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte, pour faire vos demandes depuis Jellyfin. Sur cette page, vous pouvez aussi changer la couleur de l'interface.",
},
help(false),
]
-1
View File
@@ -14,7 +14,6 @@ export function splitTitle(title: string): { name: string; suffix: string } {
return m ? { name: m[1]!, suffix: m[2]! } : { name: title, suffix: '' }
}
// Nouveauté : si importe il y a moins de 7 jours
export function isNew(lastImportedAt: string | null | undefined): boolean {
return !!lastImportedAt && Date.now() - new Date(lastImportedAt).getTime() < 7 * 86_400_000
}
+8 -12
View File
@@ -3,26 +3,22 @@
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
<div class="relative w-full max-w-sm">
<!-- Logo -->
<div class="flex items-center justify-center gap-3 mb-8">
<FankarrLogo class="w-9 h-9" />
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
</div>
<!-- Card -->
<div class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
<div class="flex flex-col gap-1">
<p class="tag-label text-accent">
{{ isSetup ? 'Première connexion' : 'Connexion' }}
</p>
<p v-if="isSetup" class="tag-label text-accent">Premier lancement</p>
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
{{ isSetup ? 'Créer le compte administrateur' : 'Connexion' }}
</h1>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="auth-username" class="field-label">Identifiant</label>
<label for="auth-username" class="field-label">Nom d'utilisateur</label>
<input id="auth-username" v-model="username" type="text" placeholder="admin" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
@@ -30,14 +26,14 @@
<input id="auth-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<div v-if="isSetup">
<label class="field-label mb-1.5">Confirmer</label>
<label class="field-label mb-1.5">Confirmer le mot de passe</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : isSetup ? 'Créer le compte' : 'Se connecter' }}
{{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
</div>
</div>
@@ -65,9 +61,9 @@ const isSetup = computed(() => !auth.setup)
async function submit() {
error.value = null
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit faire au moins 6 caractères'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis.'; return }
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas.'; return }
submitting.value = true
const err = isSetup.value
+40 -48
View File
@@ -22,12 +22,11 @@
</label>
</header>
<div class="grid lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
<div class="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
<!-- ── Colonne principale-->
<div class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
<!-- ── Colonne principale ── -->
<div data-tour="dashboard-main" class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
<!-- Dernier import -->
<section v-if="hero" class="relative sm:min-h-[344px] rounded-card overflow-hidden bg-card max-lg:order-1">
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" alt="" class="absolute inset-0 w-full h-full object-cover blur-[34px] saturate-[1.3] scale-[1.35] opacity-60" />
<div class="absolute inset-0 bg-linear-to-r from-main/97 via-main/84 via-48% to-main/35" />
@@ -40,13 +39,13 @@
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-2 top-3">NOUVEAU</span>
</RouterLink>
<div class="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importé' : 'Ajouté' }} {{ formatRelative(hero.at) }}</span>
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importée' : 'Ajoutée' }} {{ formatRelative(hero.at) }}</span>
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col">
<span class="font-display text-[28px] leading-[1.05] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
<span v-if="heroTitle.suffix" class="font-display text-xl leading-snug font-semibold text-accent">{{ heroTitle.suffix }}</span>
</RouterLink>
<p class="text-meta text-secondary">
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ plural(hero.episodes, 'épisode') }} {{ auth.isAdmin ? (hero.episodes > 1 ? 'ajoutés' : 'ajouté') : 'à regarder' }}.
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : `${plural(hero.episodes, 'épisode')} à regarder` }}.
</p>
</div>
</div>
@@ -67,7 +66,7 @@
</RouterLink>
<p class="text-[15px] leading-[1.6] text-secondary max-w-[420px] text-pretty">
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>
<span class="text-primary">{{ plural(hero.episodes, 'épisode') }}{{ auth.isAdmin ? (hero.episodes > 1 ? ' ajoutés' : ' ajouté') : '' }}</span>
<span class="text-primary">{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : plural(hero.episodes, 'épisode') }}</span>
{{ auth.isAdmin ? 'à la médiathèque.' : 'à regarder.' }}
</p>
<div class="flex items-center gap-5 mt-1.5 flex-wrap">
@@ -75,7 +74,7 @@
Voir la série <ArrowRight :size="16" />
</RouterLink>
<RouterLink :to="auth.isAdmin ? '/activity' : '/series'" class="text-body text-secondary hover:text-primary transition-colors">
{{ auth.isAdmin ? 'Tous les imports récents' : 'Toutes les nouveautés' }}
{{ auth.isAdmin ? 'Tous les imports récents' : 'Parcourir la médiathèque' }}
</RouterLink>
</div>
</div>
@@ -94,16 +93,15 @@
<p class="text-body text-secondary max-w-[460px]">
{{ auth.isAdmin
? 'Les séries importées apparaîtront ici dès la fin de leur téléchargement.'
: 'Les séries ajoutées à la médiathèque apparaîtront ici. En attendant, parcourez le catalogue et demandez ce qui vous manque.' }}
: 'Les séries ajoutées apparaîtront ici. En attendant, parcourez la médiathèque et demandez celles qui vous intéressent.' }}
</p>
<RouterLink to="/series" class="btn-secondary w-fit mt-1">Parcourir la médiathèque</RouterLink>
</div>
</section>
<!-- Récemment ajoutés -->
<section v-if="recentList.length > 0" class="flex flex-col gap-3.5 max-lg:order-4">
<div class="flex items-baseline justify-between">
<h2 class="section-title">Récemment ajoutés</h2>
<h2 class="section-title">Récemment ajoutées</h2>
<RouterLink to="/series" class="text-[13px] text-secondary hover:text-primary transition-colors flex items-center gap-1">
Médiathèque <ChevronRight :size="14" />
</RouterLink>
@@ -120,12 +118,11 @@
</div>
</section>
<!-- Chiffres -->
<section data-tour="dashboard-stats" class="border-t border-card pt-[18px] max-lg:order-5">
<div v-if="auth.isAdmin" class="flex flex-col sm:flex-row gap-3 sm:gap-0">
<div class="flex-1 flex items-baseline gap-2.5">
<span class="font-display text-[34px] font-bold text-primary">{{ stats.catalogue }}</span>
<span class="text-[13px] text-muted">séries au catalogue</span>
<span class="text-[13px] text-muted">{{ stats.catalogue > 1 ? 'séries au catalogue' : 'série au catalogue' }}</span>
</div>
<div class="hidden sm:block w-px bg-card mx-6" />
<div class="flex-1 flex items-baseline gap-2.5">
@@ -146,12 +143,11 @@
</div>
</section>
<!-- Panneau debug -->
<section v-if="devMode" class="flex flex-col gap-3 max-lg:order-6">
<div class="flex items-center gap-2.5">
<h2 class="section-title">Diagnostic</h2>
<span class="pill pill-wait">DEV</span>
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Rafraîchir</button>
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="d in debugCards" :key="d.label" class="card flex flex-col gap-1">
@@ -160,11 +156,11 @@
<span class="text-meta text-muted">{{ d.hint }}</span>
</div>
</div>
<div v-else class="card text-meta text-muted py-4 text-center">Chargement des stats…</div>
<div v-else class="card text-meta text-muted py-4 text-center">Chargement…</div>
</section>
</div>
<!-- ── Admin --- -->
<!-- ── Colonne droite : admin ── -->
<div v-if="auth.isAdmin" class="flex flex-col gap-7 min-w-0 max-lg:contents">
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
@@ -177,10 +173,9 @@
<div v-if="loadingTodo" class="flex items-center gap-2 text-muted text-meta px-[18px] py-5">
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
</div>
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur.</p>
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur ni dossier à renommer.</p>
<template v-else>
<!-- Demandes -->
<div v-for="req in visibleRequests" :key="req.id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px]" />
@@ -193,7 +188,7 @@
<span v-if="req.hasTorrents === false" class="text-meta text-err flex items-center gap-1.5"><TriangleAlert :size="13" /> Aucun torrent disponible</span>
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
<button @click="approve(req)" :disabled="busy[req.id]" class="btn-sm max-sm:flex-1" :class="req.hasTorrents === false ? 'btn-secondary' : 'btn-primary'">
{{ req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
{{ req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
</button>
<button @click="rejectTarget = req" class="text-meta text-secondary hover:text-err transition-colors max-sm:hidden">Refuser</button>
<button @click="rejectTarget = req" class="btn-icon btn-sm sm:hidden hover:text-err" title="Refuser" aria-label="Refuser la demande"><X :size="15" /></button>
@@ -201,7 +196,6 @@
</div>
</div>
<!-- Imports en erreur -->
<div v-for="err in visibleErrors" :key="err.hash" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<span class="w-10 h-10 rounded-field bg-err/12 text-err flex items-center justify-center shrink-0"><TriangleAlert :size="18" :stroke-width="1.75" /></span>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -213,18 +207,17 @@
{{ busy[err.hash] ? 'Import…' : 'Réessayer' }}
</button>
<button v-if="err.serieId" @click="openManualImport(err)" class="text-meta text-secondary hover:text-primary transition-colors max-sm:h-10 max-sm:px-2">Import manuel</button>
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir dans Activité</RouterLink>
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir l'activité</RouterLink>
</div>
</div>
</div>
<!-- Dossiers à renommer -->
<div v-for="f in visibleFolders" :key="f.serie_id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
<span class="w-10 h-10 rounded-field bg-accent/10 text-accent flex items-center justify-center shrink-0"><Folder :size="18" :stroke-width="1.75" /></span>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="tag-label">Dossier à renommer</span>
<RouterLink :to="`/series/${f.serie_id}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ f.serie_title }}</RouterLink>
<span class="text-meta text-secondary break-words">{{ f.current.map(basename).join(', ') }} → {{ basename(f.expected) }}</span>
<span class="text-meta text-secondary break-words">Actuel : {{ f.current.map(basename).join(', ') }} · Attendu : {{ basename(f.expected) }}</span>
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] max-sm:-ml-[54px]">
<button @click="renameFolder(f)" :disabled="busy[`folder-${f.serie_id}`]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
{{ busy[`folder-${f.serie_id}`] ? 'Renommage…' : 'Renommer le dossier' }}
@@ -259,13 +252,13 @@
<div class="progress-bar" :class="{ 'bg-muted': t.state === 'paused', 'bg-err': t.state === 'error' }" :style="{ width: `${Math.min(100, t.progress)}%` }" />
</div>
<span class="text-xs text-muted">
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0 && formatDuration(t.eta) !== '∞'"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
</span>
</div>
</section>
</div>
<!-- ── Colonne droite : invité ─────────────────────────── -->
<!-- ── Colonne droite : invité ── -->
<div v-else class="flex flex-col gap-7 min-w-0 max-lg:contents">
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
@@ -380,7 +373,7 @@ const manualImport = ref<{ serieId: number; serieName: string; seasons: any[]
let interval: ReturnType<typeof setInterval> | null = null
// ── Séries et utilitaires ─────────────────────────────
// ── Séries et utilitaires ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
const titleOf = (id: number, fallback: string) => seriesById.value.get(id)?.title ?? fallback
@@ -395,7 +388,7 @@ const recentList = computed(() => recentWithSerie.value.slice(1, 7))
const heroTitle = computed(() => splitTitle(hero.value?.serie.title ?? ''))
const heroMeta = computed(() => [hero.value?.serie.year, statusLabel(hero.value?.serie.status)].filter(Boolean).map(String))
// ── Chiffres
// ── Chiffres ──
const stats = computed(() => ({
catalogue : catalogueCount.value || seriesStore.series.length,
imported : seriesStore.series.filter(s => s.download_state === 'complete').length,
@@ -412,8 +405,7 @@ function torrentStateLabel(state: string) {
return ({ paused: 'en pause', checking: 'vérification', error: 'erreur du client' } as Record<string, string>)[state] ?? state
}
// ── À traiter (admin)
// Les plus anciennes d'abord
// ── À traiter (admin) ──
const pendingRequests = computed(() => requests.value.filter(r => r.status === 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
@@ -459,7 +451,7 @@ const overflowLinks = computed(() => {
return links
})
// ── Invité ────────────────────────────────────────────────────
// ── Invité ──
const myPendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length)
const guestStatus: Record<string, { label: string; class: string }> = {
@@ -476,7 +468,7 @@ const notYetHere = computed(() => {
.slice(0, 3)
})
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function fetchSettings() {
if (!auth.isAdmin) return
const res = await fetch('/api/settings', { credentials: 'include' })
@@ -493,12 +485,12 @@ async function fetchDebug() {
}
const debugCards = computed(() => debug.value ? [
{ label: 'Heap utilisé', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
{ label: 'RSS', value: `${debug.value.memory.rss} Mo`, hint: 'mémoire système' },
{ label: 'Uptime', value: debug.value.uptime, hint: 'depuis le démarrage' },
{ label: 'Cache GitHub', value: debug.value.cache.entries, hint: `entrées (TTL ${debug.value.cache.ttlHours} h)` },
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
{ label: 'Worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', hint: `${debug.value.requests.notifs} import(s) en mémoire`, accent: debug.value.worker.running },
{ label: 'Mémoire utilisée', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
{ label: 'Mémoire système (RSS)', value: `${debug.value.memory.rss} Mo`, hint: 'occupée par le serveur' },
{ label: 'Durée de fonctionnement', value: debug.value.uptime, hint: 'depuis le démarrage' },
{ label: 'Entrées en cache', value: debug.value.cache.entries, hint: `gardées ${debug.value.cache.ttlHours} h` },
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
{ label: "Tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', hint: `${plural(debug.value.requests.notifs, 'import récent', 'imports récents')} en mémoire`, accent: debug.value.worker.running },
] : [])
async function fetchCatalogue() {
@@ -545,7 +537,7 @@ async function fetchFolders() {
} catch {}
}
// ── Actions
// ── Actions ──
async function patchRequest(req: SerieRequest, body: object) {
busy[req.id] = true
try {
@@ -553,7 +545,7 @@ async function patchRequest(req: SerieRequest, body: object) {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
await fetchRequests()
reqStore.refreshPending()
return true
@@ -580,9 +572,9 @@ async function retryImport(err: { hash: string; torrent: any }) {
body: JSON.stringify({ hash: err.torrent.hash, save_path: err.torrent.save_path, name: err.torrent.name }),
})
const d = await res.json()
if (!res.ok) toast(d.error ?? "Erreur lors de l'import", 'error')
else if (d.errors?.length) toast(`${d.done} importé(s), ${d.errors.length} erreur(s)`, 'error')
else toast(`${d.done} fichier(s) importé(s) ✓`, 'success')
if (!res.ok) toast(d.error ?? "Impossible d'importer ce torrent", 'error')
else if (d.errors?.length) toast(`${plural(d.done, 'fichier importé', 'fichiers importés')}, ${plural(d.errors.length, 'erreur')}`, 'error')
else toast(plural(d.done, 'fichier importé', 'fichiers importés'), 'success')
await Promise.all([fetchTorrents(), fetchRecent()])
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[err.hash] = false }
@@ -609,14 +601,14 @@ async function openManualImport(err: { serieId: number | null; title: string })
}
async function renameFolder(f: { serie_id: number; expected: string }) {
if (!confirm(`Déplacer le contenu vers « ${basename(f.expected)} » ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer le dossier en « ${basename(f.expected)} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
const key = `folder-${f.serie_id}`
busy[key] = true
try {
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${plural(d.moved, 'fichier')} ${d.moved > 1 ? 'déplacés' : 'déplacé'})`, 'success')
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
await fetchFolders()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[key] = false }
@@ -630,8 +622,8 @@ async function requestSerie(s: Serie) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieId: s.id, serieName: s.title, seasons: [] }),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
toast(`${s.title} demandée ✓`, 'success')
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
toast(`« ${s.title} » demandée`, 'success')
await fetchRequests()
reqStore.refreshPending()
} catch { toast('Impossible de contacter le serveur', 'error') }
+20 -22
View File
@@ -2,25 +2,24 @@
<div class="px-4 md:px-10 pt-[26px] pb-12">
<div class="max-w-[1400px] flex flex-col gap-4">
<!-- En-tête -->
<header data-tour="activity-header" class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
<div class="flex items-baseline gap-3.5 flex-wrap">
<h1 class="page-title tracking-[0.01em]">Activité</h1>
<span class="flex items-center gap-[7px] text-meta text-secondary">
<span class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-ok animate-pulse' : 'bg-muted'" />
{{ polling ? 'En direct' : 'En pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
{{ polling ? 'Actualisation auto' : 'Actualisation en pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
</span>
</div>
<div class="flex items-center gap-2.5">
<button @click="importAll" :disabled="importingAll || toImportCount === 0" class="btn-primary pointer-fine:h-[38px]">
<Loader v-if="importingAll" :size="15" class="animate-spin" />
<Upload v-else :size="15" :stroke-width="2.25" />
Importer tout<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
Tout importer<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Rafraîchir" aria-label="Rafraîchir">
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
</button>
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Mettre en pause' : 'Reprendre'" :aria-label="polling ? 'Mettre en pause' : 'Reprendre'">
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'" :aria-label="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'">
<Pause v-if="polling" :size="16" />
<Play v-else :size="16" />
</button>
@@ -42,26 +41,22 @@
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
/>
<!-- Pas de clients -->
<div v-if="noClients" class="card flex flex-col items-center gap-3 py-16 text-center">
<p class="font-display text-xl font-bold text-primary">Aucun client torrent configuré</p>
<p class="text-body text-muted">Ajoutez un client pour suivre les téléchargements FanKarr.</p>
<RouterLink to="/settings/download-client" class="btn-secondary btn-sm mt-1">Configurer un client</RouterLink>
</div>
<!-- Chargement -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="visibleTorrents.length === 0" class="card flex flex-col items-center gap-2 py-16 text-center">
<p class="font-display text-xl font-bold text-primary">{{ emptyState.title }}</p>
<p class="text-body text-muted">{{ emptyState.hint }}</p>
</div>
<!-- Une ligne par torrent -->
<div v-else class="flex flex-col gap-2.5">
<TorrentCard
v-for="t in visibleTorrents"
@@ -90,6 +85,7 @@ import { RouterLink } from 'vue-router'
import { Loader, Pause, Play, RefreshCw, Upload } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useSeriesStore } from '@/stores/series'
import { plural } from '@/utils/format'
import TorrentCard from '@/components/downloads/TorrentCard.vue'
import DownloadsToolbar, { type ActivityTab } from '@/components/downloads/DownloadsToolbar.vue'
@@ -120,7 +116,7 @@ const columnOptions = [
{ key: 'size', label: 'Taille / téléchargé' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'uploaded', label: 'Envoyé (total)' },
{ key: 'upspeed', label: 'Vitesse d’envoi' },
{ key: 'upspeed', label: "Vitesse d'envoi" },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
@@ -133,7 +129,7 @@ const sortOptions = [
let pollInterval: ReturnType<typeof setInterval> | null = null
let clockInterval: ReturnType<typeof setInterval> | null = null
// ── Onglets ───────────────────────────────────────────────────
// ── Onglets ──
const isActive = (t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)
const isDone = (t: any) => t.state === 'seeding' || t.state === 'unknown'
const isToImport = (t: any) => isDone(t) && t.organizeState !== 'done'
@@ -185,13 +181,13 @@ const visibleTorrents = computed(() => {
})
const emptyState = computed(() => ({
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents de la catégorie « fankai » apparaîtront ici.' },
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents envoyés par FanKarr apparaîtront ici.' },
'done' : { title: 'Aucun torrent terminé', hint: hideImported.value ? 'Tous les torrents terminés sont importés.' : '' },
'to-import': { title: 'Rien à importer', hint: 'Tous les torrents terminés sont dans la médiathèque.' },
'errors' : { title: 'Aucune erreur', hint: 'Ni le client ni l’import ne signalent de problème.' },
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents de la catégorie « fankai » apparaîtront ici.' })
'errors' : { title: 'Aucune erreur', hint: "Ni le client ni l'import ne signalent de problème." },
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents envoyés par FanKarr apparaîtront ici.' })
// ── Présentation ──────────────────────────────────────────────
// ── Présentation ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number | undefined) => (id != null ? seriesById.value.get(id)?.poster_image : null) ?? null
@@ -211,7 +207,7 @@ function setSort(val: string) {
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
}
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function fetchTorrents() {
loading.value = true
try {
@@ -236,7 +232,7 @@ function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorre
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
// ── Actions ───────────────────────────────────────────────────
// ── Actions ──
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
@@ -244,8 +240,8 @@ async function importTorrent(torrent: any) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
})
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
if (res.ok) { toast(`« ${torrent.name} » importé`, 'success'); await fetchTorrents() }
else { const { error } = await res.json(); toast(error ?? `Impossible d'importer « ${torrent.name} »`, 'error') }
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { importing.value[torrent.hash] = false }
}
@@ -266,15 +262,17 @@ async function importAll() {
}
importingAll.value = false
await fetchTorrents()
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
errors === 0
? toast(plural(done, 'torrent importé', 'torrents importés'), 'success')
: toast(`${plural(done, 'torrent importé', 'torrents importés')}, ${plural(errors, 'erreur')}`, 'error')
}
async function deleteTorrent(torrent: any, withFiles: boolean) {
deleting.value[torrent.hash] = true
try {
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Erreur lors de la suppression', 'error')
if (res.ok) { toast(`« ${torrent.name} » supprimé`, 'success'); confirmDelete.value = null; await fetchTorrents() }
else toast('Impossible de supprimer le torrent', 'error')
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { deleting.value[torrent.hash] = false }
}
+10 -15
View File
@@ -3,33 +3,28 @@
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
<div class="relative w-full max-w-sm">
<!-- Logo -->
<div class="flex items-center justify-center gap-3 mb-8">
<FankarrLogo class="w-9 h-9" />
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
</div>
<!-- Chargement -->
<div v-if="status === 'loading'" class="card px-7 py-8 text-center text-body text-muted">
Vérification du lien…
</div>
<!-- Lien invalide -->
<div v-else-if="status === 'invalid'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
<span class="w-11 h-11 rounded-full bg-err/10 text-err flex items-center justify-center"><TriangleAlert :size="20" /></span>
<p class="font-display text-xl font-bold text-primary">Lien invalide</p>
<p class="text-body text-muted">{{ invalidReason }}</p>
</div>
<!-- Succès -->
<div v-else-if="status === 'done'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
<span class="w-11 h-11 rounded-full bg-ok/10 text-ok flex items-center justify-center"><Check :size="20" :stroke-width="2.5" /></span>
<p class="font-display text-xl font-bold text-primary">Compte créé</p>
<p class="text-body text-muted">Tu peux maintenant te connecter.</p>
<p class="text-body text-muted">Vous pouvez maintenant vous connecter.</p>
<RouterLink to="/auth" class="btn-primary w-full h-11 mt-1">Se connecter</RouterLink>
</div>
<!-- Formulaire d'inscription -->
<div v-else class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
<div class="flex flex-col gap-1">
<p class="tag-label text-accent">Invitation FanKarr</p>
@@ -39,22 +34,22 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="invite-username" class="field-label">Identifiant</label>
<input id="invite-username" v-model="username" type="text" placeholder="username" class="field" @keyup.enter="submit" />
<label for="invite-username" class="field-label">Nom d'utilisateur</label>
<input id="invite-username" v-model="username" type="text" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="invite-password" class="field-label">Mot de passe</label>
<input id="invite-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="invite-confirm" class="field-label">Confirmer</label>
<label for="invite-confirm" class="field-label">Confirmer le mot de passe</label>
<input id="invite-confirm" v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : 'Créer le compte' }}
{{ submitting ? 'Création…' : 'Créer le compte' }}
</button>
</div>
</div>
@@ -88,7 +83,7 @@ onMounted(async () => {
const res = await fetch(`/api/invites/${code}/info`)
const data = await res.json()
if (!res.ok || !data.valid) {
invalidReason.value = data.reason ?? 'Lien invalide'
invalidReason.value = data.reason ?? "Ce lien d'invitation n'est pas valide. Demandez-en un nouveau à l'administrateur."
status.value = 'invalid'
return
}
@@ -99,10 +94,10 @@ onMounted(async () => {
async function submit() {
error.value = null
if (!username.value || !password.value || !confirm.value) {
error.value = 'Tous les champs sont requis'; return
error.value = 'Tous les champs sont requis.'; return
}
if (password.value !== confirm.value) {
error.value = 'Les mots de passe ne correspondent pas'; return
error.value = 'Les mots de passe ne correspondent pas.'; return
}
submitting.value = true
@@ -111,10 +106,10 @@ async function submit() {
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ username: username.value, password: password.value }),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
submitting.value = false
if (!res.ok) { error.value = data.error; return }
if (!res.ok) { error.value = data.error ?? 'Impossible de créer le compte.'; return }
status.value = 'done'
}
</script>
+31 -35
View File
@@ -17,7 +17,7 @@
>
<Loader v-if="bulkBusy" :size="15" class="animate-spin" />
<Check v-else :size="15" :stroke-width="2.25" />
Approuver {{ approvableCount > 1 ? `les ${approvableCount}` : 'la demande' }}<span v-if="approvableCount > 1" class="max-sm:hidden"> demandes</span> avec torrent
Approuver et télécharger ({{ approvableCount }})
</button>
<div ref="headerMenuRef" class="relative">
<button @click="headerMenuOpen = !headerMenuOpen" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="headerMenuOpen">
@@ -36,7 +36,6 @@
</RouterLink>
</header>
<!-- Cmpteurs -->
<div class="chip-row">
<button
v-for="f in visibleFilters" :key="f.value"
@@ -49,7 +48,6 @@
</button>
</div>
<!-- Liste -->
<div v-if="loading" class="flex items-center gap-2 text-muted text-body py-8">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
@@ -58,7 +56,7 @@
<div v-else-if="filtered.length === 0" class="card flex flex-col items-center gap-2 py-12 text-center">
<p class="font-display text-xl font-bold text-primary">Aucune demande{{ activeFilter !== 'all' ? ' avec ce statut' : '' }}</p>
<p class="text-body text-muted">
{{ auth.isAdmin ? 'Les demandes arrivent de FanKarr ou du plugin Jellyfin.' : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
{{ auth.isAdmin ? "Les demandes des invités (depuis FanKarr ou le plugin Jellyfin) s'afficheront ici." : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
</p>
</div>
@@ -90,27 +88,25 @@
<span class="text-body" :class="req.status === 'rejected' ? 'text-muted' : 'text-secondary'">{{ scopeLine(req) }}</span>
<!-- Admin : demandeurs et avertissement torrent -->
<div v-if="auth.isAdmin && (req.status === 'pending' || req.status === 'approved')" class="flex items-center gap-2 flex-wrap">
<span
v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-[7px] h-[26px] pl-1 pr-2.5 rounded-full bg-hover text-meta text-secondary"
:title="`Demandé ${formatRelative(r.requestedAt)}`"
:title="`Demande envoyée ${formatRelative(r.requestedAt)}`"
>
<span class="w-[18px] h-[18px] rounded-full bg-border text-primary text-[10px] font-bold flex items-center justify-center">{{ r.username.charAt(0).toUpperCase() }}</span>
{{ r.username }}<span v-if="req.requesters.length > 1 && requesterScope(r)" class="text-muted">· {{ requesterScope(r) }}</span>
</span>
<span v-if="req.status === 'pending' && req.hasTorrents === false" class="flex items-center gap-[7px] text-meta text-err">
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible — approuver ne lancera aucun téléchargement.
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible : l'approbation ne lancera pas de téléchargement.
</span>
</div>
<p v-if="auth.isAdmin && req.status === 'approved'" class="text-meta text-secondary">
Téléchargement lancé à l'approbation.
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir dans Activité</RouterLink>
{{ req.hasTorrents === false ? 'Approuvée sans torrent : rien n\'a été téléchargé.' : 'Téléchargement lancé à l\'approbation.' }}
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir l'activité</RouterLink>
</p>
<!-- Invité : où en est la demande -->
<p v-if="!auth.isAdmin && req.status !== 'rejected'" class="text-meta text-muted">{{ guestStatusLine[req.status] }}</p>
<p v-if="req.status === 'rejected'" class="text-meta text-err">
@@ -119,7 +115,6 @@
</div>
</div>
<!-- Actions admin -->
<div v-if="auth.isAdmin" class="flex max-sm:items-center flex-col max-sm:flex-row gap-2 shrink-0 sm:w-52">
<button
v-if="req.status === 'pending' || req.status === 'rejected'"
@@ -131,14 +126,14 @@
: 'border border-border text-primary font-semibold hover:bg-hover'"
>
<Check v-if="req.status === 'pending' && req.hasTorrents !== false" :size="15" :stroke-width="2.25" />
{{ req.status === 'rejected' ? 'Approuver malgré tout' : req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
{{ req.status === 'rejected' ? 'Approuver finalement' : req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
</button>
<button
v-else-if="req.status === 'approved'"
@click="complete(req)"
:disabled="busy[req.id]"
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors disabled:opacity-50"
>Marquer disponible</button>
>Marquer comme disponible</button>
<RouterLink
v-else
:to="`/series/${req.serieId}`"
@@ -150,8 +145,8 @@
v-if="req.status === 'pending' || req.status === 'approved'"
@click="rejectTarget = req"
class="flex-1 h-[34px] max-sm:flex-none max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors flex items-center justify-center"
:aria-label="req.status === 'approved' ? 'Annuler la demande' : 'Refuser la demande'"
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">{{ req.status === 'approved' ? 'Annuler' : 'Refuser' }}</span></button>
aria-label="Refuser la demande"
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">Refuser</span></button>
<button
v-else
@click="remove(req)"
@@ -182,7 +177,6 @@
</div>
</div>
<!-- Actions invité -->
<div v-else class="flex flex-col max-sm:flex-row gap-2 shrink-0 sm:w-[190px]">
<RouterLink
:to="`/series/${req.serieId}`"
@@ -199,7 +193,7 @@
@click="requestAgain(req)"
:disabled="busy[req.id]"
class="h-[34px] max-sm:h-11 max-sm:flex-1 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-primary hover:bg-hover transition-colors disabled:opacity-50"
>Redemander</button>
>Demander à nouveau</button>
</div>
</article>
</div>
@@ -266,7 +260,7 @@ const filtered = computed(() => {
const approvableCount = computed(() => requests.value.filter(r => r.status === 'pending' && r.hasTorrents).length)
// ── Présentation ──────────────────────────────────────────────
// ── Présentation ──
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
const titleOf = (req: SerieRequest) => seriesById.value.get(req.serieId)?.title ?? req.serieName
@@ -288,31 +282,31 @@ function dateLabel(req: SerieRequest) {
return `ajoutée ${formatRelative(req.updatedAt)}`
}
// « Saisons 3 et 4 · 8 épisodes concernés », « 2 épisodes ciblés », « Saison 3 · demandée par marco »
// « Saisons 3 et 4 · 8 épisodes demandés », « 2 épisodes demandés », « Saison 3 · demandée par marco »
function scopeLine(req: SerieRequest) {
const episodes = mergedEpisodes(req)
const parts = [episodes.length > 0 ? `${plural(episodes.length, 'épisode')} ${episodes.length > 1 ? 'ciblés' : 'ciblé'}` : seasonsLabel(mergedSeasons(req))]
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(`${plural(req.episodeCount, 'épisode')} ${req.episodeCount > 1 ? 'concernés' : 'concerné'}`)
const parts = [episodes.length > 0 ? plural(episodes.length, 'épisode demandé', 'épisodes demandés') : seasonsLabel(mergedSeasons(req))]
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(plural(req.episodeCount, 'épisode demandé', 'épisodes demandés'))
if (auth.isAdmin) {
if (req.status !== 'pending') parts.push(`demandée par ${req.requesters.map(r => r.username).join(', ')}`)
} else if (req.requesters.length > 1) {
parts.push('demandée aussi par quelqu’un d’autre')
parts.push("demandée aussi par quelqu'un d'autre")
}
return parts.join(' · ')
}
function requesterScope(r: Requester) {
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'ép.', 'ép.')
return r.seasons.map(s => s === 0 ? 'SP' : `S${s}`).join(', ')
return r.seasons.map(s => s === 0 ? 'Spéciaux' : `S${s}`).join(', ')
}
const guestStatusLine: Record<string, string> = {
pending : 'L’administrateur n’a pas encore répondu.',
pending : "L'administrateur n'a pas encore répondu.",
approved : 'Demande approuvée : la série arrive bientôt.',
completed: 'Prête à regarder dans la médiathèque.',
}
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function load() {
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) requests.value = await res.json()
@@ -327,14 +321,14 @@ onMounted(() => {
})
onUnmounted(() => document.removeEventListener('click', closeCardMenu))
// ── Menus ─────────────────────────────────────────────────────
// ── Menus ──
const cardMenu = ref<string | null>(null)
const headerMenuOpen = ref(false)
const headerMenuRef = ref<HTMLElement | null>(null)
onClickOutside(headerMenuRef, () => { headerMenuOpen.value = false })
function closeCardMenu() { cardMenu.value = null }
// ── Actions admin ─────────────────────────────────────────────
// ── Actions admin ──
async function patch(req: SerieRequest, body: object): Promise<boolean> {
busy[req.id] = true
try {
@@ -342,7 +336,7 @@ async function patch(req: SerieRequest, body: object): Promise<boolean> {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
return true
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
finally { busy[req.id] = false }
@@ -362,7 +356,9 @@ async function approveAllWithTorrents() {
let done = 0
for (const req of targets) if (await patch(req, { action: 'approve' })) done++
bulkBusy.value = false
toast(`${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`, done === targets.length ? 'success' : 'error')
toast(done === targets.length
? `${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`
: `${plural(done, 'demande approuvée', 'demandes approuvées')} sur ${targets.length}`, done === targets.length ? 'success' : 'error')
load()
}
@@ -371,7 +367,7 @@ async function complete(req: SerieRequest) {
}
async function remove(req: SerieRequest) {
if (!confirm(`Supprimer la demande pour "${titleOf(req)}" ?`)) return
if (!confirm(`Supprimer la demande pour « ${titleOf(req)} » ?`)) return
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
load()
}
@@ -379,7 +375,7 @@ async function remove(req: SerieRequest) {
async function removeAll() {
const n = requests.value.length
if (!n) return
if (!confirm(`Supprimer les ${n} demande${n > 1 ? 's' : ''} ?`)) return
if (!confirm(n > 1 ? `Supprimer les ${n} demandes ?` : 'Supprimer la demande ?')) return
await fetch('/api/requests', { method: 'DELETE', credentials: 'include' })
load()
}
@@ -391,13 +387,13 @@ async function confirmReject(message: string) {
if (req && await patch(req, { action: 'reject', rejectionMessage: message || undefined })) load()
}
// ── Actions invité ────────────────────────────────────────────
// ── Actions invité ──
async function withdraw(req: SerieRequest) {
if (!confirm(`Annuler votre demande pour "${titleOf(req)}" ?`)) return
if (!confirm(`Annuler votre demande pour « ${titleOf(req)} » ?`)) return
busy[req.id] = true
try {
const res = await fetch(`/api/requests/${req.id}/mine`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'annuler la demande", 'error'); return }
toast('Demande annulée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -412,7 +408,7 @@ async function requestAgain(req: SerieRequest) {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieId: req.serieId, serieName: req.serieName, seasons: mine?.seasons ?? [], episodes: mine?.episodes ?? [] }),
})
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
toast('Nouvelle demande envoyée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
+47 -78
View File
@@ -1,20 +1,17 @@
<template>
<div>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Chargement…</p>
</div>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-body text-err">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Bandeau : affiche, titre, progression et actions -->
<SerieHero
:serie="data.serie"
:season-count="seasonCount"
@@ -24,7 +21,6 @@
:watched="auth.isAdmin && rssSync"
>
<template #actions>
<!-- Admin -->
<template v-if="auth.isAdmin">
<div v-if="hasSomethingToDownload" class="relative max-sm:flex-1 max-sm:min-w-0" @click.stop>
<button
@@ -38,7 +34,6 @@
<ChevronDown v-if="data.torrents_integrale.length > 1" :size="13" :stroke-width="2.5" />
</button>
<!-- Le choix -->
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1" class="menu absolute left-0 top-full mt-1.5 w-80 max-w-[90vw] z-20" role="menu">
<button
v-for="(t, i) in data.torrents_integrale"
@@ -75,13 +70,12 @@
class="menu-item text-err hover:text-err"
@click="openUnimportSerieModal(); moreMenuOpen = false"
>
<Trash2 :size="14" class="shrink-0" /> Supprimer la série…
<Trash2 :size="14" class="shrink-0" /> Retirer de la médiathèque…
</button>
</div>
</div>
</template>
<!-- Invité -->
<template v-else>
<button v-if="canRequestMore" @click="openRequestModal()" class="btn-primary max-sm:flex-1 max-sm:min-w-0">
<Clock3 :size="16" :stroke-width="2.25" />
@@ -104,9 +98,9 @@
<div v-if="auth.isAdmin && !data.scraper_synced" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1">
<p class="card-title text-accent">Série absente du scraper</p>
<p class="card-title text-accent">Torrents pas encore disponibles</p>
<p class="text-meta text-secondary">
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
Cette série n'est pas encore dans les données du catalogue. L'import manuel et le renommage restent possibles ; les torrents apparaîtront après la prochaine synchronisation.
</p>
</div>
</div>
@@ -116,9 +110,9 @@
<div class="flex items-start gap-3.5 min-w-0">
<Folder :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1 min-w-0">
<p class="card-title">Dossier différent du titre de la série</p>
<p class="card-title">Le nom du dossier ne correspond plus au titre</p>
<p class="text-meta text-secondary break-all">
{{ staleFolders.map(f => basename(f.path)).join(', ') }} → <span class="text-primary">{{ folderTarget }}</span>
Actuel : {{ staleFolders.map(f => basename(f.path)).join(', ') }} · Attendu : <span class="text-primary">{{ folderTarget }}</span>
</p>
</div>
</div>
@@ -127,7 +121,6 @@
</button>
</div>
<!-- Saisons -->
<SerieSeasonCard
v-for="season in data.seasons"
:key="season.id"
@@ -155,7 +148,6 @@
/>
</div>
<!-- Modal demande (utilisateurs) -->
<Teleport to="body">
<div v-if="requestModal" class="modal-backdrop" @click.self="requestModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="request-title">
@@ -178,7 +170,7 @@
/>
<span class="text-body text-primary">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">— {{ season.title }}</span>
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">· {{ season.title }}</span>
</span>
</label>
</div>
@@ -193,29 +185,27 @@
</Teleport>
</template>
<!-- Modal désimport série -->
<Teleport to="body">
<div v-if="unimportSerieModal" class="modal-backdrop" @click.self="unimportSerieModal = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-serie-title">
<div class="flex flex-col gap-1">
<h2 id="unimport-serie-title" class="card-title">Supprimer la série</h2>
<h2 id="unimport-serie-title" class="card-title">Retirer de la médiathèque</h2>
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
</div>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded" />
<span class="text-body text-secondary">Supprimer également les fichiers physiques du disque</span>
<span class="text-body text-secondary">Supprimer aussi les fichiers du disque</span>
</label>
<div class="flex items-center justify-end gap-2.5">
<button @click="unimportSerieModal = false" class="btn-ghost">Annuler</button>
<button @click="confirmUnimportSerie" class="btn-danger">
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
{{ deleteSerieFiles ? 'Retirer et supprimer les fichiers' : 'Retirer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal import manuel -->
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
@@ -249,17 +239,16 @@ const store = useSeriesStore()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── Mode demande (utilisateurs non-admin) ─────────────────────
// ── Demandes (invités) ──
const myRequest = ref<any>(null) // demande existante de l'utilisateur pour cette série
const requestModal = ref(false)
const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
const requestSeasons = ref<number[]>([])
const requestedSeasonNumbers = computed<number[]>(() => {
if (!myRequest.value) return []
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
if (!r) return []
// seasons=[] ET episodes=[] → toutes les saisons demandées
// seasons=[] mais episodes=[…] → seulement des épisodes ciblés, pas toute une saison
// Ni saison ni épisode précisés = série entière
if (r.seasons.length === 0 && (r.episodes ?? []).length === 0) {
return data.value?.seasons?.map((s: any) => s.season_number) ?? []
}
@@ -287,7 +276,6 @@ async function fetchMyRequest() {
}
function openRequestModal(preselectedSeason?: number) {
// Pré-cocher soit la saison cliquée, soit toutes les saisons disponibles
if (preselectedSeason !== undefined) {
requestSeasons.value = [preselectedSeason]
} else {
@@ -326,10 +314,10 @@ async function handleRequestEpisode(_seasonNumber: number, episodeId: number, to
})
if (res.ok) {
myRequest.value = await res.json()
toast('Épisode demandé ✓', 'success')
toast('Épisode demandé', 'success')
} else {
const d = await res.json()
toast(d.error ?? 'Erreur', 'error')
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
@@ -350,10 +338,10 @@ async function submitRequest() {
if (res.ok) {
myRequest.value = await res.json()
requestModal.value = false
toast('Demande envoyée ✓', 'success')
toast('Demande envoyée', 'success')
} else {
const d = await res.json()
toast(d.error ?? 'Erreur', 'error')
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
@@ -392,9 +380,9 @@ export interface ActiveTorrent { hash: string; progress: number; state: string;
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
// Suivi des états précédents pour détecter les transitions (download → seeding)
const _prevTorrentStates = new Map<string, string>() // hash → state
const _prevFileProgresses = new Map<string, number>() // hash:index → progress
// État au poll précédent, pour repérer les téléchargements terminés
const _prevTorrentStates = new Map<string, string>()
const _prevFileProgresses = new Map<string, number>()
const _recentlyOrganized = new Set<string>() // hash:fileIndex ou hash seul
const data = computed(() => store.currentSerie)
@@ -404,21 +392,19 @@ const episodeCount = computed(() => data.value?.seasons.reduce((n, s) => n + s
const organizedCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.filter(e => e.organized).length, 0) ?? 0)
const allOrganized = computed(() => episodeCount.value > 0 && organizedCount.value >= episodeCount.value)
// Invité : reste-t-il des saisons à demander ?
const canRequestMore = computed(() => {
if (!data.value || allOrganized.value) return false
if (!myRequest.value) return true
return data.value.seasons.some(s => s.organized_state !== 'complete' && !requestedSeasonNumbers.value.includes(s.season_number))
})
// ── Helpers intégrale ─────────────────────────────────────────
// ── Intégrales ──
function integraleGroupLabels(): string[] {
const torrents = data.value?.torrents_integrale ?? []
const names = torrents.map((t: any) => t.torrent_name ?? null)
const allPresent = names.every((n: any) => n !== null && String(n).trim() !== '')
const allUnique = allPresent && new Set(names).size === names.length
if (allUnique) return names as string[]
// Fallback : titre Nyaa brut
return torrents.map((t: any, i: number) => {
const raw = t.raw ?? ''
if (raw) return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
@@ -426,9 +412,7 @@ function integraleGroupLabels(): string[] {
})
}
// Épisodes couverts par une intégrale donnée (hash commun)
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
// Épisodes déjà importés : évite de retélécharger une autre version (x264/x265)
const organizedEpisodeIds = computed(() => {
const ids = new Set<number>()
for (const season of data.value?.seasons ?? [])
@@ -450,7 +434,6 @@ function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
return covered
}
// Vrai s'il reste des épisodes/saisons non couverts par cette intégrale
function hasUncoveredByIntegrale(integraleIndex: number): boolean {
if (!data.value) return false
const covered = integraleEpisodeCoverage(integraleIndex)
@@ -460,7 +443,7 @@ function hasUncoveredByIntegrale(integraleIndex: number): boolean {
return false
}
// ── Helpers d'état ────────────────────────────────────────────
// ── État local ──
function isDownloading(key: string) { return downloading.value.includes(key) }
function isDownloaded(key: string) { return downloaded.value.includes(key) }
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
@@ -479,15 +462,14 @@ function extractHash(torrent: any): string | null {
return m ? m[1].toLowerCase() : null
}
function isAlreadyQueued(torrent: any): boolean {
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
// Fichier d'un pack : le hash ne dit pas si ce fichier précis est en cours
if (torrent?.file_index != null) return false
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
// ── Fetch ─────────────────────────────────────────────────────
// ── Chargement ──
function load() { store.fetchSerieDetail(Number(route.params.id)) }
async function fetchOrganized() {
@@ -506,13 +488,13 @@ async function fetchFolderStatus() {
}
async function renameFolder() {
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer le dossier en « ${folderTarget.value} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
renamingFolder.value = true
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
await fetchOrganized()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { renamingFolder.value = false }
@@ -544,9 +526,9 @@ async function toggleRssSync() {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ serieName }),
})
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
if (res.ok) { rssSync.value = true; toast('Surveillance activée : les nouveaux épisodes seront téléchargés automatiquement', 'success') }
}
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
} catch { toast('Impossible de mettre à jour la surveillance', 'error') }
}
async function fetchActiveDownloads() {
@@ -562,7 +544,7 @@ async function fetchActiveDownloads() {
for (const t of list) {
const prevState = _prevTorrentStates.get(t.hash)
// Détecter fichier individuel terminé (progression par fichier)
// Fichier terminé (client avec progression par fichier)
if (t.files) {
for (const f of t.files) {
const key = `${t.hash}:${f.index}`
@@ -575,7 +557,7 @@ async function fetchActiveDownloads() {
}
}
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
// Torrent entier terminé (client sans progression par fichier)
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
_recentlyOrganized.add(t.hash)
toOrganize.set(t.hash, t)
@@ -609,7 +591,7 @@ async function triggerOrganize(torrent: any) {
if (res.ok) {
const result = await res.json()
if (result.done > 0) {
toast(`${result.done} épisode(s) importé(s) automatiquement ✓`, 'success')
toast(`${plural(result.done, 'épisode importé', 'épisodes importés')} automatiquement`, 'success')
await fetchOrganized()
load()
}
@@ -619,33 +601,29 @@ async function triggerOrganize(torrent: any) {
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
// ── Download ──────────────────────────────────────────────────
// ── Téléchargement ──
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
if (isDownloading(key) || isDownloaded(key)) return
addDownloading(key)
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
removeDownloading(key)
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Erreur inconnue', 'error')
if (result.success) { addDownloaded(key); toast('Téléchargement lancé', 'success'); fetchActiveDownloads() }
else toast(result.error ?? 'Impossible de lancer le téléchargement', 'error')
}
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
// Construit la liste de tout ce qui doit être téléchargé.
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
// si omis, utilise toutes les intégrales disponibles.
// Reste à envoyer : une intégrale (ou toutes), puis packs et épisodes non couverts
function collectDownloadables(integraleIndex?: number): DlItem[] {
if (!data.value) return []
const result: DlItem[] = []
const covered = new Set<number>()
// Une autre version déjà envoyée couvre déjà ses épisodes
data.value.torrents_integrale.forEach((t: any, i: number) => {
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
})
// ── 1. Intégrale(s) ───────────────────────────────────────────
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
: data.value.torrents_integrale.map((t: any, i: number) => ({ t, i }))
@@ -653,7 +631,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const { t, i } of integrales) {
if (!t) continue
// Épisodes apportés par CETTE intégrale
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
const coveredByThis = new Set<number>()
if (hash)
@@ -671,10 +648,8 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const id of coveredByThis) covered.add(id)
}
// ── 2. Packs saison non couverts ──────────────────────────────
for (const season of data.value.seasons) {
if (!season.torrents?.length || season.organized_state === 'complete') continue
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
if (!hasUncovered) continue
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
@@ -684,7 +659,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
for (const ep of season.episodes) covered.add(ep.id)
}
// ── 3. Épisodes individuels non couverts ─────────────────────
for (const season of data.value.seasons)
for (const ep of season.episodes) {
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
@@ -694,9 +668,7 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
return result
}
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
// que le garde-fou versions ne laisserait passer aucun torrent.
// Même calcul que downloadAll : jamais de bouton sans effet
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
async function downloadAll(integraleIndex?: number) {
@@ -707,7 +679,7 @@ async function downloadAll(integraleIndex?: number) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingAll.value = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger', 'success')
}
@@ -728,7 +700,6 @@ async function downloadSeason(season: Season, packHash?: string) {
}
} else {
for (const ep of seasonAny.episodes ?? []) {
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
const epTorrent = packHash
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
: ep.torrent
@@ -741,11 +712,11 @@ async function downloadSeason(season: Season, packHash?: string) {
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
}
downloadingSeason.value[season.id] = false
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
}
// ── Actions ───────────────────────────────────────────────────
// ── Actions ──
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
@@ -757,9 +728,8 @@ async function confirmUnimportSerie() { unimportSerieModal.value = false; await
async function unimportSeason(season: any, deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la saison', 'error'); return }
toast(deleteFile ? 'Saison retirée et fichiers supprimés' : 'Saison retirée de la médiathèque', 'success')
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
removeDownloaded(`season-${season.id}`)
await fetchOrganized(); load()
@@ -769,9 +739,8 @@ async function unimportSeason(season: any, deleteFile: boolean) {
async function unimportSerie(deleteFile: boolean) {
try {
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
const d = await res.json()
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la série', 'error'); return }
toast(deleteFile ? 'Série retirée et fichiers supprimés' : 'Série retirée de la médiathèque', 'success')
downloaded.value = []
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -786,7 +755,7 @@ async function renameEpisode(ep: any, _season: any) {
body: JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
})
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur rename', 'error'); return }
if (!res.ok) { toast(d.error ?? "Impossible de renommer l'épisode", 'error'); return }
if (d.renamed) { toast(`Renommé : ${d.new_name}`, 'success'); await fetchOrganized() }
else toast(d.message ?? 'Nom déjà correct', 'success')
} catch { toast('Impossible de contacter le serveur', 'error') }
@@ -797,8 +766,8 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
epActionLoading.value[ep.id] = true
try {
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
clearEpDownloaded(ep)
await fetchOrganized(); load()
} catch { toast('Impossible de contacter le serveur', 'error') }
+28 -30
View File
@@ -16,12 +16,11 @@
:selectable="auth.isAdmin"
/>
<!-- Grille -->
<div data-tour="series-grid" class="pt-1.5">
<div v-if="store.loadingSeries && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
<p class="text-body">Chargement du catalogue…</p>
<p class="text-body">Chargement de la médiathèque…</p>
</div>
<div v-else-if="store.error && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64">
@@ -42,17 +41,16 @@
>
<template v-for="item in gridItems" :key="item.key">
<!-- Tuile -->
<!-- Tuile lettre (tri alphabétique) -->
<div
v-if="item.type === 'letter'"
class="@container aspect-[2/3] rounded-poster border border-border-light bg-linear-to-b from-card to-main max-sm:hidden flex flex-col justify-between px-[11%] pt-[11%] pb-[10%] overflow-hidden select-none"
aria-hidden="true"
>
<span class="font-display text-[80cqw] leading-[0.9] font-extrabold text-accent">{{ item.letter }}</span>
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ item.count }} série{{ item.count > 1 ? 's' : '' }}</span>
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ plural(item.count, 'série') }}</span>
</div>
<!-- Affiche -->
<component
v-else
:is="selecting ? 'button' : RouterLink"
@@ -90,7 +88,7 @@
<Check :size="13" :stroke-width="3" />
</span>
<span v-if="item.state.watched" title="Surveillée" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
<span v-if="item.state.watched" title="Surveillée : nouveaux épisodes téléchargés automatiquement" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
<Rss :size="12" :stroke-width="2.5" />
</span>
<span v-else-if="item.state.requested" class="absolute right-2 top-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-accent text-[11.5px] font-bold flex items-center gap-1.5">
@@ -141,14 +139,13 @@
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-4 z-10 mt-8 mx-auto w-fit max-w-full bg-card border border-border rounded-card px-4 py-3 flex items-center gap-x-3 gap-y-2 flex-wrap shadow-[0_16px_40px_rgb(0_0_0/0.5)]">
<span class="text-body font-bold text-primary">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<span class="text-body font-bold text-primary">{{ plural(selected.size, 'sélectionnée') }}</span>
<button @click="selectAllFiltered" class="text-meta text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Désélectionner</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Tout désélectionner</button>
<span class="hidden sm:block w-px h-6 bg-hover" />
<div class="flex items-center gap-2 flex-wrap">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer</button>
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer les fichiers</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-icon btn-sm" title="Quitter la sélection" aria-label="Quitter la sélection"><X :size="14" /></button>
@@ -169,6 +166,7 @@ import { useToast } from '@/composables/useToast'
import { usePosterSize } from '@/composables/usePosterSize'
import { useLibrarySearch } from '@/composables/useLibrarySearch'
import { isNew, statusLabel } from '@/utils/series'
import { plural } from '@/utils/format'
import SeriesToolbar, { type LibraryFilter } from '@/components/series/SeriesToolbar.vue'
defineOptions({ name: 'SeriesView' })
@@ -185,13 +183,13 @@ const activeFilter = ref('all')
const activeSort = ref('alpha')
const sortOptions = [
{ label: 'A → Z', value: 'alpha' },
{ label: 'Z → A', value: 'alpha-desc' },
{ label: 'Récentes', value: 'recent' },
{ label: 'Importées d’abord', value: 'imported' },
{ label: 'De A à Z', value: 'alpha' },
{ label: 'De Z à A', value: 'alpha-desc' },
{ label: 'Année de sortie', value: 'recent' },
{ label: "Importées d'abord", value: 'imported' },
]
// ── Filtres à compteurs ──────────────────────────────────────
// ── Filtres à compteur ──
const myRequested = ref<Set<number>>(new Set())
const predicates: Record<string, (s: Serie) => boolean> = {
@@ -223,7 +221,7 @@ const filters = computed<LibraryFilter[]>(() => (auth.isAdmin
{ value: 'all', label: 'Toutes' },
{ value: 'complete', label: 'Prêtes à regarder' },
{ value: 'partial', label: 'Partielles' },
{ value: 'downloading', label: 'Bientôt', attention: true },
{ value: 'downloading', label: 'Bientôt là', attention: true },
{ value: 'not-here', label: 'Pas encore là' },
{ value: 'mine', label: 'Mes demandes', attention: true },
]
@@ -263,7 +261,7 @@ function normalize(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
// ── État des séries ─────────────────────────────────────
// ── État des séries ──
const errorsBySerie = ref<Map<number, number>>(new Map())
const staleFolders = ref<Set<number>>(new Set())
@@ -307,7 +305,7 @@ function posterState(s: Serie): PosterState {
dim : notHere,
isNew : s.download_state !== 'none' && isNew(s.last_imported_at),
progress : null,
chip : s.download_state === 'downloading' ? { label: 'Bientôt', tone: 'accent', icon: 'soon' }
chip : s.download_state === 'downloading' ? { label: 'Bientôt là', tone: 'accent', icon: 'soon' }
: partial ?? (notHere ? { label: 'Pas encore là', tone: 'muted' } : null),
}
}
@@ -320,13 +318,13 @@ function posterState(s: Serie): PosterState {
dim : s.download_state === 'none' && !s.has_torrents,
isNew : isNew(s.last_imported_at),
progress : errors ? null : downloadProgress(s),
chip : errors ? { label: `${errors} erreur${errors > 1 ? 's' : ''}`, tone: 'err', icon: 'error', title: 'Import en erreur' }
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Dossier différent du titre de la série' }
chip : errors ? { label: plural(errors, 'erreur'), tone: 'err', icon: 'error', title: 'Import en erreur' }
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Nom de dossier différent du titre' }
: partial ?? (s.download_state === 'none' && !s.has_torrents ? { label: 'Sans torrent', tone: 'muted' } : null),
}
}
// Grille──
// ── Grille ──
function letterOf(title: string) {
const c = normalize(title).charAt(0).toUpperCase()
return /[A-Z]/.test(c) ? c : '#'
@@ -353,7 +351,7 @@ const gridItems = computed<GridItem[]>(() => {
return items
})
// ── Sélection multiple ───────────────────────────────────────
// ── Sélection multiple ──
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
const bulkBusy = ref(false)
@@ -377,9 +375,9 @@ async function bulkRename() {
body: JSON.stringify({ serie_ids: [...selected.value] }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
const errs = data.errors?.length ?? 0
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
toast(`${plural(data.done, 'fichier renommé', 'fichiers renommés')}${errs ? ` · ${plural(errs, 'erreur')}` : ''}`, errs ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -396,12 +394,12 @@ async function bulkSync(enabled: boolean) {
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
if (!res.ok) { toast(data.error ?? 'Impossible de mettre à jour la surveillance', 'error'); return }
for (const s of targets) s.rss_synced = enabled
const n = data.changed
toast(enabled
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
? `${plural(n, 'série ajoutée', 'séries ajoutées')} à la surveillance`
: `${plural(n, 'série retirée', 'séries retirées')} de la surveillance`, 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -409,7 +407,7 @@ async function bulkSync(enabled: boolean) {
}
}
// ── Polling downloads + notifs import ────────────────────────
// ── Suivi des téléchargements et des imports ──
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
let notifsPrimed = false
@@ -441,8 +439,8 @@ async function fetchOrganizeNotifs() {
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
? `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}, ${plural(n.errors, 'erreur')}`
: `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
+9 -12
View File
@@ -1,7 +1,6 @@
<template>
<div class="min-h-screen bg-main flex flex-col md:flex-row">
<!-- Rail des étapes -->
<aside class="md:w-[280px] md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-card flex flex-col shrink-0">
<div class="flex items-center gap-[11px] pl-5 pr-3 pt-[22px] pb-5">
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
@@ -29,7 +28,6 @@
</button>
</div>
<!-- Progression mobile -->
<div class="md:hidden px-5 pb-4">
<div class="progress" role="progressbar" :aria-valuenow="index + 1" aria-valuemin="1" :aria-valuemax="steps.length">
<div class="progress-bar" :style="{ width: `${((index + 1) / steps.length) * 100}%` }" />
@@ -70,7 +68,6 @@
</p>
</aside>
<!-- Contenu -->
<main class="flex-1 min-w-0">
<div v-if="!loaded" class="flex items-center justify-center py-32">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
@@ -103,7 +100,7 @@
Passer
</button>
<button @click="next(true)" :disabled="busy" class="btn-primary">
{{ busy ? '…' : index === 0 ? 'Commencer' : 'Suivant' }}
{{ busy ? 'Enregistrement…' : index === 0 ? 'Commencer' : 'Suivant' }}
<ArrowRight v-if="!busy" :size="15" />
</button>
</footer>
@@ -142,9 +139,9 @@ interface StepDef {
const steps: StepDef[] = [
{
id: 'welcome', label: 'Bienvenue', hint: 'Ce qui vous attend', component: SetupWelcomeStep,
id: 'welcome', label: 'Bienvenue', hint: 'Présentation', component: SetupWelcomeStep,
title: 'Bienvenue dans FanKarr',
description: 'Quelques minutes pour préparer FanKarr : où ranger vos séries, quel client torrent utiliser et comment les retrouver dans votre lecteur.',
description: 'Cet assistant règle les dossiers, le client torrent, le serveur multimédia et le catalogue.',
},
{
id: 'paths', label: 'Dossiers', hint: 'Téléchargements et médiathèque', component: SetupPathsStep,
@@ -153,28 +150,28 @@ const steps: StepDef[] = [
},
{
id: 'client', label: 'Client torrent', hint: 'qBittorrent, Transmission…', component: SetupClientStep,
title: 'Client de téléchargement',
title: 'Client torrent',
description: 'FanKarr envoie les torrents à votre client puis surveille leur progression pour les importer.',
},
{
id: 'import', label: 'Import', hint: 'Automatisme et nommage', component: SetupImportStep,
id: 'import', label: 'Import', hint: 'Import automatique et nommage', component: SetupImportStep,
title: 'Options d\'import',
description: 'Réglez ce qui se passe quand un téléchargement se termine.',
},
{
id: 'media-server', label: 'Serveur multimédia', hint: 'Jellyfin, Plex', component: SetupMediaServerStep, optional: true,
title: 'Jellyfin et Plex',
description: 'Connectez votre serveur multimédia pour y retrouver vos séries. Vous pouvez passer cette étape.',
description: 'Connectez votre serveur multimédia pour y retrouver vos séries.',
},
{
id: 'catalog', label: 'Catalogue', hint: 'Séries Fankai', component: SetupCatalogStep,
title: 'Catalogue Fankai',
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met à jour tout seul ensuite.',
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met ensuite à jour automatiquement.',
},
{
id: 'recap', label: 'Récapitulatif', hint: 'Vérifier et terminer', component: SetupRecapStep,
title: 'Tout est prêt ?',
description: 'Vérifiez la configuration avant de commencer. Chaque point reste modifiable.',
title: 'Récapitulatif',
description: 'Vérifiez la configuration avant de commencer.',
},
]
+7 -11
View File
@@ -8,7 +8,6 @@
</button>
</Teleport>
<!-- ── Version ────────────────────────────────────────────── -->
<SettingsSection title="Version">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -18,7 +17,7 @@
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
</span>
<span class="text-meta text-muted">
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
<template v-if="debug">Serveur démarré il y a {{ uptime }} · </template>mises à jour vérifiées toutes les 6 h
</span>
</div>
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -27,8 +26,7 @@
</div>
</SettingsSection>
<!-- ── Diag ─────────────────────────────────────────── -->
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
<SettingsSection title="Diagnostic" description="État du serveur, utile pour signaler un bug.">
<template #actions>
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</template>
@@ -41,7 +39,6 @@
<p v-else class="text-meta text-muted">Chargement…</p>
</SettingsSection>
<!-- ── Dev ──────────────────────────────────────── -->
<SettingsSection title="Développement">
<template #actions>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -49,11 +46,10 @@
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
description="Affiche aussi ce diagnostic en bas de l'accueil."
/>
</SettingsSection>
<!-- ── Assistant et visite ────────────────────────────────── -->
<SettingsSection title="Assistant et visite guidée">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -85,7 +81,7 @@ const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
let loaded = false
// ── Version ───────────────────────────────────────────────────
// ── Version ──
const GITHUB_REPO = 'masutayunikon/FanKarr'
const currentVersion = ref('')
const latestVersion = ref('')
@@ -120,7 +116,7 @@ async function checkForUpdates() {
}
}
// ── Diag ────────────────────────────────────────────────
// ── Diagnostic ──
const debug = ref<any>(null)
async function fetchDebug() {
@@ -139,8 +135,8 @@ const diagnostics = computed(() => debug.value ? [
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
{ label: `entrées en cache (gardées ${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: "tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', accent: debug.value.worker.running },
] : [])
onMounted(async () => {
+21 -26
View File
@@ -8,14 +8,12 @@
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Données manquantes -->
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
@@ -23,17 +21,16 @@
<p class="card-title text-accent">Données manquantes</p>
<p class="text-meta text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
Synchronisez le catalogue pour utiliser FanKarr.
</p>
</div>
</div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
{{ updating ? 'Synchronisation…' : 'Synchroniser' }}
</button>
</div>
<!-- Sources -->
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
<SettingsSection title="Sources" description="Les séries et les torrents viennent du scraper GitHub. L'API Fankai complète les séries qui n'y figurent pas encore.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -41,36 +38,35 @@
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
</span>
<span class="text-meta text-muted">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
{{ status.empty ? 'Aucune donnée chargée' : plural(status.count, 'série disponible', 'séries disponibles') }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
</span>
</div>
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
<span class="text-meta text-muted shrink-0">Données gardées en cache 1 h</span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">API Fankai</span>
<span class="text-meta text-muted">
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
<template v-if="apiOnlyCount > 0">{{ plural(apiOnlyCount, 'série vient', 'séries viennent') }} uniquement de l'API : fiche et images, mais pas de torrents.</template>
<template v-else>Fournit les titres à jour et les séries absentes du scraper.</template>
</span>
</div>
</div>
</SettingsSection>
<!-- Surveillance RSS -->
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés automatiquement au client. Vérification toutes les 6 heures.">
<template #actions>
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
<Loader v-if="runningRss" :size="14" class="animate-spin" />
<RefreshCw v-else :size="14" />
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
{{ runningRss ? 'Vérification…' : 'Vérifier maintenant' }}
</button>
</template>
<div class="flex items-baseline gap-[9px]">
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ watched.length > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
</div>
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
<RouterLink
@@ -79,17 +75,16 @@
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
>{{ s.serieName }}</RouterLink>
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
+ {{ plural(watched.length - visibleWatched.length, 'autre') }}
</button>
</div>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou en sélectionnant plusieurs séries dans la médiathèque.</p>
</SettingsSection>
<!-- Surveillances orphelines -->
<SettingsSection
v-if="syncOrphans.length > 0"
:title="`Surveillances orphelines · ${syncOrphans.length}`"
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
:title="`Séries surveillées disparues du catalogue · ${syncOrphans.length}`"
description="Ces séries n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
>
<div class="flex flex-col rounded-field bg-main border border-border-light">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
@@ -111,7 +106,7 @@ import { RouterLink } from 'vue-router'
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useSeriesStore } from '@/stores/series'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
@@ -153,10 +148,10 @@ async function fetchSyncOrphans() {
async function removeSyncOrphan(serieId: number) {
try {
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
if (!res.ok) { toast('Impossible de retirer la surveillance', 'error'); return }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
watched.value = watched.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
toast('Surveillance retirée', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
@@ -167,8 +162,8 @@ async function runRss() {
try {
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de vérifier les séries surveillées', 'error'); return }
toast(data.sent > 0 ? `${plural(data.sent, 'épisode envoyé', 'épisodes envoyés')} au client` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -183,11 +178,11 @@ async function update() {
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
toast(`${count} séries chargées ✓`, 'success')
toast(`Catalogue synchronisé : ${plural(count, 'série')}`, 'success')
fetchSyncOrphans()
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
toast(error ?? 'Impossible de synchroniser le catalogue', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+21 -20
View File
@@ -7,11 +7,10 @@
</button>
</Teleport>
<!-- Clients configurés -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Clients configurés</h3>
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
<p class="text-meta text-muted">FanKarr range ses torrents dans une catégorie dédiée (« fankai » par défaut) et ne touche pas aux autres.</p>
</div>
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
@@ -57,11 +56,10 @@
</div>
</section>
<!-- Types pris en charge -->
<section class="bg-card rounded-card overflow-hidden">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Types pris en charge</h3>
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
<h3 class="card-title">Clients compatibles</h3>
<p class="text-meta text-muted">Fonctions disponibles selon le client. Vous pouvez en configurer plusieurs.</p>
</div>
<div class="overflow-x-auto border-t border-hover">
<table class="w-full min-w-[720px] text-meta">
@@ -73,7 +71,7 @@
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppression des fichiers</th>
</tr>
</thead>
<tbody>
@@ -91,9 +89,9 @@
</div>
</td>
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Immédiat' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Via FanKarr' : row.torrentUpload === 'url' ? 'Lien direct' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
</tr>
@@ -101,13 +99,12 @@
</table>
</div>
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
<span><span class="text-ok">Immédiat</span> : seuls les fichiers choisis sont téléchargés, dès la réception des métadonnées.</span>
<span><span class="text-ok">Via FanKarr</span> : FanKarr récupère le fichier .torrent et le transmet au client (fonctionne avec les liens protégés).</span>
<span><span class="text-secondary">Lien direct</span> : le client récupère lui-même le fichier .torrent.</span>
</div>
</section>
<!-- Modale ajout / modification -->
<Teleport to="body">
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
@@ -155,28 +152,32 @@ function matrixHealth(type: string) {
const clientMatrix = [
{
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe · Clé API (5.2+)", fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
{
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: 'Adresse XML-RPC', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'utorrent', label: 'uTorrent', version: '3.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
},
{
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
}
},
{
id: 'deluge', label: 'Deluge', version: 'Interface web',
auth: 'Mot de passe', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
]
const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
@@ -211,7 +212,7 @@ async function testClient(uuid: string) {
try {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
toast(ok ? 'Connexion réussie' : (message ?? 'Échec de la connexion'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -229,7 +230,7 @@ async function deleteClient(client: SavedTorrentClient) {
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
toast('Impossible de supprimer le client', 'error')
}
}
+71 -72
View File
@@ -5,18 +5,17 @@
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]" title="Repère les épisodes présents sur le disque mais pas encore suivis">
<Loader v-if="scanning" :size="15" class="animate-spin" />
<ScanSearch v-else :size="15" />
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
</Teleport>
<!-- ── Nommage ────────────────────────────────────────────── -->
<SettingsSection title="Nommage">
<template #description>
FanKarr renomme les fichiers d’après le catalogue Fankai, en
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
FanKarr renomme les fichiers d'après le catalogue Fankai.
<span class="text-secondary">{{ nfoSupport ? 'NFO activés : chaque fichier prend le nom de son NFO.' : 'NFO désactivés : noms du type Titre.S01E01.' }}</span>
</template>
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
@@ -24,7 +23,8 @@
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
<span class="text-meta text-muted">
<template v-if="loading">Analyse des fichiers importés…</template>
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
<template v-else-if="seriesNeedingRename > 1">{{ totalNeedsRename }} épisodes répartis sur {{ seriesNeedingRename }} séries ne portent pas le nom attendu.</template>
<template v-else-if="totalNeedsRename > 0">{{ plural(totalNeedsRename, 'épisode ne porte', 'épisodes ne portent') }} pas le nom attendu.</template>
<template v-else>Tous les fichiers portent le nom attendu.</template>
</span>
</div>
@@ -33,7 +33,7 @@
</button>
</div>
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
{{ plural(renameResult.done, 'fichier renommé', 'fichiers renommés') }}<template v-if="renameResult.errors.length > 0"> · {{ plural(renameResult.errors.length, 'erreur') }}</template>
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
</p>
@@ -43,7 +43,7 @@
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
<span class="text-meta text-muted">
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
<template v-else>{{ staleFolders.length > 1 ? `${staleFolders.length} dossiers ne portent pas le titre de leur série` : 'Un dossier ne porte pas le titre de sa série' }} : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
</span>
</div>
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -54,24 +54,23 @@
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
<span class="text-sm font-medium text-primary">Tout resynchroniser</span>
<span class="text-meta text-muted">Recharge entièrement le catalogue, met à jour les identifiants d'épisodes et renomme les fichiers. Utile si des épisodes ne sont plus reconnus.</span>
</div>
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
{{ forcingRefresh ? 'Resynchronisation…' : 'Lancer' }}
</button>
</div>
</SettingsSection>
<!-- ── Métadonnées ────────────────────────────────────────── -->
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
<SettingsSection title="Métadonnées" description="Fichiers NFO et images, pour Infuse ou les lecteurs sans agent Fankai.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
<span class="text-meta text-muted">
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
<template v-if="!nfoSupport">Compare les NFO locaux à ceux du dépôt Fankai. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux à ceux du dépôt Fankai (toutes les heures). Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux à ceux du dépôt Fankai et télécharge ceux qui ont changé (toutes les heures).</template>
</span>
</div>
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -90,22 +89,22 @@
</div>
</SettingsSection>
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
<SettingsSection title="Cohérence de la médiathèque" description="Compare les épisodes suivis par FanKarr au contenu du disque et au catalogue.">
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">{{ trackedFiles > 1 ? 'fichiers suivis' : 'fichier suivi' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">{{ orphans.length > 1 ? 'épisodes disparus du catalogue' : 'épisode disparu du catalogue' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">{{ missingFiles > 1 ? 'fichiers introuvables' : 'fichier introuvable' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis la dernière analyse</span></span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
<span class="text-sm font-medium text-primary">Retirer les épisodes disparus du catalogue</span>
<span class="text-meta text-muted">
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
<template v-if="orphans.length === 0">Aucun épisode suivi n'a disparu du catalogue.</template>
<template v-else-if="orphans.length === 1">Retire du suivi l'épisode qui n'existe plus dans le catalogue. Aucun fichier n'est supprimé.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n'existent plus dans le catalogue. Aucun fichier n'est supprimé.</template>
</span>
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
@@ -113,7 +112,7 @@
</button>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ removingOrphans ? '…' : 'Nettoyer' }}
{{ removingOrphans ? 'Retrait…' : 'Retirer' }}
</button>
</div>
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
@@ -121,7 +120,7 @@
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? 'Chemin inconnu' }}</span>
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
</div>
@@ -131,7 +130,7 @@
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
<span class="text-meta text-muted">À utiliser si Fankai a renommé une série et que ses épisodes ne sont plus reconnus.</span>
</div>
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ migrating ? 'Migration…' : 'Migrer' }}
@@ -139,12 +138,11 @@
</div>
</SettingsSection>
<!-- ── Fichiers importés, série par série ──────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
<div class="flex flex-col gap-0.5">
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ plural(series.length, 'série') }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode. Renommez ou retirez les épisodes un par un.</p>
</div>
<button
v-if="totalNeedsRename > 0"
@@ -162,7 +160,7 @@
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
<p class="text-body text-primary">Aucune série importée</p>
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
<p class="text-meta text-muted">Importez des séries depuis la page Activité ou par l'import manuel d'une fiche série.</p>
</div>
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
@@ -176,13 +174,13 @@
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
</button>
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ plural(serie.total, 'épisode') }}</span>
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ plural(missingIn(serie), 'introuvable') }}</span>
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
<div class="flex items-center gap-1.5 ml-auto">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
</div>
</div>
@@ -210,10 +208,10 @@
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
{{ renamingEp[ep.episode_id] ? 'Renommage…' : 'Renommer' }}
</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer le fichier</button>
</div>
</div>
</div>
@@ -228,12 +226,12 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
@@ -259,7 +257,7 @@ const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
@@ -277,17 +275,16 @@ const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function load() {
loading.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
if (!res.ok) { toast('Impossible de charger les fichiers importés', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
orphans.value = data.orphans ?? []
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally {
loading.value = false
@@ -313,8 +310,8 @@ async function scan() {
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
if (!res.ok) { toast(data.error ?? "Impossible d'analyser la médiathèque", 'error'); return }
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -327,17 +324,17 @@ async function renameFolders() {
const targets = staleFolders.value
if (!targets.length) return
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer ${targets.length > 1 ? 'les dossiers' : 'le dossier'} en ${names} ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
renamingFolders.value = true
let moved = 0
try {
for (const f of targets) {
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'impossible de renommer le dossier'}`, 'error'); continue }
moved += d.moved ?? 0
}
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
if (moved > 0) toast(plural(moved, 'fichier déplacé', 'fichiers déplacés'), 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -351,7 +348,7 @@ async function checkNfo() {
try {
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
const data = await res.json()
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Impossible de lancer la vérification des NFO'), res.ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -364,8 +361,8 @@ async function migrateIds() {
try {
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de migrer les identifiants', 'error'); return }
toast(data.updated > 0 ? plural(data.updated, 'identifiant migré', 'identifiants migrés') : 'Aucun identifiant à migrer', 'success')
if (data.updated > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -391,7 +388,8 @@ async function renameAll(serieId?: number) {
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
} catch {
@@ -411,7 +409,7 @@ async function renameSingle(ep: any, serie: any) {
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
if (!res.ok) { toast(data.error ?? "Impossible de renommer l'épisode", 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
@@ -431,12 +429,13 @@ async function forceMetadataRefresh() {
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de tout resynchroniser', 'error'); return }
const parts = [plural(data.count, 'série rechargée', 'séries rechargées')]
if (data.migrated > 0) parts.push(plural(data.migrated, 'identifiant migré', 'identifiants migrés'))
if (data.deduped > 0) parts.push(plural(data.deduped, 'doublon retiré', 'doublons retirés'))
parts.push(plural(data.renamed, 'fichier renommé', 'fichiers renommés'))
if (data.errors > 0) parts.push(plural(data.errors, 'erreur'))
toast(parts.join(' · '), data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -446,13 +445,13 @@ async function forceMetadataRefresh() {
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de la médiathèque ? Les vidéos ne sont pas touchées.')) return
purgingNfo.value = true
try {
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de purger les NFO', 'error'); return }
toast(`${plural(data.deleted, 'fichier supprimé', 'fichiers supprimés')}${data.errors.length > 0 ? ` (${plural(data.errors.length, 'erreur')})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -461,15 +460,16 @@ async function purgeNfo() {
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
if (!confirm(deleteFile
? `Retirer « ${serie.serie_title} » et supprimer ses fichiers du disque ? Cette action est irréversible.`
: `Retirer « ${serie.serie_title} » de la médiathèque ? Les fichiers restent sur le disque.`)) return
renamingAll.value = true
try {
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer les fichiers de la série' : 'Impossible de retirer la série'), 'error'); return }
toast(deleteFile ? 'Épisodes retirés et fichiers supprimés' : 'Épisodes retirés de la médiathèque', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -481,15 +481,14 @@ async function unimportSerie(serie: any, deleteFile: boolean) {
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -499,7 +498,7 @@ async function unimportEp(ep: any, deleteFile: boolean) {
}
async function removeOrphans(episodeIds?: number[]) {
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
if (!episodeIds && !confirm(`Retirer du suivi ${plural(orphans.value.length, 'épisode disparu', 'épisodes disparus')} du catalogue ? Aucun fichier n'est supprimé.`)) return
removingOrphans.value = true
try {
const res = await fetch('/api/organized-summary/orphans', {
@@ -509,8 +508,8 @@ async function removeOrphans(episodeIds?: number[]) {
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de retirer les épisodes disparus', 'error'); return }
toast(`${plural(data.removed, 'épisode retiré', 'épisodes retirés')} du suivi`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
+16 -18
View File
@@ -2,41 +2,38 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
</Teleport>
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
</SettingsSection>
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
<SettingsSection
title="Synchronisation des utilisateurs"
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures."
>
<div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span>
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
<span v-else-if="syncResult" class="text-meta text-secondary">
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
</span>
</div>
</SettingsSection>
<!-- ── API publique ───────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">API publique</h3>
<p class="text-meta text-muted">
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
Adresse : <span class="text-secondary">{{ apiBase }}</span> · Authentification : jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
</p>
</div>
<div
@@ -57,6 +54,7 @@ import { ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import { plural } from '@/utils/format'
const hasToken = ref(false)
const syncing = ref(false)
@@ -66,12 +64,12 @@ const syncError = ref<string | null>(null)
const apiBase = `${window.location.origin}/api/v1`
const apiEndpoints = [
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
{ method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/series/:id', desc: 'Obtenir la fiche complète (saisons et épisodes)' },
{ method: 'POST', path: '/requests', desc: 'Créer ou compléter une demande au nom de l\'utilisateur' },
{ method: 'GET', path: '/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
]
async function syncUsers() {
@@ -81,7 +79,7 @@ async function syncUsers() {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin.'
syncing.value = false
}
</script>
+8 -15
View File
@@ -8,7 +8,6 @@
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
</Teleport>
<!-- Filtres -->
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button
@@ -43,26 +42,23 @@
class="segmented-item"
:class="{ 'is-active': limit === n }"
:aria-pressed="limit === n"
>{{ n === 2000 ? 'Tout' : n }}</button>
>{{ n.toLocaleString('fr-FR') }}</button>
</div>
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
</div>
</div>
<!-- Chargement -->
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
</div>
<!-- Table -->
<section v-else class="bg-card rounded-card overflow-hidden">
<div class="overflow-x-auto">
<div class="min-w-[640px]">
@@ -90,9 +86,8 @@
</div>
</section>
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
<p class="text-meta text-muted">{{ plural(entries.length, 'entrée affichée', 'entrées affichées') }} · fichier de {{ formatSize(fileSize) }}</p>
<!-- Confirmation -->
<Teleport to="body">
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
@@ -115,7 +110,7 @@
import { ref, computed, onMounted } from 'vue'
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatSize } from '@/utils/format'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -130,13 +125,12 @@ const limit = ref(100)
const levels = [
{ label: 'Tout', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Informations', value: 'info' },
{ label: 'Avertissements', value: 'warn' },
{ label: 'Erreurs', value: 'error' },
{ label: 'Debug', value: 'debug' },
{ label: 'Débogage', value: 'debug' },
]
// Compteurs et sources
const counts = computed(() => {
const c: Record<string, number> = {}
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
@@ -172,7 +166,6 @@ async function doClear() {
await load()
}
// Fichier texte
function exportLogs() {
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
@@ -196,8 +189,8 @@ function fullDate(iso: string): string {
function levelInfo(level: string): { label: string; class: string } {
return ({
debug: { label: 'Debug', class: 'pill-muted' },
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
debug: { label: 'Débogage', class: 'pill-muted' },
info : { label: 'Information', class: 'border-transparent px-0 text-muted' },
warn : { label: 'Avertissement', class: 'pill-wait' },
error: { label: 'Erreur', class: 'pill-err' },
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
+18 -28
View File
@@ -2,20 +2,18 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ plural(dirtyCount, 'modification non enregistrée', 'modifications non enregistrées') }}</span>
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Dossiers -->
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
<div class="grid md:grid-cols-2 gap-4">
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
@@ -39,18 +37,16 @@
</div>
</SettingsSection>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1">
<p class="card-title text-accent">Chemins non configurés</p>
<p class="text-meta text-secondary">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
L'import automatique reste désactivé tant que ces deux dossiers ne sont pas choisis.
</p>
</div>
</div>
<!-- Import -->
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
@@ -66,40 +62,39 @@
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
</div>
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
<span class="text-meta text-muted">Recommandé : le hardlink laisse le fichier en partage dans le client sans occuper deux fois l'espace. Les deux dossiers doivent être sur le même disque.</span>
</div>
<div class="h-px bg-hover" />
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
description="Importe les téléchargements terminés (vérification toutes les 5 minutes)."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO et métadonnées"
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
label="Fichiers NFO et images"
description="À chaque import, ajoute les fichiers de description (.nfo) et les images. Utile pour Infuse ou les lecteurs sans agent Fankai."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
description="Le torrent quitte le client une fois ses fichiers déplacés et n'est plus partagé."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier a disparu"
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
label="Retirer les épisodes dont le fichier a disparu"
description="Lors de l'analyse, retire de la médiathèque les épisodes dont le fichier n'existe plus."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » plutôt que « Saison »"
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
label="Dossiers de saison en anglais"
description="« Season 01 » au lieu de « Saison 1 »."
/>
</SettingsSection>
<!-- Serveurs média -->
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
<SettingsSection title="Serveurs multimédias" description="Les applications qui liront la médiathèque.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -120,7 +115,6 @@
</div>
</SettingsSection>
<!-- Scan -->
<div class="flex items-center gap-3 flex-wrap">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<Loader v-if="scanning" :size="15" class="animate-spin" />
@@ -128,13 +122,12 @@
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
<span v-if="lastScan" class="text-meta text-muted">
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
Dernière analyse {{ formatRelative(lastScan.at) }} · {{ plural(lastScan.found, 'fichier trouvé', 'fichiers trouvés') }}<template v-if="lastScan.added"> · {{ plural(lastScan.added, 'ajouté') }}</template>
</span>
</div>
</template>
<!-- Choix de dossier -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@@ -142,14 +135,12 @@
@cancel="picker.open = false"
/>
<!-- Assistant Plex -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
@@ -178,7 +169,7 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
@@ -198,7 +189,7 @@ const savedSnapshot = ref('')
const pathFields = [
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Dossier racine lu par Jellyfin, Plex ou Kodi.' },
] as const
function onNfoToggle(val: boolean) {
@@ -231,7 +222,6 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
function isRootPath(p: string): boolean { return !p || p === '/' }
// Réglages modifiés
const dirtyCount = computed(() => {
if (!savedSnapshot.value) return 0
const saved = JSON.parse(savedSnapshot.value)
@@ -259,7 +249,7 @@ async function save() {
body: JSON.stringify(form.value),
})
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
else toast('Erreur lors de la sauvegarde', 'error')
else toast("Impossible d'enregistrer les réglages", 'error')
} finally {
saving.value = false
}
@@ -272,9 +262,9 @@ async function scan() {
if (res.ok) {
const data = await res.json()
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
} else {
toast("Erreur lors de l'analyse", 'error')
toast("Impossible d'analyser la médiathèque", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+9 -13
View File
@@ -1,8 +1,7 @@
<template>
<div class="flex flex-col gap-4">
<!-- ── Compte ─────────────────────────────────────────────── -->
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
<SettingsSection title="Compte" description="Le nom d'utilisateur sert à la connexion et apparaît sur vos demandes.">
<div class="flex items-center gap-[18px]">
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
{{ auth.username?.charAt(0).toUpperCase() }}
@@ -21,7 +20,6 @@
</div>
</SettingsSection>
<!-- ── Mot de passe ──────────────────────────────────────── -->
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
<div class="grid sm:grid-cols-3 gap-4">
<div class="flex flex-col gap-[7px]">
@@ -48,14 +46,12 @@
</SettingsSection>
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
<!-- ── Apparence ───────────────────────────────────────── -->
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
<SettingsSection title="Apparence" description="Couleur d'accent de l'interface. Ce choix ne s'applique qu'à ce navigateur.">
<ThemePicker />
</SettingsSection>
<!-- ── Jeton d'API ─────────────────────────────────────── -->
<div data-tour="profile-token">
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<SettingsSection title="Jeton d'API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<div class="flex items-center gap-2.5 flex-wrap">
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
{{ showToken ? myToken : maskedToken }}
@@ -74,8 +70,8 @@
</button>
</div>
<p class="text-xs text-muted">
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l'ancien.
Authentification par l'en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
</p>
</SettingsSection>
</div>
@@ -101,7 +97,7 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
// ── Mot de passe ──────────────────────────────────────────────
// ── Mot de passe ──
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
@@ -120,7 +116,7 @@ async function changePassword() {
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
}
if (newPassword.value.length < 6) {
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
pwdError.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return
}
changingPwd.value = true
@@ -138,12 +134,12 @@ async function changePassword() {
confirmPassword.value = ''
setTimeout(() => { pwdSuccess.value = false }, 3000)
} else {
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
pwdError.value = data.error ?? 'Impossible de changer le mot de passe.'
}
changingPwd.value = false
}
// ── Jeton d'API 2───────────────────────────────────────────────
// ── Jeton d'API ──
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
+12 -11
View File
@@ -1,11 +1,12 @@
<template>
<div class="flex flex-col md:flex-row min-h-full">
<!-- h-auto! annule le h-full du layout, sinon le menu sticky défile avec la page -->
<div class="flex flex-col md:flex-row min-h-full h-auto!">
<nav
v-if="groups.length > 1"
ref="navRef"
data-tour="settings-menu"
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-dvh md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
>
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
@@ -56,15 +57,15 @@ watch(() => route.path, async () => {
}, { immediate: true })
const pages: Record<string, { title: string; description: string }> = {
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
'/settings/profile' : { title: 'Mon profil', description: "Votre compte, votre mot de passe et l'apparence de l'application." },
'/settings/users' : { title: 'Utilisateurs et invitations', description: "Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l'activité ni les paramètres." },
'/settings/media-management' : { title: 'Gestion des médias', description: "Dossiers, mode d'import et serveurs multimédias." },
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
}
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
+30 -35
View File
@@ -7,7 +7,6 @@
</button>
</Teleport>
<!-- ── Comptes ────────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
@@ -18,7 +17,7 @@
</div>
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
<span>Utilisateur</span><span>Rôle</span><span>Dernière connexion</span><span>Jeton d'API</span><span />
</div>
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
@@ -41,7 +40,7 @@
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
</span>
<span class="text-[13px] text-secondary order-4 md:order-none">
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
<span class="md:hidden text-muted">Dernière connexion : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
</span>
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
@@ -72,10 +71,9 @@
</div>
</section>
<!-- ── Invitations ────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ plural(activeInvites, 'lien actif', 'liens actifs') }}</span></h3>
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
</div>
@@ -116,10 +114,9 @@
</div>
</section>
<!-- ── Téléchargement auto ────────────── -->
<SettingsSection
title="Téléchargement automatique"
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
title="Approbation automatique"
description="Les demandes de ces utilisateurs sont approuvées et téléchargées sans attendre votre validation."
>
<template #actions>
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -129,7 +126,7 @@
:model-value="autoDownloadAll"
@update:model-value="setAutoDownloadAll"
label="Tous les utilisateurs"
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
description="Toutes les demandes sont approuvées et téléchargées automatiquement."
/>
<template v-if="!autoDownloadAll">
@@ -143,7 +140,7 @@
<button
type="button" role="switch"
:aria-checked="autoDownloadUserIds.includes(u.id)"
:aria-label="`Téléchargement automatique pour ${u.username}`"
:aria-label="`Approbation automatique pour ${u.username}`"
@click="toggleAutoDownloadUser(u.id)"
class="switch"
><span class="switch-knob" /></button>
@@ -152,7 +149,6 @@
</template>
</SettingsSection>
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
<Teleport to="body">
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
@@ -162,13 +158,13 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="user-name" class="field-label">Identifiant</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
<label for="user-name" class="field-label">Nom d'utilisateur</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" autocomplete="off" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" />
</div>
<div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span>
@@ -184,14 +180,13 @@
<div class="flex gap-2.5 justify-end">
<button @click="userModal = null" class="btn-ghost">Annuler</button>
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
{{ userModal.loading ? (userModal.mode === 'create' ? 'Création…' : 'Enregistrement…') : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Modal créer invitation ─────────────────────────────── -->
<Teleport to="body">
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
@@ -199,12 +194,12 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="invite-note" class="field-label">Note (facultative)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
<label for="invite-note" class="field-label">Message pour la personne invitée (facultatif)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-[7px]">
<label for="invite-uses" class="field-label">Utilisations max</label>
<label for="invite-uses" class="field-label">Nombre d'utilisations</label>
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
</div>
<div class="flex flex-col gap-[7px]">
@@ -212,7 +207,7 @@
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
</div>
</div>
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
<p class="text-xs text-muted">Laissez un champ vide pour ne pas fixer de limite.</p>
</div>
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
@@ -220,7 +215,7 @@
<div class="flex gap-2.5 justify-end">
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
{{ inviteModal.loading ? 'Création…' : 'Créer le lien' }}
</button>
</div>
</div>
@@ -267,13 +262,13 @@ const copied = ref<string | null>(null)
const pendingByUser = ref<Map<string, number>>(new Map())
const userMenu = ref<string | null>(null)
// ── Téléchargement auto des demandes ──────────────────────────
// ── Approbation automatique ──
const autoDownloadAll = ref(false)
const autoDownloadUserIds = ref<string[]>([])
const autoDownloadSaved = ref(false)
let autoDownloadTimer: ReturnType<typeof setTimeout> | null = null
// ── Modals ────────────────────────────────────────────────────
// ── Modales ──
const userModal = ref<{
mode : 'create' | 'edit'
id? : string
@@ -292,7 +287,7 @@ const inviteModal = ref<{
loading : boolean
} | null>(null)
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function loadUsers() {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) users.value = await res.json()
@@ -361,7 +356,7 @@ onMounted(() => {
})
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Users ─────────────────────────────────────────────────────
// ── Utilisateurs ──
function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
}
@@ -376,8 +371,8 @@ async function submitUserModal() {
m.error = null
m.loading = true
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
if (!m.username) { m.error = "Nom d'utilisateur requis."; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis.'; m.loading = false; return }
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
const method = m.mode === 'create' ? 'POST' : 'PATCH'
@@ -385,15 +380,15 @@ async function submitUserModal() {
if (m.password) body.password = m.password
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible d\'enregistrer le compte.'; m.loading = false; return }
userModal.value = null
loadUsers()
}
async function confirmDelete(u: User) {
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
loadUsers()
}
@@ -408,7 +403,7 @@ function userSubtitle(u: User) {
}
function maskToken(token: string) {
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : 'Aucun jeton'
}
async function copyToken(u: User) {
@@ -419,7 +414,7 @@ async function copyToken(u: User) {
} catch {}
}
// ── Invites ───────────────────────────────────────────────────
// ── Invitations ──
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
function openCreateInvite() {
@@ -436,9 +431,9 @@ async function submitInviteModal() {
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible de créer l\'invitation.'; m.loading = false; return }
inviteModal.value = null
loadInvites()
}
@@ -461,7 +456,7 @@ async function copyInvite(code: string) {
} catch {}
}
// ── Helpers ───────────────────────────────────────────────────
// ── Utilitaires ──
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}