feat(ui): refonte de l'interface

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 3ede3e808c
commit 09eb1fa3aa
79 files changed
+5323 -3514

No files matched your search

+21 -20
View File
@@ -1,42 +1,43 @@
<template>
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<div class="relative min-h-screen bg-main flex items-center justify-center px-4 py-10 overflow-hidden">
<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-2.5 mb-8">
<FankarrLogo class="w-8 h-8" />
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
<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="settings-card p-8 flex flex-col gap-5">
<div>
<p class="text-xs text-accent font-medium mb-1">
<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>
<h1 class="text-lg font-semibold text-primary">
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
</h1>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
<div class="flex flex-col gap-[7px]">
<label for="auth-username" class="field-label">Identifiant</label>
<input id="auth-username" v-model="username" type="text" placeholder="admin" class="field" @keyup.enter="submit" />
</div>
<div>
<label class="settings-label mb-1.5">Mot de passe</label>
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
<div class="flex flex-col gap-[7px]">
<label for="auth-password" class="field-label">Mot de passe</label>
<input id="auth-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<div v-if="isSetup">
<label class="settings-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
<label class="field-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : isSetup ? 'Créer le compte' : 'Se connecter' }}
</button>
</div>
</div>
+582 -232
View File
@@ -1,192 +1,482 @@
<template>
<div class="px-8 py-8 max-w-4xl">
<div class="px-4 md:px-10 pt-2 md:pt-[26px] pb-12">
<div class="max-w-[1400px] flex flex-col gap-[22px]">
<!-- Stats -->
<div data-tour="dashboard-stats" class="grid gap-3 mb-6" :class="auth.isAdmin ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-2'">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Catalogue</span>
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
<span class="text-xs text-muted">séries disponibles</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Importées</span>
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
</div>
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En cours</span>
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
<span class="text-xs text-muted">téléchargements</span>
</div>
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Erreurs</span>
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
</div>
</div>
<div v-if="auth.isAdmin" class="grid md:grid-cols-2 gap-4 mb-6">
<!-- Téléchargements actifs -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
<header class="hidden md:flex items-center justify-between gap-6 min-h-11">
<div class="flex items-baseline gap-3.5 min-w-0">
<h1 class="page-title tracking-[0.01em]">Accueil</h1>
<span class="text-[13px] text-muted truncate">{{ today }}</span>
</div>
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
<div v-else class="flex flex-col gap-3">
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary truncate">{{ t.name }}</span>
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
</div>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
</div>
</div>
</div>
</div>
<label class="hidden md:flex items-center gap-2.5 w-[360px] h-10 rounded-full bg-card border border-border-light pl-4 pr-2 cursor-text focus-within:border-accent transition-colors">
<Search :size="16" :stroke-width="1.75" class="text-muted shrink-0" />
<input
v-model="searchText"
type="search"
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"
@input="openSearchSoon"
@keydown.enter="librarySearch.open(searchText)"
/>
<kbd class="h-6 px-[9px] rounded-full bg-hover text-secondary text-[11.5px] font-sans flex items-center">Ctrl K</kbd>
</label>
</header>
<!-- Derniers imports -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
<div class="grid 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">
<!-- 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" />
<div class="dot-grid" />
<div class="relative sm:hidden flex flex-col gap-5 p-4">
<div class="flex gap-4">
<RouterLink :to="`/series/${hero.serie.id}`" class="relative shrink-0">
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" :alt="hero.serie.title" class="w-[88px] h-[132px] object-cover rounded-poster shadow-[0_16px_32px_rgb(0_0_0/0.5),0_0_0_1px_rgb(239_233_221/0.08)]" />
<span v-else class="w-[88px] h-[132px] rounded-poster bg-hover flex items-center justify-center text-muted"><Tv :size="22" /></span>
<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>
<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' }}.
</p>
</div>
</div>
<RouterLink :to="`/series/${hero.serie.id}`" class="btn-primary w-full">
Voir la série <ArrowRight :size="16" />
</RouterLink>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ n.name }}</p>
<p class="text-[11px] text-muted mt-0.5">
{{ n.done }} fichier{{ n.done > 1 ? 's' : '' }} importé{{ n.done > 1 ? 's' : '' }}
<span v-if="n.errors > 0" class="text-red-400"> · {{ n.errors }} erreur{{ n.errors > 1 ? 's' : '' }}</span>
<div class="relative min-h-[344px] hidden sm:flex items-center gap-9 pl-10 pr-11 py-8">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<div class="flex items-center gap-3">
<span class="text-[11.5px] tracking-[0.18em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Dernier import' : 'Nouveau dans la médiathèque' }}</span>
<span class="w-[26px] h-px bg-accent/50" />
<span class="text-[13px] text-secondary">{{ formatRelative(hero.at) }}</span>
</div>
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col gap-0.5 w-fit">
<span class="font-display text-[40px] sm:text-[56px] leading-[1.02] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
<span v-if="heroTitle.suffix" class="font-display text-2xl sm:text-[30px] leading-[1.15] font-semibold text-accent">{{ heroTitle.suffix }}</span>
</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>
{{ auth.isAdmin ? 'à la médiathèque.' : 'à regarder.' }}
</p>
<div class="flex items-center gap-5 mt-1.5 flex-wrap">
<RouterLink :to="`/series/${hero.serie.id}`" class="btn-primary h-[42px] text-sm pl-[22px]">
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' }}
</RouterLink>
</div>
</div>
<RouterLink :to="`/series/${hero.serie.id}`" class="relative shrink-0 rotate-2">
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" :alt="hero.serie.title" class="w-[180px] h-[270px] object-cover rounded-poster shadow-[0_28px_60px_rgb(0_0_0/0.55),0_0_0_1px_rgb(239_233_221/0.08)]" />
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-7 top-4 text-[15px] px-[11px] pt-1 pb-[3px] tracking-[0.14em] -rotate-[9deg]">NOUVEAU</span>
</RouterLink>
</div>
</section>
<section v-else-if="!loadingRecent" class="relative rounded-card overflow-hidden bg-card max-lg:order-1">
<div class="dot-grid" />
<div class="relative flex flex-col gap-3 px-6 sm:px-10 py-10">
<span class="text-[11.5px] tracking-[0.18em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Dernier import' : 'Nouveautés' }}</span>
<p class="font-display text-[28px] font-bold text-primary">Rien de nouveau pour l'instant</p>
<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.' }}
</p>
<RouterLink to="/series" class="btn-secondary w-fit mt-1">Parcourir la médiathèque</RouterLink>
</div>
<span class="text-[11px] text-muted shrink-0">{{ formatRelative(n.at) }}</span>
</div>
</div>
</div>
</section>
</div>
<!-- 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>
<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>
</div>
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3.5">
<RouterLink v-for="item in recentList" :key="item.serie.id" :to="`/series/${item.serie.id}`" class="group flex flex-col gap-2 min-w-0">
<img v-if="item.serie.poster_image" :src="item.serie.poster_image" :alt="item.serie.title" loading="lazy" class="w-full aspect-[2/3] object-cover rounded-[5px] shadow-[0_0_0_1px_rgb(239_233_221/0.06)] group-hover:-translate-y-0.5 transition-transform" />
<div v-else class="w-full aspect-[2/3] rounded-[5px] bg-card flex items-center justify-center text-muted"><Tv :size="22" /></div>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[13px] font-medium text-primary truncate">{{ item.serie.title }}</span>
<span class="text-xs text-muted">{{ item.episodes }} ép. · {{ formatRelative(item.at) }}</span>
</div>
</RouterLink>
</div>
</section>
<!-- Widget demandes -->
<div data-tour="dashboard-requests" class="settings-card flex flex-col gap-3 mb-6">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-secondary">
{{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }}
</h2>
<div class="flex items-center gap-2">
<span v-if="auth.isAdmin && pendingRequests.length > 0" class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ pendingRequests.length }} en attente
</span>
<RouterLink to="/requests" class="text-xs text-muted hover:text-accent transition">Voir tout →</RouterLink>
<!-- 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>
</div>
<div class="hidden sm:block w-px bg-card mx-6" />
<div class="flex-1 flex items-baseline gap-2.5">
<span class="font-display text-[34px] font-bold text-primary">{{ stats.imported }}</span>
<span class="text-[13px] text-muted">dans la médiathèque · {{ importedPercent }}&nbsp;%</span>
</div>
<div class="hidden sm:block w-px bg-card mx-6" />
<div class="flex-1 flex items-baseline gap-2.5">
<span class="font-display text-[34px] font-bold text-primary">{{ stats.watched }}</span>
<span class="text-[13px] text-muted">{{ stats.watched > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
</div>
</div>
<div v-else class="flex items-baseline gap-2.5 flex-wrap">
<span class="font-display text-[34px] font-bold text-primary">{{ stats.imported }}</span>
<span class="text-[13px] text-muted">
{{ stats.imported > 1 ? 'séries disponibles' : 'série disponible' }} dans la médiathèque<template v-if="toRequestCount > 0"> · {{ toRequestCount }} {{ toRequestCount > 1 ? 'autres' : 'autre' }} au catalogue, à demander</template>
</span>
</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>
</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">
<span class="tag-label">{{ d.label }}</span>
<span class="font-display text-2xl font-bold" :class="d.accent ? 'text-accent' : 'text-primary'">{{ d.value }}</span>
<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>
</section>
</div>
</div>
<div v-if="loadingRequests" class="flex items-center gap-2 text-muted text-xs py-2">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<div v-else-if="pendingRequests.length === 0" class="text-xs text-muted py-2">
{{ auth.isAdmin ? 'Aucune demande en attente' : 'Aucune demande' }}
</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="req in pendingRequests" :key="req.id" class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
<div class="flex-1 min-w-0">
<RouterLink :to="`/series/${req.serieId}`" class="text-xs text-primary truncate hover:text-accent transition block">{{ req.serieName }}</RouterLink>
<p v-if="auth.isAdmin" class="text-[11px] text-muted mt-0.5">
{{ req.requesters.length }} demandeur{{ req.requesters.length > 1 ? 's' : '' }}
· {{ req.requesters.map((r: any) => r.username).join(', ') }}
</p>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded border shrink-0"
:class="{
'bg-yellow-500/10 text-yellow-400 border-yellow-500/20': req.status === 'pending',
'bg-blue-500/10 text-blue-400 border-blue-500/20' : req.status === 'approved',
'bg-green-500/10 text-green-400 border-green-500/20' : req.status === 'completed',
'bg-red-500/10 text-red-400 border-red-500/20' : req.status === 'rejected',
}">
{{ { pending: 'Attente', approved: 'Approuvée', completed: 'Dispo', rejected: 'Refusée' }[req.status as string] }}
</span>
<!-- ── 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">
<div class="flex items-center gap-2.5">
<h2 class="section-title">À traiter</h2>
<span v-if="todoCount > 0" class="h-[22px] min-w-[22px] px-[7px] rounded-full bg-accent text-on-accent text-xs font-bold flex items-center justify-center">{{ todoCount }}</span>
</div>
<div class="bg-card rounded-card flex flex-col">
<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>
<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]" />
<span v-else class="w-10 h-[60px] rounded-[4px] bg-hover flex items-center justify-center text-muted"><Tv :size="16" /></span>
</RouterLink>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="tag-label">Demande</span>
<RouterLink :to="`/series/${req.serieId}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ titleOf(req.serieId, req.serieName) }}</RouterLink>
<span class="text-meta text-secondary">{{ scopeLabel(req) }} · {{ requesterNames(req) }} · {{ formatRelative(req.createdAt) }}</span>
<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' }}
</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>
</div>
</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]">
<span class="tag-label text-err">Import en erreur</span>
<span class="text-[14.5px] font-bold text-primary truncate" :title="err.name">{{ err.title }}</span>
<span class="text-meta text-secondary line-clamp-2" :title="err.detail">{{ err.detail }}</span>
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
<button v-if="err.torrent" @click="retryImport(err)" :disabled="busy[err.hash]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
{{ 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>
</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>
<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' }}
</button>
</div>
</div>
</div>
<RouterLink v-for="more in overflowLinks" :key="more.to + more.label" :to="more.to" class="flex items-center justify-between px-[18px] py-3.5 text-[13px] text-secondary hover:text-primary transition-colors border-b border-hover last:border-b-0">
<span>{{ more.label }}</span>
<ChevronRight :size="14" />
</RouterLink>
</template>
</div>
</section>
<section class="flex flex-col gap-4 max-lg:order-3">
<div class="flex items-baseline justify-between gap-3">
<RouterLink to="/activity" class="section-title hover:text-accent transition-colors">Téléchargements</RouterLink>
<span class="text-meta text-muted">{{ plural(stats.downloading, 'actif') }}<template v-if="totalSpeed > 0"> · {{ formatSpeed(totalSpeed) }}</template></span>
</div>
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-meta">
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
</div>
<p v-else-if="activeTorrents.length === 0" class="text-meta text-muted">Aucun téléchargement en cours.</p>
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-[7px]">
<div class="flex justify-between gap-3">
<span class="text-body font-medium text-primary truncate" :title="t.name">{{ t.name }}</span>
<span class="text-[13px] tabular-nums shrink-0" :class="t.state === 'error' ? 'text-err' : 'text-primary'">{{ Math.round(t.progress) }}&nbsp;%</span>
</div>
<div class="progress">
<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>
</span>
</div>
</section>
</div>
<!-- ── 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">
<div class="flex items-center gap-2.5">
<h2 class="section-title">Mes demandes</h2>
<span v-if="myPendingCount > 0" class="h-[22px] min-w-[22px] px-[7px] rounded-full bg-accent-muted text-accent text-xs font-bold flex items-center justify-center">{{ myPendingCount }}</span>
</div>
<div class="bg-card rounded-card flex flex-col">
<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="requests.length === 0" class="text-meta text-muted px-[18px] py-5">Aucune demande pour l'instant. Ouvrez une fiche série pour demander ce qui vous manque.</p>
<RouterLink v-for="req in requests.slice(0, 3)" :key="req.id" :to="`/series/${req.serieId}`" class="flex gap-3.5 px-[18px] py-3.5 border-b border-hover hover:bg-hover/40 transition-colors">
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px] shrink-0" />
<span v-else class="w-10 h-[60px] rounded-[4px] bg-hover flex items-center justify-center text-muted shrink-0"><Tv :size="16" /></span>
<div class="flex-1 min-w-0 flex flex-col gap-1">
<span class="text-[14.5px] font-bold text-primary truncate">{{ titleOf(req.serieId, req.serieName) }}</span>
<span class="text-meta text-secondary">{{ scopeLabel(req) }} · {{ req.status === 'completed' ? 'ajoutée' : 'demandée' }} {{ formatRelative(req.status === 'completed' ? req.updatedAt : req.createdAt) }}</span>
<span v-if="req.status === 'completed'" class="flex items-center gap-[7px] text-xs font-bold text-ok"><Check :size="13" :stroke-width="2.5" /> Disponible</span>
<span v-else class="pill h-5 px-[9px] text-[11px] w-fit" :class="guestStatus[req.status]?.class">{{ guestStatus[req.status]?.label }}</span>
</div>
</RouterLink>
<RouterLink to="/requests" class="flex items-center justify-between px-[18px] py-3.5 text-[13px] text-secondary hover:text-primary transition-colors">
<span>Toutes mes demandes</span>
<ChevronRight :size="14" />
</RouterLink>
</div>
</section>
<section v-if="notYetHere.length > 0" class="flex flex-col gap-3.5 max-lg:order-3">
<div class="flex items-baseline justify-between">
<h2 class="section-title">Pas encore là</h2>
<span class="text-meta text-muted">au catalogue Fankai</span>
</div>
<div class="grid grid-cols-3 gap-3">
<div v-for="s in notYetHere" :key="s.id" class="flex flex-col gap-2 min-w-0">
<RouterLink :to="`/series/${s.id}`">
<img v-if="s.poster_image" :src="s.poster_image" :alt="s.title" loading="lazy" class="w-full aspect-[2/3] object-cover rounded-[5px] shadow-[0_0_0_1px_rgb(239_233_221/0.06)]" />
<span v-else class="w-full aspect-[2/3] rounded-[5px] bg-card flex items-center justify-center text-muted"><Tv :size="20" /></span>
</RouterLink>
<span class="text-meta font-medium text-primary truncate">{{ s.title }}</span>
<button @click="requestSerie(s)" :disabled="busy[`req-${s.id}`]" class="btn-secondary btn-sm px-2 w-full">
{{ busy[`req-${s.id}`] ? 'Envoi…' : 'Demander' }}
</button>
</div>
</div>
</section>
</div>
</div>
</div>
<!-- Panneau debug -->
<div v-if="devMode" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-medium text-secondary">Debug</h2>
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Heap utilisé</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">RSS (process)</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
<span class="text-xs text-muted">mémoire système</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Uptime</span>
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
<span class="text-xs text-muted">depuis le démarrage</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Cache GitHub</span>
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Fichiers trackés</span>
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
<span class="text-xs text-muted">dans organized.json</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Worker organize</span>
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
</span>
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
</div>
</div>
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
</div>
<RejectRequestModal
v-if="rejectTarget"
:serie-name="titleOf(rejectTarget.serieId, rejectTarget.serieName)"
@close="rejectTarget = null"
@confirm="confirmReject"
/>
<ManualImportModal
v-if="manualImport"
:serie-id="manualImport.serieId"
:serie-name="manualImport.serieName"
:seasons="manualImport.seasons"
:organized="manualImport.organized"
:initial-path="manualImport.mediaPath"
@close="manualImport = null"
@imported="fetchTorrents(); fetchRecent()"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { RouterLink } from 'vue-router'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
import { ArrowRight, Check, ChevronRight, Folder, RefreshCw, Search, TriangleAlert, Tv, X } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { useSeriesStore, type Serie } from '@/stores/series'
import { useRequestsStore } from '@/stores/requests'
import { useToast } from '@/composables/useToast'
import { useLibrarySearch } from '@/composables/useLibrarySearch'
import { formatDuration, formatRelative, formatSize, formatSpeed, formatToday, plural } from '@/utils/format'
import { isNew, splitTitle, statusLabel } from '@/utils/series'
import { requesterNames, scopeLabel, type SerieRequest } from '@/utils/requests'
import RejectRequestModal from '@/components/requests/RejectRequestModal.vue'
import ManualImportModal from '@/components/ManualImportModal.vue'
const auth = useAuthStore()
const seriesStore = useSeriesStore()
const reqStore = useRequestsStore()
const librarySearch = useLibrarySearch()
const { add: toast } = useToast()
const today = formatToday()
const searchText = ref('')
const openSearchSoon = useDebounceFn(() => { if (searchText.value.trim()) librarySearch.open(searchText.value) }, 450)
const auth = useAuthStore()
const loadingTorrents = ref(true)
const loadingNotifs = ref(true)
const loadingRecent = ref(true)
const loadingTodo = ref(true)
const devMode = ref(false)
const debug = ref<any>(null)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const activeTorrents = ref<any[]>([])
const recentNotifs = ref<any[]>([])
const pendingRequests = ref<any[]>([])
const loadingRequests = ref(true)
const catalogueCount = ref(0)
const torrents = ref<any[]>([])
const recent = ref<{ serieId: number; episodes: number; at: string }[]>([])
const requests = ref<SerieRequest[]>([])
const errorNotifs = ref<any[]>([])
const staleFolders = ref<{ serie_id: number; serie_title: string; expected: string; current: string[] }[]>([])
const busy = reactive<Record<string, boolean>>({})
const rejectTarget = ref<SerieRequest | null>(null)
const manualImport = ref<{ serieId: number; serieName: string; seasons: any[]; organized: Record<string, any>; mediaPath: string } | null>(null)
let interval: ReturnType<typeof setInterval> | null = null
// ── 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
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const recentWithSerie = computed(() => recent.value
.map(r => ({ ...r, serie: seriesById.value.get(r.serieId) }))
.filter((r): r is typeof r & { serie: Serie } => !!r.serie))
const hero = computed(() => recentWithSerie.value[0] ?? null)
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
const stats = computed(() => ({
catalogue : catalogueCount.value || seriesStore.series.length,
imported : seriesStore.series.filter(s => s.download_state === 'complete').length,
watched : seriesStore.series.filter(s => s.rss_synced).length,
downloading: torrents.value.filter(t => t.state === 'downloading').length,
}))
const importedPercent = computed(() => stats.value.catalogue > 0 ? Math.round(stats.value.imported / stats.value.catalogue * 100) : 0)
const toRequestCount = computed(() => Math.max(0, stats.value.catalogue - stats.value.imported))
const activeTorrents = computed(() => torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5))
const totalSpeed = computed(() => torrents.value.filter(t => t.state === 'downloading').reduce((sum, t) => sum + (t.speed ?? 0), 0))
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
const pendingRequests = computed(() => requests.value.filter(r => r.status === 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
function errorDetail(e: { file: string; error: string }) {
const ep = basename(e.file).match(/S\d+E(\d+)/i)
return `${ep ? `E${ep[1]}` : basename(e.file)} · ${e.error}`
}
// Dernier passage d'import de chaque torrent, s'il a laissé des erreurs
const importErrors = computed(() => {
const seen = new Set<string>()
const list: { hash: string; name: string; title: string; detail: string; serieId: number | null; torrent: any }[] = []
for (const n of errorNotifs.value) {
if (seen.has(n.hash)) continue
seen.add(n.hash)
if (!n.errors) continue
const torrent = torrents.value.find(t => t.hash === n.hash) ?? null
const serieId = n.serieId ?? torrent?.serieId ?? null
const first = n.errorFiles?.[0]
const detail = first ? errorDetail(first) : plural(n.errors, 'erreur')
list.push({
hash: n.hash, name: n.name, serieId, torrent,
title : serieId ? titleOf(serieId, n.name) : (torrent?.serieName ?? n.name),
detail: n.errors > 1 ? `${detail} (+${n.errors - 1})` : detail,
})
}
return list
})
const visibleRequests = computed(() => pendingRequests.value.slice(0, 2))
const visibleErrors = computed(() => importErrors.value.slice(0, 3))
const visibleFolders = computed(() => staleFolders.value.slice(0, 3))
const todoCount = computed(() => pendingRequests.value.length + importErrors.value.length + staleFolders.value.length)
const overflowLinks = computed(() => {
const links: { to: string; label: string }[] = []
const moreRequests = pendingRequests.value.length - visibleRequests.value.length
const moreErrors = importErrors.value.length - visibleErrors.value.length
const moreFolders = staleFolders.value.length - visibleFolders.value.length
if (moreRequests > 0) links.push({ to: '/requests', label: `${moreRequests} ${moreRequests > 1 ? 'autres demandes' : 'autre demande'} en attente` })
if (moreErrors > 0) links.push({ to: '/activity', label: `${moreErrors} ${moreErrors > 1 ? 'autres imports' : 'autre import'} en erreur` })
if (moreFolders > 0) links.push({ to: '/settings/import-management', label: `${moreFolders} ${moreFolders > 1 ? 'autres dossiers' : 'autre dossier'} à renommer` })
return links
})
// ── Invité ────────────────────────────────────────────────────
const myPendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length)
const guestStatus: Record<string, { label: string; class: string }> = {
pending : { label: 'En attente', class: 'pill-wait' },
approved: { label: 'Bientôt là', class: 'pill-active' },
rejected: { label: 'Refusée', class: 'pill-err' },
}
const notYetHere = computed(() => {
const requested = new Set(requests.value.filter(r => r.status !== 'rejected').map(r => r.serieId))
return seriesStore.series
.filter(s => s.download_state === 'none' && !requested.has(s.id) && !isNew(s.last_imported_at))
.sort((a, b) => Number(b.has_torrents) - Number(a.has_torrents) || (b.year ?? 0) - (a.year ?? 0))
.slice(0, 3)
})
// ── Chargement ────────────────────────────────────────────────
async function fetchSettings() {
if (!auth.isAdmin) return
const res = await fetch('/api/settings', { credentials: 'include' })
@@ -202,96 +492,156 @@ async function fetchDebug() {
if (res.ok) debug.value = await res.json()
}
async function fetchStats() {
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 },
] : [])
async function fetchCatalogue() {
try {
const [statusRes, seriesRes] = await Promise.all([
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/series', { credentials: 'include' }),
])
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
if (seriesRes.ok) {
const s = await seriesRes.json()
const list = Array.isArray(s) ? s : (s.series ?? [])
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
}
// Stats téléchargements : admin uniquement
if (auth.isAdmin) {
const downloadsRes = await fetch('/api/downloads', { credentials: 'include' })
if (downloadsRes.ok) {
const torrents = await downloadsRes.json()
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
}
}
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) catalogueCount.value = (await res.json()).count ?? 0
} catch {}
}
async function fetchRecent() {
try {
const res = await fetch('/api/library/recent?limit=12', { credentials: 'include' })
if (res.ok) recent.value = await res.json()
} catch {} finally { loadingRecent.value = false }
}
async function fetchTorrents() {
if (!auth.isAdmin) { loadingTorrents.value = false; return }
const isFirst = loadingTorrents.value
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (res.ok) {
const list = await res.json()
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
}
} catch {} finally {
if (isFirst) loadingTorrents.value = false
}
}
async function fetchNotifs() {
if (!auth.isAdmin) { loadingNotifs.value = false; return }
loadingNotifs.value = true
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
} catch {} finally {
loadingNotifs.value = false
}
const [dl, notifs] = await Promise.all([
fetch('/api/downloads', { credentials: 'include' }),
fetch('/api/organize/recent', { credentials: 'include' }),
])
if (dl.ok) torrents.value = await dl.json()
if (notifs.ok) errorNotifs.value = await notifs.json()
} catch {} finally { loadingTorrents.value = false }
}
async function fetchRequests() {
loadingRequests.value = true
try {
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) {
const all = await res.json()
pendingRequests.value = auth.isAdmin
? all.filter((r: any) => r.status === 'pending').slice(0, 5)
: all.slice(0, 5)
const all: SerieRequest[] = await res.json()
requests.value = all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
} catch {} finally {
loadingRequests.value = false
}
} catch {}
}
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]}`
async function fetchFolders() {
if (!auth.isAdmin) return
try {
const res = await fetch('/api/organized-folders', { credentials: 'include' })
if (res.ok) staleFolders.value = await res.json()
} catch {}
}
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
return `${s}s`
// ── Actions
async function patchRequest(req: SerieRequest, body: object) {
busy[req.id] = true
try {
const res = await fetch(`/api/requests/${req.id}`, {
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 }
await fetchRequests()
reqStore.refreshPending()
return true
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
finally { busy[req.id] = false }
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
async function approve(req: SerieRequest) {
if (await patchRequest(req, { action: 'approve' }))
toast(req.hasTorrents === false ? 'Demande approuvée, aucun torrent à télécharger' : 'Demande approuvée, téléchargement lancé', 'success')
}
onMounted(() => {
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings(), fetchRequests()])
async function confirmReject(message: string) {
const req = rejectTarget.value
rejectTarget.value = null
if (req && await patchRequest(req, { action: 'reject', rejectionMessage: message || undefined })) toast('Demande refusée', 'success')
}
async function retryImport(err: { hash: string; torrent: any }) {
busy[err.hash] = true
try {
const res = await fetch('/api/organize', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
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')
await Promise.all([fetchTorrents(), fetchRecent()])
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[err.hash] = false }
}
async function openManualImport(err: { serieId: number | null; title: string }) {
if (!err.serieId) return
try {
const [detail, organized, settings] = await Promise.all([
fetch(`/api/series/${err.serieId}`, { credentials: 'include' }),
fetch(`/api/organized/${err.serieId}`, { credentials: 'include' }),
fetch('/api/settings', { credentials: 'include' }),
])
if (!detail.ok) { toast('Série introuvable', 'error'); return }
const d = await detail.json()
manualImport.value = {
serieId : err.serieId,
serieName: d.serie?.title ?? err.title,
seasons : d.seasons ?? [],
organized: organized.ok ? await organized.json() : {},
mediaPath: settings.ok ? ((await settings.json()).mediaPath || '/') : '/',
}
} catch { toast('Impossible de contacter le serveur', 'error') }
}
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
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')
await fetchFolders()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[key] = false }
}
async function requestSerie(s: Serie) {
const key = `req-${s.id}`
busy[key] = true
try {
const res = await fetch('/api/requests', {
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')
await fetchRequests()
reqStore.refreshPending()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[key] = false }
}
onMounted(async () => {
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
await Promise.all([fetchCatalogue(), fetchRecent(), fetchTorrents(), fetchSettings(), fetchRequests(), fetchFolders()])
loadingTodo.value = false
interval = setInterval(() => {
fetchTorrents()
if (devMode.value) fetchDebug()
@@ -299,4 +649,4 @@ onMounted(() => {
})
onUnmounted(() => { if (interval) clearInterval(interval) })
</script>
</script>
+128 -70
View File
@@ -1,37 +1,32 @@
<template>
<div>
<div class="px-8 py-8 max-w-4xl">
<div class="px-4 md:px-10 pt-[26px] pb-12">
<div class="max-w-[1400px] flex flex-col gap-4">
<!-- Header -->
<div data-tour="activity-header" class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
</div>
<!-- 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>
</span>
</div>
<div class="flex items-center gap-2">
<button @click="importAll" :disabled="importingAll" class="btn-primary">{{ importingAll ? '...' : 'Importer tout' }}</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
<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>
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Rafraîchir" aria-label="Rafraîchir">
<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'">
<Pause v-if="polling" :size="16" />
<Play v-else :size="16" />
</button>
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
</div>
</div>
</header>
<!-- Stats -->
<div class="grid grid-cols-4 gap-3 mb-6">
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">{{ stat.label }}</span>
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
</div>
</div>
<!-- Toolbar + Tabs -->
<DownloadsToolbar
v-model:search="search"
v-model:active-tab="activeTab"
@@ -40,8 +35,7 @@
:active-sort="activeSort"
:sort-dir="sortDir"
:columns="columns"
:active-count="activeTorrents.length"
:done-count="doneTorrents.length"
:tabs="tabs"
:column-options="columnOptions"
:sort-options="sortOptions"
@sort="setSort"
@@ -49,31 +43,34 @@
/>
<!-- Pas de clients -->
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
<p class="text-sm text-muted">Aucun client torrent configuré</p>
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
<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>
<!-- Loading -->
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<!-- 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...
Chargement…
</div>
<!-- Vide -->
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
<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>
<!-- Liste torrents -->
<div v-else class="flex flex-col gap-2">
<!-- Une ligne par torrent -->
<div v-else class="flex flex-col gap-2.5">
<TorrentCard
v-for="t in visibleTorrents"
:key="t.hash"
:torrent="t"
:tab="activeTab"
:columns="columns"
:poster="posterOf(t.serieId)"
:auto-import="schedule?.autoImport ?? true"
:auto-import-in="autoImportIn"
:importing="!!importing[t.hash]"
:deleting="!!deleting[t.hash]"
:show-confirm-delete="confirmDelete === t.hash"
@@ -90,11 +87,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
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 TorrentCard from '@/components/downloads/TorrentCard.vue'
import DownloadsToolbar from '@/components/downloads/DownloadsToolbar.vue'
import DownloadsToolbar, { type ActivityTab } from '@/components/downloads/DownloadsToolbar.vue'
const { add: toast } = useToast()
const seriesStore = useSeriesStore()
const torrents = ref<any[]>([])
const loading = ref(true)
@@ -104,20 +104,23 @@ const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const confirmDelete = ref<string | null>(null)
const deleting = ref<Record<string, boolean>>({})
const activeTab = ref<'active' | 'done'>('active')
const activeTab = ref<string>('all')
const hideImported = ref(false)
const seedingOnly = ref(false)
const search = ref('')
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const lastUpdate = ref<number | null>(null)
const now = ref(Date.now())
const schedule = ref<{ autoImport: boolean; nextRunAt: string | null } | null>(null)
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
const columnOptions = [
{ key: 'client', label: 'Client' },
{ key: 'size', label: 'Taille / Téléchargé' },
{ key: 'size', label: 'Taille / téléchargé' },
{ key: 'ratio', label: 'Ratio' },
{ key: 'uploaded', label: 'Uploadé (total)' },
{ key: 'upspeed', label: 'Vitesse upload' },
{ key: 'uploaded', label: 'Envoyé (total)' },
{ key: 'upspeed', label: 'Vitesse d’envoi' },
]
const sortOptions = [
{ label: 'Nom', value: 'name' },
@@ -128,22 +131,39 @@ const sortOptions = [
]
let pollInterval: ReturnType<typeof setInterval> | null = null
let clockInterval: ReturnType<typeof setInterval> | null = null
// ── 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'
const hasErrors = (t: any) => t.state === 'error' || (t.errorFiles?.length ?? 0) > 0
const tabPredicates: Record<string, (t: any) => boolean> = {
'all' : () => true,
'active' : isActive,
'done' : isDone,
'to-import': isToImport,
'errors' : hasErrors,
}
const tabs = computed<ActivityTab[]>(() => [
{ value: 'all', label: 'Tout', count: torrents.value.length },
{ value: 'active', label: 'En cours', count: torrents.value.filter(isActive).length, tone: 'accent' },
{ value: 'done', label: 'Terminés', count: torrents.value.filter(isDone).length },
{ value: 'to-import', label: 'À importer', count: torrents.value.filter(isToImport).length },
{ value: 'errors', label: 'Erreurs', count: torrents.value.filter(hasErrors).length, tone: 'err' },
])
const toImportCount = computed(() => torrents.value.filter(isToImport).length)
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
if (activeTab.value === 'done') {
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
}
let list = torrents.value.filter(tabPredicates[activeTab.value] ?? tabPredicates.all!)
if (seedingOnly.value) list = list.filter(t => !isDone(t) || t.state === 'seeding')
if (hideImported.value) list = list.filter(t => !isDone(t) || t.organizeState !== 'done')
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
list = list.filter(t => t.name.toLowerCase().includes(q) || (t.serieName ?? '').toLowerCase().includes(q))
}
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
@@ -157,35 +177,66 @@ const visibleTorrents = computed(() => {
default : return 0
}
})
} else {
const rank = (t: any) => isActive(t) ? 0 : isToImport(t) ? 1 : 2
list = [...list].sort((a, b) => rank(a) - rank(b))
}
return list
})
const stats = computed(() => [
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
])
const emptyState = computed(() => ({
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents de la catégorie « fankai » 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.' })
// ── 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
const sinceUpdate = computed(() => {
const s = Math.max(0, Math.round((now.value - (lastUpdate.value ?? now.value)) / 1000))
return s < 60 ? `il y a ${s} s` : `il y a ${Math.floor(s / 60)} min`
})
const autoImportIn = computed(() => {
if (!schedule.value?.autoImport || !schedule.value.nextRunAt) return ''
const min = Math.ceil((new Date(schedule.value.nextRunAt).getTime() - now.value) / 60_000)
return min <= 0 ? 'imminent' : `dans ${min} min`
})
function setSort(val: string) {
if (activeSort.value === val) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
}
// ── Chargement ────────────────────────────────────────────────
async function fetchTorrents() {
loading.value = true
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (res.status === 503) { noClients.value = true; return }
if (!res.ok) return
torrents.value = await res.json()
noClients.value = false
torrents.value = await res.json()
noClients.value = false
lastUpdate.value = Date.now()
} catch {} finally { loading.value = false }
fetchSchedule()
}
async function fetchSchedule() {
try {
const res = await fetch('/api/organize/schedule', { credentials: 'include' })
if (res.ok) schedule.value = await res.json()
} catch {}
}
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
// ── Actions ───────────────────────────────────────────────────
async function importTorrent(torrent: any) {
importing.value[torrent.hash] = true
try {
@@ -200,7 +251,7 @@ async function importTorrent(torrent: any) {
}
async function importAll() {
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
const toImport = torrents.value.filter(isToImport)
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
importingAll.value = true
let done = 0, errors = 0
@@ -228,6 +279,13 @@ async function deleteTorrent(torrent: any, withFiles: boolean) {
finally { deleting.value[torrent.hash] = false }
}
onMounted(startPolling)
onUnmounted(stopPolling)
onMounted(() => {
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
startPolling()
clockInterval = setInterval(() => { now.value = Date.now() }, 1000)
})
onUnmounted(() => {
stopPolling()
if (clockInterval) clearInterval(clockInterval)
})
</script>
+35 -33
View File
@@ -1,59 +1,59 @@
<template>
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<div class="relative min-h-screen bg-main flex items-center justify-center px-4 py-10 overflow-hidden">
<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-2.5 mb-8">
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
<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="settings-card p-8 text-center text-sm text-muted">
<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="settings-card p-8 flex flex-col gap-3 text-center">
<p class="text-sm font-semibold text-red-400">Lien invalide</p>
<p class="text-sm text-muted">{{ invalidReason }}</p>
<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="settings-card p-8 flex flex-col gap-4 text-center">
<p class="text-sm font-semibold text-green-400">Compte créé !</p>
<p class="text-sm text-muted">Tu peux maintenant te connecter.</p>
<RouterLink to="/auth" class="btn-primary w-full py-2.5 text-center">Se connecter</RouterLink>
<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>
<RouterLink to="/auth" class="btn-primary w-full h-11 mt-1">Se connecter</RouterLink>
</div>
<!-- Formulaire d'inscription -->
<div v-else class="settings-card p-8 flex flex-col gap-5">
<div>
<p class="text-xs text-accent font-medium mb-1">Invitation FanKarr</p>
<h1 class="text-lg font-semibold text-primary">Créer un compte</h1>
<p v-if="note" class="text-xs text-muted mt-1">{{ note }}</p>
<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>
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">Créer un compte</h1>
<p v-if="note" class="text-meta text-muted">{{ note }}</p>
</div>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="username" type="text" placeholder="username" class="settings-input" @keyup.enter="submit" />
<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" />
</div>
<div>
<label class="settings-label mb-1.5">Mot de passe</label>
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
<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>
<label class="settings-label mb-1.5">Confirmer</label>
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
<div class="flex flex-col gap-[7px]">
<label for="invite-confirm" class="field-label">Confirmer</label>
<input id="invite-confirm" v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
{{ submitting ? '…' : 'Créer le compte' }}
</button>
</div>
@@ -65,7 +65,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
import { Check, TriangleAlert } from 'lucide-vue-next'
import FankarrLogo from '@/components/FankarrLogo.vue'
const route = useRoute()
const code = route.params.code as string
+339 -203
View File
@@ -1,184 +1,249 @@
<template>
<div class="px-4 md:px-8 py-8 max-w-4xl">
<div class="px-4 md:px-10 pt-[26px] pb-12">
<div class="max-w-[1100px] flex flex-col gap-[18px]">
<div data-tour="requests-header" class="flex items-center justify-between mb-6">
<div>
<h1 class="text-lg font-semibold text-primary">Demandes</h1>
<p class="text-sm text-muted mt-0.5">
{{ auth.isAdmin ? 'Toutes les demandes des utilisateurs.' : 'Vos demandes de séries.' }}
</p>
</div>
<!-- Actions admin -->
<div v-if="auth.isAdmin" class="flex items-center gap-2">
<header data-tour="requests-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]">{{ auth.isAdmin ? 'Demandes' : 'Mes demandes' }}</h1>
<span class="text-[13px] text-muted">{{ counts.all }} au total · {{ counts.pending }} en attente</span>
</div>
<div v-if="auth.isAdmin" class="flex items-center gap-2.5">
<button
v-if="approvableCount > 0"
@click="approveAllWithTorrents"
:disabled="bulkBusy"
class="h-[38px] pointer-coarse:h-11 px-4 rounded-full border border-accent/35 text-accent text-body font-bold flex items-center gap-2 hover:bg-accent-muted transition-colors disabled:opacity-50"
>
<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
</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">
<Ellipsis :size="17" />
</button>
<div v-if="headerMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-52 z-20" role="menu">
<button role="menuitem" class="menu-item text-err hover:text-err" :disabled="requests.length === 0" @click="headerMenuOpen = false; removeAll()">
<Trash2 :size="14" /> Tout supprimer
</button>
</div>
</div>
</div>
<RouterLink v-else to="/series" class="h-[38px] pointer-coarse:h-11 px-[18px] rounded-full border border-border text-primary text-body font-semibold flex items-center gap-[9px] hover:bg-hover transition-colors">
<Plus :size="15" /> Demander une série
</RouterLink>
</header>
<!-- Cmpteurs -->
<div class="chip-row">
<button
@click="removeAll"
class="px-3 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border border-red-500/20 transition"
>Tout supprimer</button>
</div>
<!-- Filtres statuts -->
<div class="flex items-center gap-1">
<button
v-for="f in filters" :key="f.value"
v-for="f in visibleFilters" :key="f.value"
@click="activeFilter = f.value"
class="px-3 py-1.5 rounded-lg text-xs transition"
:class="activeFilter === f.value
? 'bg-accent text-white'
: 'text-muted hover:text-primary hover:bg-hover'"
class="chip"
:class="{ 'is-active': activeFilter === f.value }"
:aria-pressed="activeFilter === f.value"
>
{{ f.label }}
<span v-if="counts[f.value]" class="ml-1 opacity-70">({{ counts[f.value] }})</span>
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.value === 'pending' && counts.pending > 0 && activeFilter !== 'pending' }">{{ counts[f.value] }}</span>
</button>
</div>
</div>
<!-- Statistiques (admin) -->
<div v-if="auth.isAdmin" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En attente</span>
<span class="text-2xl font-semibold text-yellow-400">{{ counts.pending }}</span>
<!-- 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…
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Approuvées</span>
<span class="text-2xl font-semibold text-blue-400">{{ counts.approved }}</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Disponibles</span>
<span class="text-2xl font-semibold text-green-400">{{ counts.completed }}</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Refusées</span>
<span class="text-2xl font-semibold" :class="counts.rejected > 0 ? 'text-red-400' : 'text-muted'">{{ counts.rejected }}</span>
</div>
</div>
<!-- Liste -->
<div v-if="loading" class="flex items-center gap-2 text-muted text-sm py-8">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<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.' }}
</p>
</div>
<div v-else-if="filtered.length === 0" class="settings-card text-sm text-muted py-8 text-center">
Aucune demande {{ activeFilter !== 'all' ? `avec ce statut` : '' }}.
</div>
<div v-else class="flex flex-col gap-3.5">
<article
v-for="req in filtered" :key="req.id"
class="card px-[18px] py-4 flex flex-col sm:flex-row gap-4"
>
<div class="flex gap-4 flex-1 min-w-0">
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
<img
v-if="posterOf(req.serieId)"
:src="posterOf(req.serieId)!"
:alt="titleOf(req)"
class="w-14 h-[84px] object-cover rounded-[5px]"
:class="{ 'grayscale-[0.7] brightness-[0.8]': req.status === 'rejected' }"
/>
<span v-else class="w-14 h-[84px] rounded-[5px] bg-hover flex items-center justify-center text-muted"><Tv :size="18" /></span>
</RouterLink>
<div v-else class="flex flex-col gap-3">
<div
v-for="req in filtered" :key="req.id"
class="settings-card flex flex-col gap-3"
>
<div class="flex items-start justify-between gap-4 flex-wrap">
<!-- Infos série -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<RouterLink :to="`/series/${req.serieId}`" class="text-sm font-semibold text-primary hover:text-accent transition truncate">
{{ req.serieName }}
</RouterLink>
<span class="text-xs px-2 py-0.5 rounded border shrink-0" :class="statusClass(req.status)">
{{ statusLabel(req.status) }}
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[9px]">
<div class="flex items-center gap-x-2.5 gap-y-1 flex-wrap">
<RouterLink :to="`/series/${req.serieId}`" class="text-base font-bold hover:text-accent transition-colors truncate" :class="req.status === 'rejected' ? 'text-secondary' : 'text-primary'">
{{ titleOf(req) }}
</RouterLink>
<span class="pill h-[22px] px-2.5" :class="statusPill(req.status).class">{{ statusPill(req.status).label }}</span>
<span class="sm:ml-auto text-meta text-muted">{{ dateLabel(req) }}</span>
</div>
<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)}`"
>
<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.
</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>
</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">
{{ req.rejectionMessage ? `Motif : ${req.rejectionMessage}` : 'Refusée sans motif.' }}
</p>
</div>
<!-- Saisons / épisodes fusionnés -->
<p class="text-xs text-muted mt-1">
<template v-if="mergedEpisodes(req).length > 0">
{{ mergedEpisodes(req).length }} épisode{{ mergedEpisodes(req).length > 1 ? 's' : '' }} spécifique{{ mergedEpisodes(req).length > 1 ? 's' : '' }}
<template v-if="mergedSeasons(req).length > 0"> · Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
</template>
<template v-else-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
<template v-else>Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
</p>
<!-- Message de refus -->
<p v-if="req.rejectionMessage" class="text-xs text-red-400 mt-1">
Refus : {{ req.rejectionMessage }}
</p>
</div>
<!-- Actions admin -->
<div v-if="auth.isAdmin" class="flex items-center gap-1 shrink-0">
<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'"
@click="approve(req)"
class="btn-ghost text-xs text-blue-400 hover:text-blue-300"
>Approuver</button>
:disabled="busy[req.id]"
class="h-9 max-sm:h-11 max-sm:flex-1 max-sm:min-w-0 px-3 rounded-full text-[13px] flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
:class="req.status === 'pending' && req.hasTorrents !== false
? 'bg-accent text-on-accent font-bold hover:bg-accent-hover'
: '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' }}
</button>
<button
v-if="req.status === 'approved'"
v-else-if="req.status === 'approved'"
@click="complete(req)"
class="btn-ghost text-xs text-green-400 hover:text-green-300"
: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>
<RouterLink
v-else
:to="`/series/${req.serieId}`"
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 flex items-center justify-center"
>Voir la série</RouterLink>
<div class="flex gap-2 shrink-0">
<button
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>
<button
v-else
@click="remove(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="Supprimer la demande"
><Trash2 :size="15" class="sm:hidden" /><span class="max-sm:hidden">Supprimer</span></button>
<div class="relative" @click.stop>
<button
@click="cardMenu = cardMenu === req.id ? null : req.id"
class="w-[34px] h-[34px] max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-primary hover:bg-hover transition-colors"
aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="cardMenu === req.id"
>
<Ellipsis :size="15" />
</button>
<div v-if="cardMenu === req.id" class="menu absolute right-0 top-full mt-1.5 w-52 z-20" role="menu">
<RouterLink v-if="req.status !== 'completed'" :to="`/series/${req.serieId}`" role="menuitem" class="menu-item">
<Tv :size="14" /> Voir la série
</RouterLink>
<button v-if="req.status === 'completed'" role="menuitem" class="menu-item" @click="cardMenu = null; rejectTarget = req">
<X :size="14" /> Refuser
</button>
<button v-if="req.status === 'pending' || req.status === 'approved'" role="menuitem" class="menu-item text-err hover:text-err" @click="cardMenu = null; remove(req)">
<Trash2 :size="14" /> Supprimer
</button>
</div>
</div>
</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}`"
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 flex items-center justify-center"
>Voir la série</RouterLink>
<button
v-if="req.status !== 'rejected'"
@click="openRejectModal(req)"
class="btn-ghost text-xs text-red-400 hover:text-red-300"
>Refuser</button>
<button @click="remove(req)" class="btn-ghost text-xs text-muted hover:text-red-400">Supprimer</button>
v-if="req.status === 'pending'"
@click="withdraw(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-err hover:border-err/30 transition-colors disabled:opacity-50"
>Annuler ma demande</button>
<button
v-else-if="req.status === 'rejected'"
@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>
</div>
</div>
<!-- Requesters (admin) -->
<div v-if="auth.isAdmin && req.requesters.length > 0" class="flex flex-wrap gap-2">
<div v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
{{ r.username.charAt(0).toUpperCase() }}
</span>
<span class="text-muted">{{ r.username }}</span>
<span v-if="r.episodes?.length > 0" class="text-muted/60">
({{ r.episodes.length }} ep.)
</span>
<span v-else-if="r.seasons.length > 0" class="text-muted/60">
({{ r.seasons.map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }})
</span>
</div>
</div>
<p class="text-[11px] text-muted">Demandé {{ formatRelative(req.createdAt) }}</p>
</article>
</div>
</div>
<!-- Modal refus -->
<Teleport to="body">
<div v-if="rejectModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="rejectModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-4 p-6">
<p class="text-sm font-semibold text-primary">Refuser la demande</p>
<p class="text-xs text-muted truncate">{{ rejectModal.serieName }}</p>
<div>
<label class="settings-label mb-1.5">Message de refus (optionnel)</label>
<input v-model="rejectMessage" type="text" class="settings-input" placeholder="Raison du refus…" @keyup.enter="confirmReject" />
</div>
<div class="flex gap-2 justify-end">
<button @click="rejectModal = null" class="btn-ghost">Annuler</button>
<button @click="confirmReject" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition text-xs">
Refuser
</button>
</div>
</div>
</div>
</Teleport>
<RejectRequestModal
v-if="rejectTarget"
:serie-name="titleOf(rejectTarget)"
@close="rejectTarget = null"
@confirm="confirmReject"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { onClickOutside } from '@vueuse/core'
import { Check, Ellipsis, Loader, Plus, Trash2, TriangleAlert, Tv, X } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { useSeriesStore } from '@/stores/series'
import { useRequestsStore } from '@/stores/requests'
import { useToast } from '@/composables/useToast'
import { formatRelative, plural } from '@/utils/format'
import { mergedEpisodes, mergedSeasons, seasonsLabel, type Requester, type SerieRequest } from '@/utils/requests'
import RejectRequestModal from '@/components/requests/RejectRequestModal.vue'
interface Requester { userId: string; username: string; seasons: number[]; episodes: number[]; requestedAt: string }
interface SerieRequest {
id: string; serieId: number; serieName: string
requesters: Requester[]; status: string
rejectionMessage?: string; createdAt: string; updatedAt: string
}
const auth = useAuthStore()
const seriesStore = useSeriesStore()
const reqStore = useRequestsStore()
const { add: toast } = useToast()
const auth = useAuthStore()
const loading = ref(true)
const loading = ref(true)
const requests = ref<SerieRequest[]>([])
const busy = reactive<Record<string, boolean>>({})
const bulkBusy = ref(false)
const activeFilter = ref<string>('all')
const activeFilter = ref<'all' | 'pending' | 'approved' | 'completed' | 'rejected'>('all')
const filters = [
{ value: 'all', label: 'Toutes' },
{ value: 'pending', label: 'En attente' },
{ value: 'approved', label: 'Approuvées' },
{ value: 'all', label: 'Toutes' },
{ value: 'pending', label: 'En attente' },
{ value: 'approved', label: 'Approuvées' },
{ value: 'completed', label: 'Disponibles' },
{ value: 'rejected', label: 'Refusées' },
{ value: 'rejected', label: 'Refusées' },
] as const
const counts = computed(() => ({
@@ -189,40 +254,124 @@ const counts = computed(() => ({
rejected : requests.value.filter(r => r.status === 'rejected').length,
}))
const filtered = computed(() =>
activeFilter.value === 'all'
? requests.value
: requests.value.filter(r => r.status === activeFilter.value)
)
// L'invité ne voit « Approuvées » que s'il en a
const visibleFilters = computed(() => filters.filter(f => auth.isAdmin || f.value !== 'approved' || counts.value.approved > 0))
// En attente d'abord, puis les plus récentes
const filtered = computed(() => {
const order: Record<string, number> = { pending: 0, approved: 1, completed: 2, rejected: 3 }
const list = activeFilter.value === 'all' ? [...requests.value] : requests.value.filter(r => r.status === activeFilter.value)
return list.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt))
})
const approvableCount = computed(() => requests.value.filter(r => r.status === 'pending' && r.hasTorrents).length)
// ── 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
function statusPill(status: string) {
if (status === 'approved' && !auth.isAdmin) return { label: 'Bientôt là', class: 'pill-active' }
return ({
pending : { label: 'En attente', class: 'pill-wait' },
approved : { label: 'Approuvée', class: 'pill-neutral' },
completed: { label: 'Disponible', class: 'pill-ok' },
rejected : { label: 'Refusée', class: 'pill-err' },
} as Record<string, { label: string; class: string }>)[status] ?? { label: status, class: 'pill-neutral' }
}
function dateLabel(req: SerieRequest) {
if (req.status === 'pending') return `demandée ${formatRelative(req.createdAt)}`
if (req.status === 'approved') return `approuvée ${formatRelative(req.updatedAt)}`
if (req.status === 'rejected') return `refusée ${formatRelative(req.updatedAt)}`
return `ajoutée ${formatRelative(req.updatedAt)}`
}
// « Saisons 3 et 4 · 8 épisodes concernés », « 2 épisodes ciblé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é'}`)
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')
}
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(', ')
}
const guestStatusLine: Record<string, string> = {
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 ────────────────────────────────────────────────
async function load() {
loading.value = true
const res = await fetch('/api/requests', { credentials: 'include' })
if (res.ok) requests.value = await res.json()
loading.value = false
reqStore.refreshPending()
}
onMounted(load)
onMounted(() => {
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
load()
document.addEventListener('click', closeCardMenu)
})
onUnmounted(() => document.removeEventListener('click', closeCardMenu))
// ── 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 ─────────────────────────────────────────────
async function patch(req: SerieRequest, body: object): Promise<boolean> {
busy[req.id] = true
try {
const res = await fetch(`/api/requests/${req.id}`, {
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 }
return true
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
finally { busy[req.id] = false }
}
async function approve(req: SerieRequest) {
const res = await fetch(`/api/requests/${req.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'approve' }),
})
if (res.ok) load()
if (await patch(req, { action: 'approve' })) {
toast(req.hasTorrents === false ? 'Demande approuvée, aucun torrent à télécharger' : 'Demande approuvée, téléchargement lancé', 'success')
load()
}
}
async function approveAllWithTorrents() {
const targets = requests.value.filter(r => r.status === 'pending' && r.hasTorrents)
if (!targets.length || !confirm(`Approuver ${plural(targets.length, 'demande')} et lancer les téléchargements ?`)) return
bulkBusy.value = true
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')
load()
}
async function complete(req: SerieRequest) {
const res = await fetch(`/api/requests/${req.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'complete' }),
})
if (res.ok) load()
if (await patch(req, { action: 'complete' })) load()
}
async function remove(req: SerieRequest) {
if (!confirm(`Supprimer la demande pour "${req.serieName}" ?`)) return
if (!confirm(`Supprimer la demande pour "${titleOf(req)}" ?`)) return
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
load()
}
@@ -235,51 +384,38 @@ async function removeAll() {
load()
}
const rejectModal = ref<SerieRequest | null>(null)
const rejectMessage = ref('')
function openRejectModal(req: SerieRequest) { rejectModal.value = req; rejectMessage.value = '' }
async function confirmReject() {
if (!rejectModal.value) return
await fetch(`/api/requests/${rejectModal.value.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ action: 'reject', rejectionMessage: rejectMessage.value || undefined }),
})
rejectModal.value = null
load()
const rejectTarget = ref<SerieRequest | null>(null)
async function confirmReject(message: string) {
const req = rejectTarget.value
rejectTarget.value = null
if (req && await patch(req, { action: 'reject', rejectionMessage: message || undefined })) load()
}
// ── Helpers ───────────────────────────────────────────────────
function mergedSeasons(req: SerieRequest): number[] {
if (req.requesters.some(r => r.seasons.length === 0 && (r.episodes?.length ?? 0) === 0)) return []
const all = new Set<number>()
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
return [...all].sort((a, b) => a - b)
// ── Actions invité ────────────────────────────────────────────
async function withdraw(req: SerieRequest) {
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 }
toast('Demande annulée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[req.id] = false }
}
function mergedEpisodes(req: SerieRequest): number[] {
const all = new Set<number>()
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
return [...all].sort((a, b) => a - b)
}
function statusLabel(s: string) {
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[s] ?? s
}
function statusClass(s: string) {
return {
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
}[s] ?? ''
}
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
async function requestAgain(req: SerieRequest) {
const mine = req.requesters.find(r => r.userId === auth.userId)
busy[req.id] = true
try {
const res = await fetch('/api/requests', {
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 }
toast('Nouvelle demande envoyée', 'success')
load()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { busy[req.id] = false }
}
</script>
+151 -163
View File
@@ -4,155 +4,130 @@
<!-- 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-sm">Chargement…</p>
<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-sm text-red-400">{{ store.error }}</p>
<p class="text-body text-err">{{ store.error }}</p>
<button class="btn-primary" @click="load">Réessayer</button>
</div>
<template v-else-if="data">
<!-- Hero — poster, titre, métadonnées, synopsis -->
<SerieHero :serie="data.serie" />
<!-- Bandeau : affiche, titre, progression et actions -->
<SerieHero
:serie="data.serie"
:season-count="seasonCount"
:episode-count="episodeCount"
:organized-count="organizedCount"
:organized-label="auth.isAdmin ? 'importé' : 'disponible'"
: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
:disabled="downloadingAll"
class="btn-primary max-sm:w-full"
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
>
<Loader v-if="downloadingAll" :size="16" class="animate-spin" />
<Download v-else :size="16" :stroke-width="2.25" />
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
<ChevronDown v-if="data.torrents_integrale.length > 1" :size="13" :stroke-width="2.5" />
</button>
<!-- Barre d'actions admin -->
<div v-if="auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<!-- 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"
:key="i"
role="menuitem"
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
class="menu-item"
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
:title="t.raw ?? ''"
@click="downloadAll(i); downloadMenuOpen = false"
>
<Download :size="14" class="shrink-0" />
<span class="truncate">{{ integraleGroupLabels()[i] }}</span>
</button>
</div>
</div>
<!-- Gauche : gestion bibliothèque -->
<div class="flex items-center gap-2">
<!-- Import manuel -->
<button
@click="openManualImport"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20 transition"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import manuel
</button>
<!-- Supprimer série -->
<button
v-if="Object.keys(organizedByEpisode).length > 0"
@click="openUnimportSerieModal"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
</svg>
Supprimer
</button>
</div>
<!-- Droite : téléchargements -->
<div class="flex flex-wrap items-center gap-2">
<!-- Bouton RSS sync -->
<button
@click="toggleRssSync"
:title="rssSync ? 'Surveillance activée — cliquer pour désactiver' : 'Surveiller les nouveaux épisodes'"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="rssSync
? 'bg-green-500/10 text-green-400 border-green-500/20 hover:bg-green-500/20'
: 'bg-shell text-muted border-border hover:text-primary hover:bg-hover'"
>
<!-- Icône antenne RSS -->
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M4 11a9 9 0 0 1 9 9"/>
<path d="M4 4a16 16 0 0 1 16 16"/>
<circle cx="5" cy="19" r="1" fill="currentColor" stroke="none"/>
</svg>
{{ rssSync ? 'Surveillé' : 'Surveiller' }}
</button>
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
<button
:disabled="downloadingAll"
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
>
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
<path v-if="!downloadingAll" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline v-if="!downloadingAll" points="7 10 12 15 17 10"/><line v-if="!downloadingAll" x1="12" y1="15" x2="12" y2="3"/>
<path v-else d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
</svg>
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
<span v-if="data.torrents_integrale.length > 1" class="opacity-70">▾</span>
<button @click="openManualImport" class="btn-secondary font-medium max-sm:w-11 max-sm:px-0 max-sm:shrink-0" title="Import manuel" aria-label="Import manuel">
<Upload :size="15" :stroke-width="2" /> <span class="max-sm:hidden">Import manuel</span>
</button>
<!-- Dropdown choix d'intégrale -->
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1"
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
<button
v-for="(t, i) in data.torrents_integrale"
:key="i"
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
@click="downloadAll(i); downloadMenuOpen = false"
>
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{{ integraleGroupLabels()[i] }}
<div class="relative" @click.stop>
<button @click="moreMenuOpen = !moreMenuOpen" class="btn-icon" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="moreMenuOpen">
<Ellipsis :size="16" />
</button>
<div v-if="moreMenuOpen" class="menu absolute right-0 sm:right-auto sm:left-0 top-full mt-1.5 w-64 z-20" role="menu">
<button role="menuitem" class="menu-item" @click="toggleRssSync(); moreMenuOpen = false">
<Rss :size="14" class="shrink-0" />
{{ rssSync ? 'Ne plus surveiller' : 'Surveiller les nouveaux épisodes' }}
</button>
<button
v-if="Object.keys(organizedByEpisode).length > 0"
role="menuitem"
class="menu-item text-err hover:text-err"
@click="openUnimportSerieModal(); moreMenuOpen = false"
>
<Trash2 :size="14" class="shrink-0" /> Supprimer la série…
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Série absente du scraper -->
<div v-if="auth.isAdmin && !data.scraper_synced" class="mx-4 md:mx-8 mt-4 flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Série absente du scraper</p>
<p class="text-xs text-muted">
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
</p>
</div>
</div>
<!-- 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" />
{{ myRequest ? 'Demander les saisons manquantes' : 'Demander la série' }}
</button>
<span v-if="myRequest" class="min-h-10 py-2 px-4 rounded-full border border-accent/30 text-accent text-[13px] font-semibold flex items-center gap-[9px]">
<Clock3 :size="15" :stroke-width="2" />
{{ myRequest.status === 'approved' ? 'Demande approuvée · bientôt là' : 'Demande en attente' }}<template v-if="myScopeLabel"> · {{ myScopeLabel }}</template>
</span>
<span v-else-if="allOrganized" class="h-10 px-1 flex items-center gap-2 text-body font-semibold text-ok">
<Check :size="16" :stroke-width="2.5" /> Toute la série est disponible
</span>
</template>
</template>
</SerieHero>
<!-- Dossier série à renommer -->
<div v-if="auth.isAdmin && staleFolders.length > 0" class="mx-4 md:mx-8 mt-4 flex items-center justify-between gap-3 flex-wrap px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<div class="flex items-start gap-3 min-w-0">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5 min-w-0">
<p class="text-sm text-yellow-500 font-medium">Dossier différent du titre de la série</p>
<p class="text-xs text-muted break-all">
<span class="font-mono text-primary">{{ staleFolders.map(f => basename(f.path)).join(', ') }}</span>
→ <span class="font-mono text-primary">{{ folderTarget }}</span>
<div class="px-4 md:px-10 pt-5 pb-16 flex flex-col gap-4">
<!-- Série absente du scraper -->
<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="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.
</p>
</div>
</div>
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary text-xs shrink-0">
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
</button>
</div>
<!-- Barre utilisateur (mode demande) -->
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<div v-if="myRequest" class="flex items-center gap-3">
<span class="text-xs px-2 py-1 rounded border" :class="requestStatusClass(myRequest.status)">
{{ requestStatusLabel(myRequest.status) }}
</span>
<span v-if="myRequest.status === 'rejected' && myRequest.rejectionMessage" class="text-xs text-muted">
{{ myRequest.rejectionMessage }}
</span>
<!-- Dossier série à renommer -->
<div v-if="auth.isAdmin && staleFolders.length > 0" 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 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="text-meta text-secondary break-all">
{{ staleFolders.map(f => basename(f.path)).join(', ') }} → <span class="text-primary">{{ folderTarget }}</span>
</p>
</div>
</div>
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary btn-sm shrink-0">
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
</button>
</div>
<div v-else class="text-xs text-muted">Cliquez sur une saison ou un épisode pour faire une demande</div>
<button
v-if="!myRequest || myRequest.status === 'rejected' || myRequest.status === 'completed'"
@click="openRequestModal()"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
>
Demander la série
</button>
</div>
<!-- Saisons -->
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
<!-- Saisons -->
<SerieSeasonCard
v-for="season in data.seasons"
:key="season.id"
@@ -168,6 +143,7 @@
:request-mode="!auth.isAdmin"
:requested-seasons="requestedSeasonNumbers"
:requested-episodes="requestedEpisodeIds"
:request-status="myRequest?.status"
@toggle="toggleSeason"
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
@download-season="(s, h) => downloadSeason(s, h)"
@@ -181,34 +157,34 @@
<!-- Modal demande (utilisateurs) -->
<Teleport to="body">
<div v-if="requestModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="requestModal = false">
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
<div>
<p class="text-sm font-semibold text-primary">Demander une série</p>
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
<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">
<div class="flex flex-col gap-1">
<h2 id="request-title" class="card-title">Demander une série</h2>
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
</div>
<div class="flex flex-col gap-2">
<p class="text-xs text-muted font-medium">Saisons demandées</p>
<div class="flex flex-col gap-1">
<p class="tag-label mb-1.5">Saisons demandées</p>
<label
v-for="season in data?.seasons"
:key="season.id"
class="flex items-center gap-3 cursor-pointer select-none"
class="flex items-center gap-3 min-h-9 cursor-pointer select-none"
>
<input
type="checkbox"
:value="season.season_number"
v-model="requestSeasons"
class="w-4 h-4 rounded border-border accent-accent"
class="w-4 h-4 rounded"
/>
<span class="text-sm text-primary">
<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 font-normal">— {{ season.title }}</span>
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">— {{ season.title }}</span>
</span>
</label>
</div>
<div class="flex items-center justify-end gap-2">
<button @click="requestModal = false" class="btn-secondary text-xs">Annuler</button>
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary text-xs disabled:opacity-50">
<div class="flex items-center justify-end gap-2.5">
<button @click="requestModal = false" class="btn-ghost">Annuler</button>
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary">
Envoyer la demande
</button>
</div>
@@ -219,19 +195,19 @@
<!-- Modal désimport série -->
<Teleport to="body">
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
<div>
<p class="text-sm font-semibold text-primary">Supprimer la série</p>
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
<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>
<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 border-border accent-accent" />
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
<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>
</label>
<div class="flex items-center justify-end gap-2">
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
<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' }}
</button>
</div>
@@ -260,6 +236,9 @@ import { useRoute } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { Check, ChevronDown, Clock3, Download, Ellipsis, Folder, Loader, Rss, Trash2, TriangleAlert, Upload } from 'lucide-vue-next'
import { seasonsLabel } from '@/utils/requests'
import { plural } from '@/utils/format'
import ManualImportModal from '@/components/ManualImportModal.vue'
import SerieHero from '@/components/serie/SerieHero.vue'
import SerieSeasonCard from '@/components/serie/SerieSeasonCard.vue'
@@ -379,17 +358,13 @@ async function submitRequest() {
} catch { toast('Impossible de contacter le serveur', 'error') }
}
function requestStatusLabel(status: string) {
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[status] ?? status
}
function requestStatusClass(status: string) {
return {
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
}[status] ?? ''
}
// Portée de ma demande en cours : « saisons 3 et 4 », « 2 épisodes »
const myScopeLabel = computed(() => {
const r = myRequest.value?.requesters?.find((r: any) => r.userId === auth.userId)
if (!r) return ''
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'épisode')
return seasonsLabel(r.seasons).toLowerCase()
})
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<string[]>([])
@@ -402,6 +377,7 @@ const epActionLoading = ref<Record<number, boolean>>({})
const unimportSerieModal = ref(false)
const deleteSerieFiles = ref(false)
const downloadMenuOpen = ref(false)
const moreMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
const rssSync = ref(false)
@@ -423,6 +399,18 @@ const _recentlyOrganized = new Set<string>() // hash:fileIndex ou has
const data = computed(() => store.currentSerie)
const seasonCount = computed(() => data.value?.seasons.filter(s => s.season_number > 0).length ?? 0)
const episodeCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.length, 0) ?? 0)
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 ─────────────────────────────────────────
function integraleGroupLabels(): string[] {
const torrents = data.value?.torrents_integrale ?? []
@@ -817,7 +805,7 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
finally { epActionLoading.value[ep.id] = false }
}
const closeMenus = () => { downloadMenuOpen.value = false }
const closeMenus = () => { downloadMenuOpen.value = false; moreMenuOpen.value = false }
onMounted(() => {
load()
+333 -145
View File
@@ -1,119 +1,157 @@
<template>
<div class="flex flex-col">
<div class="px-4 md:px-10 pt-[26px] pb-12">
<div class="flex flex-col gap-[18px]">
<SeriesToolbar
v-model:search="search"
v-model:active-filter="activeFilter"
v-model:active-sort="activeSort"
v-model:poster-size="posterSize"
:poster-sizes="posterSizes"
:count="filtered.length"
:has-active-filter="hasActiveFilter"
:filters-disponibilite="filtersDisponibilite"
:filters-import="filtersImport"
:sort-options="sortOptions"
:legend="legend"
:selectable="auth.isAdmin"
v-model:selecting="selecting"
/>
<SeriesToolbar
v-model:search="search"
v-model:active-filter="activeFilter"
v-model:active-sort="activeSort"
v-model:poster-size="posterSize"
v-model:selecting="selecting"
:poster-sizes="posterSizes"
:count="filtered.length"
:filters="filters"
:more-filters="moreFilters"
:sort-options="sortOptions"
:selectable="auth.isAdmin"
/>
<!-- Grille -->
<div data-tour="series-grid" class="px-4 md:px-6 py-6">
<!-- Grille -->
<div data-tour="series-grid" class="pt-1.5">
<div v-if="store.loadingSeries" 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-sm">Chargement du catalogue…</p>
</div>
<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>
</div>
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
<p class="text-sm text-red-400">{{ store.error }}</p>
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
</div>
<div v-else-if="store.error && store.series.length === 0" 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="store.fetchSeries()">Réessayer</button>
</div>
<div v-else-if="filtered.length === 0" class="flex items-center justify-center h-64 text-muted text-sm">
Aucune série trouvée
</div>
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 h-64 text-center">
<p class="font-display text-xl font-bold text-primary">Aucune série trouvée</p>
<p class="text-body text-muted">Essayez une autre recherche ou un autre filtre.</p>
<button v-if="search || activeFilter !== 'all'" @click="search = ''; activeFilter = 'all'" class="btn-ghost btn-sm">Tout afficher</button>
</div>
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<component
:is="selecting ? 'div' : RouterLink"
v-for="(serie, i) in filtered"
:key="serie.id"
:data-tour="i === 0 ? 'series-poster' : undefined"
v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
'cursor-pointer select-none': selecting,
}"
@click="selecting && toggleSelected(serie.id)"
<div
v-else
class="grid grid-cols-2 gap-x-3.5 gap-y-5 sm:grid-cols-[repeat(auto-fill,minmax(var(--poster-min),1fr))] sm:gap-x-5 sm:gap-y-[26px]"
:style="{ '--poster-min': posterSizes[posterSize] }"
>
<div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
>
<img
v-if="serie.poster_image"
:src="serie.poster_image"
:alt="serie.title"
loading="lazy"
class="w-full h-full object-cover"
/>
<div v-else class="w-full h-full flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
<div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
</div>
<template v-for="item in gridItems" :key="item.key">
<!-- Tuile -->
<div
v-if="selecting"
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
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"
>
<Check v-if="selected.has(serie.id)" :size="13" />
<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>
</div>
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
<Rss :size="11" />
</div>
<div
class="absolute bottom-0 left-0 right-0 h-1"
:style="{ background: stateColor(serie.download_state) }"
/>
</div>
<div>
<p class="text-xs font-medium truncate leading-snug text-primary">{{ serie.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span v-if="serie.year" class="text-[11px] text-muted">{{ serie.year }}</span>
<span
v-if="serie.status"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/10 text-green-400'
: 'bg-hover text-muted'"
<!-- Affiche -->
<component
v-else
:is="selecting ? 'button' : RouterLink"
v-bind="selecting ? { type: 'button', 'aria-pressed': selected.has(item.serie.id) } : { to: `/series/${item.serie.id}` }"
:data-tour="item.first ? 'series-poster' : undefined"
class="group flex flex-col gap-[9px] min-w-0 text-left"
:class="{ 'cursor-pointer select-none': selecting }"
@click="selecting && toggleSelected(item.serie.id)"
>
<div
class="relative aspect-[2/3] rounded-poster overflow-hidden bg-card shadow-[0_0_0_1px_rgb(239_233_221/0.06)] transition-transform duration-200 group-hover:-translate-y-0.5"
:class="{ 'ring-2 ring-accent': selected.has(item.serie.id) }"
>
{{ serie.status }}
</span>
</div>
</div>
</component>
<img
v-if="item.serie.poster_image"
:src="item.serie.poster_image"
:alt="item.serie.title"
loading="lazy"
class="w-full h-full object-cover"
:class="{ 'grayscale brightness-[0.45]': item.state.dim && auth.isAdmin, 'brightness-[0.55] saturate-[0.7]': item.state.dim && !auth.isAdmin }"
/>
<div v-else class="w-full h-full flex items-center justify-center text-muted">
<Tv :size="28" />
</div>
<!-- Haut gauche : sélection ou importée -->
<span
v-if="selecting"
class="absolute left-2 top-2 w-6 h-6 rounded-full border flex items-center justify-center"
:class="selected.has(item.serie.id) ? 'bg-accent border-accent text-on-accent' : 'bg-sidebar/80 border-primary/50'"
>
<Check v-if="selected.has(item.serie.id)" :size="14" :stroke-width="3" />
</span>
<span v-else-if="item.state.imported" title="Importée" class="absolute left-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-ok flex items-center justify-center">
<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">
<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">
<Clock3 :size="12" :stroke-width="2.25" /> Demandée
</span>
<!-- Bas : téléchargement en cours -->
<div
v-if="item.state.progress !== null"
class="absolute inset-x-0 bottom-0 px-2.5 pt-[30px] pb-2.5 bg-linear-to-b from-sidebar/0 to-sidebar/92 to-65% flex flex-col gap-1.5"
>
<div class="flex justify-between text-[11.5px] font-bold text-primary">
<span>Téléchargement</span><span v-if="item.state.progress >= 0">{{ item.state.progress }}&nbsp;%</span>
</div>
<div class="h-[3px] rounded-[2px] bg-primary/20 overflow-hidden">
<div class="h-full rounded-[2px] bg-accent transition-[width] duration-500" :style="{ width: `${Math.max(0, item.state.progress)}%` }" />
</div>
</div>
<!-- Bas : état écrit -->
<span
v-else-if="item.state.chip"
class="absolute left-2 bottom-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-[11.5px] font-bold flex items-center gap-1.5 whitespace-nowrap max-w-[calc(100%-16px)]"
:class="chipTone[item.state.chip.tone]"
:title="item.state.chip.title"
>
<TriangleAlert v-if="item.state.chip.icon === 'error'" :size="12" :stroke-width="2.25" class="shrink-0" />
<Folder v-else-if="item.state.chip.icon === 'folder'" :size="12" :stroke-width="2.25" class="shrink-0" />
<Clock3 v-else-if="item.state.chip.icon === 'soon'" :size="12" :stroke-width="2.25" class="shrink-0" />
<span class="truncate">{{ item.state.chip.label }}</span>
<span v-if="item.state.chip.detail" class="text-secondary font-medium">{{ item.state.chip.detail }}</span>
</span>
<span
v-if="item.state.isNew"
class="stamp-new absolute left-2"
:class="item.state.chip || item.state.progress !== null ? 'bottom-[42px]' : 'bottom-2.5'"
>NOUVEAU</span>
</div>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-body font-medium truncate" :class="item.state.dim ? 'text-secondary' : 'text-primary'">{{ item.serie.title }}</span>
<span class="text-xs text-muted truncate">{{ [item.serie.year, statusLabel(item.serie.status)].filter(Boolean).join(' · ') }}</span>
</div>
</component>
</template>
</div>
</div>
</div>
<!-- Actions de masse -->
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
<div class="flex items-center gap-2 ml-auto">
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
<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>
<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>
<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="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>
</div>
</div>
@@ -121,21 +159,24 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { RouterLink } from 'vue-router'
import { Tv, Check, Rss, X } from 'lucide-vue-next'
import { Check, Clock3, Folder, Rss, TriangleAlert, Tv, X } from 'lucide-vue-next'
import { useSeriesStore, type Serie } from '@/stores/series'
import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import { usePosterSize } from '@/composables/usePosterSize'
import SeriesToolbar from '@/components/series/SeriesToolbar.vue'
import { useLibrarySearch } from '@/composables/useLibrarySearch'
import { isNew, statusLabel } from '@/utils/series'
import SeriesToolbar, { type LibraryFilter } from '@/components/series/SeriesToolbar.vue'
defineOptions({ name: 'SeriesView' })
const store = useSeriesStore()
const dlStore = useDownloadsStore()
const auth = useAuthStore()
const librarySearch = useLibrarySearch()
const { add: toast } = useToast()
const { current: posterSize, sizes: posterSizes } = usePosterSize()
@@ -143,48 +184,65 @@ const search = ref('')
const activeFilter = ref('all')
const activeSort = ref('alpha')
const filtersDisponibilite = [
{ label: 'Toutes', value: 'all' },
{ label: 'Disponibles', value: 'available' },
{ label: 'Sans torrent', value: 'unavailable' },
]
const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
]
const sortOptions = [
{ label: 'A→Z', value: 'alpha' },
{ label: 'Z→A', value: 'alpha-desc' },
{ label: 'Récents', value: 'recent' },
{ label: 'Importés', value: 'imported' },
{ label: 'A → Z', value: 'alpha' },
{ label: 'Z → A', value: 'alpha-desc' },
{ label: 'Récentes', value: 'recent' },
{ label: 'Importées d’abord', value: 'imported' },
]
const legend = [
{ label: 'Importé', color: '#22c55e' },
{ label: 'En cours', color: '#3b82f6' },
{ label: 'Partiel', color: '#9b59b6' },
{ label: 'Non importé', color: 'var(--border)' },
]
// ── Filtres à compteurs ──────────────────────────────────────
const myRequested = ref<Set<number>>(new Set())
const hasActiveFilter = computed(() => activeFilter.value !== 'all')
const predicates: Record<string, (s: Serie) => boolean> = {
'all' : () => true,
'complete' : s => s.download_state === 'complete',
'partial' : s => s.download_state === 'partial',
'downloading': s => s.download_state === 'downloading',
'to-download': s => s.download_state === 'none' && s.has_torrents,
'no-torrent' : s => s.download_state === 'none' && !s.has_torrents,
'not-here' : s => s.download_state === 'none',
'mine' : s => myRequested.value.has(s.id),
'available' : s => s.has_torrents,
'untracked' : s => !s.in_client && !s.has_files,
'watched' : s => s.rss_synced,
}
const countOf = (value: string) => store.series.filter(predicates[value]!).length
const filters = computed<LibraryFilter[]>(() => (auth.isAdmin
? [
{ value: 'all', label: 'Toutes' },
{ value: 'complete', label: 'Importées' },
{ value: 'partial', label: 'Partielles' },
{ value: 'downloading', label: 'En téléchargement', attention: true },
{ value: 'to-download', label: 'À télécharger' },
{ value: 'no-torrent', label: 'Sans torrent' },
]
: [
{ value: 'all', label: 'Toutes' },
{ value: 'complete', label: 'Prêtes à regarder' },
{ value: 'partial', label: 'Partielles' },
{ value: 'downloading', label: 'Bientôt', attention: true },
{ value: 'not-here', label: 'Pas encore là' },
{ value: 'mine', label: 'Mes demandes', attention: true },
]
).map(f => ({ ...f, count: countOf(f.value) })))
const moreFilters = computed<LibraryFilter[]>(() => auth.isAdmin
? [
{ value: 'available', label: 'Avec torrent' },
{ value: 'watched', label: 'Surveillées' },
{ value: 'untracked', label: 'Absentes du client et du disque' },
].map(f => ({ ...f, count: countOf(f.value) }))
: [])
const filtered = computed(() => {
let list = [...store.series]
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
let list = store.series.filter(predicates[activeFilter.value] ?? predicates.all!)
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(s => s.title.toLowerCase().includes(q))
const q = normalize(search.value)
list = list.filter(s => normalize(s.title).includes(q))
}
switch (activeSort.value) {
@@ -201,6 +259,100 @@ const filtered = computed(() => {
return list
})
function normalize(text: string) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
}
// ── État des séries ─────────────────────────────────────
const errorsBySerie = ref<Map<number, number>>(new Map())
const staleFolders = ref<Set<number>>(new Set())
const chipTone: Record<string, string> = {
err : 'text-err',
accent : 'text-accent',
primary: 'text-primary',
muted : 'text-secondary',
}
interface PosterState {
imported : boolean
watched : boolean
requested: boolean
dim : boolean
isNew : boolean
progress : number | null
chip : { label: string; detail?: string; tone: string; icon?: string; title?: string } | null
}
function downloadProgress(s: Serie): number | null {
if (s.download_state !== 'downloading') return null
const active = dlStore.torrents.filter(t => t.serieId === s.id && t.state === 'downloading')
if (active.length === 0) return -1
const size = active.reduce((sum, t) => sum + (t.size ?? 0), 0)
const done = active.reduce((sum, t) => sum + (t.downloaded ?? 0), 0)
return Math.round(size > 0 ? done / size * 100 : active[0].progress ?? 0)
}
function posterState(s: Serie): PosterState {
const partial = s.download_state === 'partial'
? { label: 'Partielle', detail: s.episode_count ? `${s.organized_count}/${s.episode_count}` : undefined, tone: 'primary' }
: null
if (!auth.isAdmin) {
const notHere = s.download_state === 'none'
return {
imported : false,
watched : false,
requested: myRequested.value.has(s.id),
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' }
: partial ?? (notHere ? { label: 'Pas encore là', tone: 'muted' } : null),
}
}
const errors = errorsBySerie.value.get(s.id) ?? 0
return {
imported : s.download_state === 'complete',
watched : s.rss_synced,
requested: false,
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' }
: partial ?? (s.download_state === 'none' && !s.has_torrents ? { label: 'Sans torrent', tone: 'muted' } : null),
}
}
// Grille──
function letterOf(title: string) {
const c = normalize(title).charAt(0).toUpperCase()
return /[A-Z]/.test(c) ? c : '#'
}
type GridItem =
| { type: 'letter'; key: string; letter: string; count: number }
| { type: 'serie'; key: number; serie: Serie; state: PosterState; first: boolean }
const gridItems = computed<GridItem[]>(() => {
const withLetters = activeSort.value === 'alpha' || activeSort.value === 'alpha-desc'
const counts = new Map<string, number>()
if (withLetters) for (const s of filtered.value) counts.set(letterOf(s.title), (counts.get(letterOf(s.title)) ?? 0) + 1)
const items: GridItem[] = []
let letter = ''
filtered.value.forEach((serie, i) => {
if (withLetters && letterOf(serie.title) !== letter) {
letter = letterOf(serie.title)
items.push({ type: 'letter', key: `letter-${letter}`, letter, count: counts.get(letter) ?? 0 })
}
items.push({ type: 'serie', key: serie.id, serie, state: posterState(serie), first: i === 0 })
})
return items
})
// ── Sélection multiple ───────────────────────────────────────
const selecting = ref(false)
const selected = ref<Set<number>>(new Set())
@@ -257,29 +409,35 @@ async function bulkSync(enabled: boolean) {
}
}
function stateColor(state: string): string {
return ({
complete : '#22c55e',
downloading: '#3b82f6',
partial : '#9b59b6',
none : 'transparent',
} as Record<string, string>)[state] ?? 'transparent'
}
// ── Polling downloads + notifs import ────────────────────────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
let notifsPrimed = false
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
// Erreurs du dernier passage de chaque torrent, par série
const errors = new Map<number, number>()
const latest = new Set<string>()
for (const n of notifs) {
if (latest.has(n.hash)) continue
latest.add(n.hash)
if (n.errors > 0 && n.serieId != null) errors.set(n.serieId, (errors.get(n.serieId) ?? 0) + n.errors)
}
errorsBySerie.value = errors
const primed = notifsPrimed
notifsPrimed = true
let hasNew = false
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
if (!primed) continue
hasNew = true
if (n.done > 0) {
const msg = n.errors > 0
@@ -292,11 +450,28 @@ async function fetchOrganizeNotifs() {
} catch {}
}
async function fetchStaleFolders() {
try {
const res = await fetch('/api/organized-folders', { credentials: 'include' })
if (res.ok) staleFolders.value = new Set((await res.json()).map((f: any) => f.serie_id))
} catch {}
}
async function fetchMyRequests() {
try {
const res = await fetch('/api/requests', { credentials: 'include' })
if (!res.ok) return
const list: any[] = await res.json()
myRequested.value = new Set(list.filter(r => r.status === 'pending' || r.status === 'approved').map(r => r.serieId))
} catch {}
}
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
if (auth.isAdmin) {
dlStore.refresh()
fetchOrganizeNotifs()
fetchStaleFolders()
dlInterval = setInterval(() => {
dlStore.refresh()
fetchOrganizeNotifs()
@@ -304,9 +479,22 @@ onMounted(async () => {
}
})
let isActive = false
function applyLibrarySearch() {
const q = librarySearch.consume()
if (q === null) return
if (q) search.value = q
nextTick(() => document.querySelector<HTMLInputElement>('[data-library-search]')?.focus())
}
watch(librarySearch.pending, (p) => { if (p && isActive) applyLibrarySearch() })
onDeactivated(() => { isActive = false })
onActivated(async () => {
isActive = true
applyLibrarySearch()
store.fetchSeries()
if (auth.isAdmin) dlStore.refresh()
if (auth.isAdmin) { dlStore.refresh(); fetchStaleFolders() }
else fetchMyRequests()
})
onUnmounted(() => {
+39 -38
View File
@@ -1,70 +1,71 @@
<template>
<div class="min-h-screen bg-shell flex flex-col md:flex-row">
<div class="min-h-screen bg-main flex flex-col md:flex-row">
<!-- Rail des étapes -->
<aside class="md:w-72 md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-border flex flex-col shrink-0">
<div class="flex items-center gap-2.5 px-5 py-4 border-b border-border min-h-[56px]">
<FankarrLogo class="w-7 h-7 shrink-0" />
<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" />
<div class="flex-1 min-w-0">
<p class="text-[15px] font-semibold text-primary tracking-wide leading-tight">FanKarr</p>
<p class="text-[11px] text-muted">Assistant de configuration</p>
<p class="font-display text-[23px] font-bold text-primary leading-tight tracking-[0.01em]">FanKarr</p>
<p class="text-[11.5px] text-muted">Assistant de configuration</p>
</div>
<button
v-if="context.relaunch"
@click="close"
title="Fermer l'assistant"
class="text-muted hover:text-primary transition-colors p-1 rounded-md hover:bg-hover"
aria-label="Fermer l'assistant"
class="w-9 h-9 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
>
<X :size="16" />
<X :size="17" />
</button>
<button
v-else
@click="logout"
title="Se déconnecter"
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
aria-label="Se déconnecter"
class="w-9 h-9 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-hover transition-colors"
>
<LogOut :size="15" />
<LogOut :size="16" :stroke-width="1.75" />
</button>
</div>
<!-- Progression mobile -->
<div class="md:hidden px-5 py-3">
<p class="text-xs text-muted">Étape {{ index + 1 }} sur {{ steps.length }} · <span class="text-primary">{{ current.label }}</span></p>
<div class="h-1 bg-border rounded-full mt-2 overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: `${((index + 1) / steps.length) * 100}%` }" />
<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}%` }" />
</div>
</div>
<nav aria-label="Étapes" class="hidden md:block flex-1 overflow-y-auto px-3 py-5">
<ol class="flex flex-col gap-1">
<nav aria-label="Étapes" class="hidden md:block flex-1 overflow-y-auto px-3.5 pb-5">
<ol class="flex flex-col gap-0.5">
<li v-for="(step, i) in steps" :key="step.id">
<button
:disabled="!canVisit(i)"
:aria-current="i === index ? 'step' : undefined"
@click="visit(i)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="i === index ? 'bg-active' : canVisit(i) ? 'hover:bg-hover' : 'cursor-default'"
class="w-full flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-left transition-colors"
:class="i === index ? 'bg-hover' : canVisit(i) ? 'hover:bg-hover/60' : 'cursor-default'"
>
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium shrink-0 border"
class="w-[26px] h-[26px] rounded-full flex items-center justify-center text-[11.5px] font-bold shrink-0 border tabular-nums"
:class="stepBadgeClass(step.id, i)"
>
<TriangleAlert v-if="step.id === 'client' && context.clientSkipped && i !== index" :size="12" />
<Check v-else-if="i < reached && i !== index" :size="12" />
<Check v-else-if="i < reached && i !== index" :size="13" :stroke-width="2.5" />
<template v-else>{{ i + 1 }}</template>
</span>
<span class="flex flex-col min-w-0">
<span class="text-sm truncate" :class="i === index ? 'text-primary font-medium' : canVisit(i) ? 'text-secondary' : 'text-muted'">
<span class="text-body truncate" :class="i === index ? 'text-primary font-semibold' : canVisit(i) ? 'text-secondary' : 'text-muted'">
{{ step.label }}
</span>
<span class="text-[11px] text-muted truncate">{{ step.hint }}</span>
<span class="text-xs text-muted truncate">{{ step.hint }}</span>
</span>
</button>
</li>
</ol>
</nav>
<p class="hidden md:block px-5 py-4 text-[11px] text-muted border-t border-border leading-relaxed">
<p class="hidden md:block px-5 py-4 text-xs text-muted border-t border-card leading-relaxed">
Tout reste modifiable ensuite dans les Paramètres.
</p>
</aside>
@@ -75,13 +76,13 @@
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<div v-else class="max-w-2xl mx-auto px-5 md:px-8 py-8 md:py-12 flex flex-col gap-8">
<div v-else class="max-w-[720px] mx-auto px-4 md:px-10 pt-7 md:pt-12 pb-16 flex flex-col gap-7">
<header class="flex flex-col gap-1.5">
<p class="text-xs text-accent font-medium">
Étape {{ index + 1 }} sur {{ steps.length }}<template v-if="current.optional"> · optionnelle</template>
<p class="tag-label text-accent">
Étape {{ index + 1 }} sur {{ steps.length }}<template v-if="current.optional"> · facultative</template>
</p>
<h1 ref="titleRef" tabindex="-1" class="text-xl font-semibold text-primary outline-none">{{ current.title }}</h1>
<p class="text-sm text-muted leading-relaxed">{{ current.description }}</p>
<h1 ref="titleRef" tabindex="-1" class="page-title outline-none">{{ current.title }}</h1>
<p class="text-body text-muted leading-relaxed">{{ current.description }}</p>
</header>
<component
@@ -93,17 +94,17 @@
:default-path="defaultPath"
/>
<footer v-if="current.id !== 'recap'" class="flex items-center gap-2 pt-6 border-t border-border">
<button v-if="index > 0" @click="visit(index - 1)" :disabled="busy" class="btn-secondary flex items-center gap-1.5">
<ArrowLeft :size="14" /> Précédent
<footer v-if="current.id !== 'recap'" class="flex items-center gap-2.5 pt-6 border-t border-hover">
<button v-if="index > 0" @click="visit(index - 1)" :disabled="busy" class="btn-ghost">
<ArrowLeft :size="15" /> Précédent
</button>
<div class="flex-1" />
<button v-if="current.optional" @click="next(false)" :disabled="busy" class="btn-ghost">
<button v-if="current.optional" @click="next(false)" :disabled="busy" class="text-body text-muted hover:text-primary transition-colors px-2">
Passer
</button>
<button @click="next(true)" :disabled="busy" class="btn-primary flex items-center gap-1.5">
{{ busy ? '...' : index === 0 ? 'Commencer' : 'Suivant' }}
<ArrowRight v-if="!busy" :size="14" />
<button @click="next(true)" :disabled="busy" class="btn-primary">
{{ busy ? '…' : index === 0 ? 'Commencer' : 'Suivant' }}
<ArrowRight v-if="!busy" :size="15" />
</button>
</footer>
</div>
@@ -219,8 +220,8 @@ function canVisit(i: number) {
function stepBadgeClass(id: SetupStepId, i: number) {
if (i === index.value) return 'border-accent text-accent bg-accent-muted'
if (id === 'client' && context.clientSkipped) return 'border-yellow-500/50 text-yellow-500'
if (i < reached.value) return 'border-green-500/50 text-green-400'
if (id === 'client' && context.clientSkipped) return 'border-accent/40 text-accent'
if (i < reached.value) return 'border-ok/40 text-ok'
return 'border-border text-muted'
}
@@ -276,7 +277,7 @@ async function finish(withTour: boolean) {
await router.push('/dashboard')
} else {
if (!auth.tourSeen) auth.markTourSeen()
await router.push(context.relaunch ? '/settings/advanced' : '/series')
await router.push(context.relaunch ? '/settings/advanced' : '/dashboard')
}
}
+124 -23
View File
@@ -1,40 +1,81 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Avancé</h2>
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
</div>
<Teleport defer to="#settings-actions">
<button @click="checkForUpdates" :disabled="checking" class="btn-secondary pointer-fine:h-[38px]">
<RefreshCw :size="15" :class="{ 'animate-spin': checking }" />
{{ checking ? 'Vérification…' : 'Vérifier les mises à jour' }}
</button>
</Teleport>
<div class="settings-card flex flex-col gap-5">
<!-- ── 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]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary flex-wrap">
FanKarr {{ currentVersion || '…' }}
<span v-if="latestVersion && updateAvailable" class="pill pill-wait h-5 px-[9px] text-[11px]">{{ latestVersion }} disponible</span>
<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
</span>
</div>
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
Notes de version <ExternalLink :size="13" />
</a>
</div>
</SettingsSection>
<!-- ── Diag ─────────────────────────────────────────── -->
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
<template #actions>
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</template>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-5 gap-x-6 gap-y-4">
<div v-for="d in diagnostics" :key="d.label" class="flex flex-col gap-0.5">
<span class="font-display text-2xl font-bold" :class="d.accent ? 'text-accent' : 'text-primary'">{{ d.value }}</span>
<span class="text-meta text-muted">{{ d.label }}</span>
</div>
</div>
<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>
</template>
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
/>
</div>
</SettingsSection>
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
<div class="settings-card flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1">
<p class="text-sm text-primary">Assistant de configuration</p>
<p class="text-xs text-muted mt-0.5">
Reprend pas à pas les dossiers, le client torrent, Jellyfin/Plex et le catalogue. La configuration actuelle est conservée.
</p>
<!-- ── 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]">
<span class="text-sm font-medium text-primary">Assistant de configuration</span>
<span class="text-meta text-muted">Reprend pas à pas les dossiers, le client torrent, Jellyfin ou Plex et le catalogue. La configuration actuelle est conservée.</span>
</div>
<div class="flex gap-2.5 shrink-0 flex-wrap">
<button @click="tour.start()" class="btn-ghost btn-sm pointer-fine:h-[34px]">Revoir la visite</button>
<RouterLink to="/setup" class="btn-secondary btn-sm pointer-fine:h-[34px]">Relancer l'assistant</RouterLink>
</div>
</div>
<div class="flex gap-2 shrink-0">
<button @click="tour.start()" class="btn-ghost">Revoir la visite</button>
<RouterLink to="/setup" class="btn-secondary">Relancer l'assistant</RouterLink>
</div>
</div>
</SettingsSection>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Check, ExternalLink, RefreshCw } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import { useTourStore } from '@/stores/tour'
const tour = useTourStore()
@@ -44,7 +85,67 @@ const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
let loaded = false
// ── Version ───────────────────────────────────────────────────
const GITHUB_REPO = 'masutayunikon/FanKarr'
const currentVersion = ref('')
const latestVersion = ref('')
const releaseUrl = ref(`https://github.com/${GITHUB_REPO}/releases/latest`)
const checking = ref(false)
const updateAvailable = computed(() => {
if (!latestVersion.value || !currentVersion.value || currentVersion.value === 'dev') return false
const parse = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
const l = parse(latestVersion.value), c = parse(currentVersion.value)
for (let i = 0; i < Math.max(l.length, c.length); i++) {
if ((l[i] ?? 0) !== (c[i] ?? 0)) return (l[i] ?? 0) > (c[i] ?? 0)
}
return false
})
async function checkForUpdates() {
checking.value = true
try {
const vRes = await fetch('/api/version', { credentials: 'include' })
if (vRes.ok) currentVersion.value = (await vRes.json()).version
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github.v3+json' },
})
if (gRes.ok) {
const release = await gRes.json()
latestVersion.value = release.tag_name ?? ''
releaseUrl.value = release.html_url ?? releaseUrl.value
}
} catch {} finally {
checking.value = false
}
}
// ── Diag ────────────────────────────────────────────────
const debug = ref<any>(null)
async function fetchDebug() {
const res = await fetch('/api/debug/stats', { credentials: 'include' })
if (res.ok) debug.value = await res.json()
}
// « 4 h 37 min »
const uptime = computed(() => {
const s = debug.value?.uptimeSeconds ?? 0
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d} j ${h} h` : h > 0 ? `${h} h ${m} min` : `${m} min`
})
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 },
] : [])
onMounted(async () => {
checkForUpdates()
fetchDebug()
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
@@ -65,4 +166,4 @@ watch(devMode, async (val) => {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saved.value = false }, 2000)
})
</script>
</script>
+129 -65
View File
@@ -1,99 +1,148 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
<p class="text-sm text-muted mt-1">
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
</p>
</div>
<Teleport defer to="#settings-actions">
<button @click="update" :disabled="updating" class="btn-secondary pointer-fine:h-[38px]">
<RefreshCw :size="15" :class="{ 'animate-spin': updating }" />
{{ updating ? 'Synchronisation…' : 'Synchroniser maintenant' }}
</button>
</Teleport>
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<!-- 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>
<!-- Bannière données manquantes -->
<div
v-if="status.empty"
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
>
<div>
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
<p class="text-sm text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
</p>
</div>
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
</button>
</div>
<!-- Statut -->
<div class="settings-card">
<p class="text-xs text-muted mb-1">Statut</p>
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
</p>
</div>
<!-- Action -->
<div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
</button>
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
<!-- Surveillances orphelines -->
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
<div>
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
<p class="text-xs text-muted mt-0.5">
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.
</p>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
<button
@click="removeSyncOrphan(s.serieId)"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
<!-- 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" />
<div class="flex flex-col gap-1">
<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.
</p>
</div>
</div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
</button>
</div>
<!-- Sources -->
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
<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">
Scraper GitHub
<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>
</span>
</div>
<span class="text-meta text-muted shrink-0">Cache d’une heure</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>
</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.">
<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' }}
</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>
</div>
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
<RouterLink
v-for="s in visibleWatched" :key="s.serieId"
:to="`/series/${s.serieId}`"
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' : '' }}
</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>
</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."
>
<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">
<span class="flex-1 min-w-0 text-body text-primary truncate">{{ s.serieName }}</span>
<span class="text-meta text-muted shrink-0 tabular-nums">#{{ s.serieId }}</span>
<button @click="removeSyncOrphan(s.serieId)" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
</div>
</div>
</SettingsSection>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
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 SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
const seriesStore = useSeriesStore()
const updating = ref(false)
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
const status = ref<{ exists: boolean; count: number; empty: boolean; syncedAt?: string | null }>({ exists: false, count: 0, empty: true })
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
const watched = ref<{ serieId: number; serieName: string }[]>([])
const runningRss = ref(false)
const showAllWatched = ref(false)
const visibleWatched = computed(() => showAllWatched.value ? watched.value : watched.value.slice(0, 8))
const apiOnlyCount = computed(() => Math.max(0, seriesStore.series.length - status.value.count))
onMounted(async () => {
fetchSyncOrphans()
fetchWatched()
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
loaded.value = true
})
async function fetchWatched() {
try {
const res = await fetch('/api/rss-sync', { credentials: 'include' })
if (res.ok) watched.value = (await res.json()).sort((a: any, b: any) => a.serieName.localeCompare(b.serieName, 'fr'))
} catch {}
}
async function fetchSyncOrphans() {
try {
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
@@ -106,19 +155,34 @@ async function removeSyncOrphan(serieId: number) {
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 }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
watched.value = watched.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
}
async function runRss() {
runningRss.value = true
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')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
runningRss.value = false
}
}
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 }
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
toast(`${count} séries chargées ✓`, 'success')
fetchSyncOrphans()
} else {
@@ -131,4 +195,4 @@ async function update() {
updating.value = false
}
}
</script>
</script>
+115 -101
View File
@@ -1,125 +1,119 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
<Teleport defer to="#settings-actions">
<button @click="openAddModal" class="btn-primary pointer-fine:h-[38px]">
<Plus :size="15" :stroke-width="2.25" /> Ajouter un client
</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>
</div>
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
</div>
<!-- Liste vide -->
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
Aucun client configuré.
</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">
<p class="text-body text-muted">Aucun client configuré.</p>
<button @click="openAddModal" class="btn-secondary btn-sm">Ajouter un client</button>
</div>
<!-- Liste clients -->
<div v-else class="flex flex-col gap-2">
<div
v-for="client in clients"
:key="client.uuid"
class="settings-card flex items-center justify-between"
class="flex items-center gap-4 px-5 py-3.5 border-t border-hover flex-wrap sm:flex-nowrap"
>
<div class="flex items-center gap-3">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
/>
<div>
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 flex-wrap">
<span class="text-sm font-medium text-primary">{{ client.name }}</span>
<span
class="pill h-5 px-[9px] text-[11px]"
:class="healthStatus[client.uuid] === true ? 'pill-ok' : healthStatus[client.uuid] === false ? 'pill-err' : 'pill-muted'"
>
{{ healthStatus[client.uuid] === true ? 'Connecté' : healthStatus[client.uuid] === false ? 'Injoignable' : 'Vérification…' }}
</span>
</span>
<span class="text-meta text-muted truncate">
{{ clientLabel(client.type) }}<template v-if="client.config?.url"> · {{ client.config.url }}</template>
</span>
</div>
<button @click="testClient(client.uuid)" :disabled="testingClient[client.uuid]" class="btn-secondary btn-sm pointer-fine:h-[34px]">
{{ testingClient[client.uuid] ? 'Test…' : 'Tester' }}
</button>
<div class="relative" @click.stop>
<button
@click="clientMenu = clientMenu === client.uuid ? null : client.uuid"
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-primary hover:bg-hover transition-colors"
:aria-label="`Actions pour ${client.name}`" aria-haspopup="menu" :aria-expanded="clientMenu === client.uuid"
>
<Ellipsis :size="15" />
</button>
<div v-if="clientMenu === client.uuid" class="menu absolute right-0 top-full mt-1.5 w-44 z-20" role="menu">
<button role="menuitem" class="menu-item" @click="clientMenu = null; editClient(client)"><Pencil :size="14" /> Modifier</button>
<button role="menuitem" class="menu-item text-err hover:text-err" @click="clientMenu = null; deleteClient(client)"><Trash2 :size="14" /> Supprimer</button>
</div>
</div>
<div class="flex items-center gap-1">
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
</div>
</div>
</div>
</section>
<!-- Tableau comparatif -->
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
<div class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-xs">
<!-- 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>
</div>
<div class="overflow-x-auto border-t border-hover">
<table class="w-full min-w-[720px] text-meta">
<thead>
<tr class="border-b border-border bg-shell">
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
<tr class="border-b border-hover">
<th class="text-left px-5 h-[34px] tag-label tracking-[0.12em] font-bold">Client</th>
<th class="text-left px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Authentification</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Choix du fichier</th>
<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>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
<!-- Client + version -->
<td class="px-4 py-2.5">
<tbody>
<tr v-for="row in clientMatrix" :key="row.id" class="border-b border-hover last:border-b-0">
<td class="px-5 py-2.5">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full shrink-0"
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:class="matrixHealth(row.id) === true ? 'bg-ok' : matrixHealth(row.id) === false ? 'bg-err' : 'bg-transparent'"
/>
<div>
<p class="text-primary font-medium">{{ row.label }}</p>
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
<p class="text-body text-primary font-medium">{{ row.label }}</p>
<p class="text-xs text-muted">{{ row.version }}</p>
</div>
</div>
</td>
<!-- Auth -->
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
<!-- Sélection fichier -->
<td class="text-center px-3 py-2.5">
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
{{ row.fileSelect ? 'Async ✓' : '✗' }}
</span>
</td>
<!-- Progression par fichier -->
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
{{ row.fileProgress ? '✓' : '✗' }}
</td>
<!-- Upload .torrent (FanKarr proxy) -->
<td class="text-center px-3 py-2.5">
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
</span>
</td>
<!-- Catégories -->
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
{{ row.categories ? '✓' : '✗' }}
</td>
<!-- Suppression fichiers -->
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
{{ row.deleteFiles ? '✓' : '✗' }}
</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.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.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>
</tbody>
</table>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
<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>
</div>
</div>
</section>
<!-- Modal ajout -->
<!-- Modale ajout / modification -->
<Teleport to="body">
<div
v-if="modal.open"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="closeModal"
>
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">{{ modal.client ? 'Modifier un client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
<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">
<div class="flex items-center justify-between gap-4">
<h3 id="client-modal-title" class="font-display text-xl font-bold text-primary">{{ modal.client ? 'Modifier le client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
</div>
<TorrentClientForm
@@ -128,7 +122,6 @@
:definitions="availableClients"
@saved="onSaved"
/>
</div>
</div>
</Teleport>
@@ -137,7 +130,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { Ellipsis, Pencil, Plus, Trash2, X } from 'lucide-vue-next'
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
import { useToast } from '@/composables/useToast'
import type { TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
@@ -147,6 +141,16 @@ const { add: toast } = useToast()
const clients = ref<SavedTorrentClient[]>([])
const availableClients = ref<TorrentClientDefinition[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const testingClient = ref<Record<string, boolean>>({})
const clientMenu = ref<string | null>(null)
const closeClientMenu = () => { clientMenu.value = null }
onUnmounted(() => document.removeEventListener('click', closeClientMenu))
function matrixHealth(type: string) {
const client = clients.value.find(c => c.type === type)
return client ? healthStatus.value[client.uuid] : undefined
}
const clientMatrix = [
{
@@ -189,6 +193,7 @@ onMounted(async () => {
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
for (const c of clients.value) refreshHealth(c.uuid)
document.addEventListener('click', closeClientMenu)
})
async function refreshHealth(uuid: string) {
@@ -202,13 +207,22 @@ async function refreshHealth(uuid: string) {
}
async function testClient(uuid: string) {
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')
healthStatus.value[uuid] = ok
testingClient.value[uuid] = true
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')
healthStatus.value[uuid] = ok
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
testingClient.value[uuid] = false
}
}
async function deleteClient(uuid: string) {
async function deleteClient(client: SavedTorrentClient) {
if (!confirm(`Supprimer le client « ${client.name} » ?`)) return
const uuid = client.uuid
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
+293 -169
View File
@@ -1,212 +1,235 @@
<template>
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
<div class="flex flex-col gap-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
<p class="text-xs text-muted mt-0.5">
Visualisez et renommez vos fichiers importés selon le mode actuel
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
<Teleport defer to="#settings-actions">
<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]">
<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>.
</template>
<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">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>Tous les fichiers portent le nom attendu.</template>
</span>
</p>
</div>
<button @click="renameAll()" :disabled="renamingAll || totalNeedsRename === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ renamingAll ? 'Renommage…' : totalNeedsRename > 0 ? `Renommer les ${totalNeedsRename}` : 'Renommer' }}
</button>
</div>
<div class="flex items-center gap-2">
<button @click="load" :disabled="loading" class="btn-secondary">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
Actualiser
<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>
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
</p>
<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">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>
</span>
</div>
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ renamingFolders ? 'Renommage…' : staleFolders.length > 1 ? `Renommer les ${staleFolders.length} dossiers` : 'Renommer le dossier' }}
</button>
<button
@click="forceMetadataRefresh"
:disabled="forcingRefresh"
class="btn-secondary"
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
>
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
</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">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>
</div>
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
</button>
<button
@click="purgeNfo"
:disabled="purgingNfo"
class="btn-secondary text-red-400 hover:text-red-300"
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
>
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
</div>
</SettingsSection>
<!-- ── Métadonnées ────────────────────────────────────────── -->
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
<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>
</span>
</div>
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ checkingNfo ? 'Lancement…' : 'Vérifier' }}
</button>
</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-err">Purger les NFO</span>
<span class="text-meta text-muted">Supprime tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de séries. Les vidéos ne sont pas touchées.</span>
</div>
<button @click="purgeNfo" :disabled="purgingNfo" class="btn-danger btn-sm pointer-fine:h-[34px] shrink-0">
{{ purgingNfo ? 'Suppression…' : 'Purger' }}
</button>
</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.">
<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>
</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-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>
</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' }}
<ChevronDown :size="13" class="transition-transform" :class="{ 'rotate-180': orphansOpen }" />
</button>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ removingOrphans ? '…' : 'Nettoyer' }}
</button>
</div>
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
<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 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>
</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">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>
</div>
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ migrating ? 'Migration…' : 'Migrer' }}
</button>
</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>
</div>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
:disabled="renamingAll"
class="btn-primary"
>
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
</button>
@click="showOnlyNeedsRename = !showOnlyNeedsRename"
class="chip"
:class="{ 'is-active': showOnlyNeedsRename }"
:aria-pressed="showOnlyNeedsRename"
>À renommer<span class="chip-count">{{ seriesNeedingRename }}</span></button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<div v-if="loading" class="flex items-center justify-center gap-2 py-12 text-muted text-body border-t border-hover">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
<p class="text-sm text-muted">Aucune série importée</p>
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
</div>
<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>
</div>
<!-- Résultat rename all -->
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
</p>
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
</div>
<!-- Liste séries -->
<div v-else class="flex flex-col gap-3">
<div
v-for="serie in filteredSeries"
:key="serie.serie_id"
class="settings-card flex flex-col gap-0"
>
<!-- Header série -->
<div class="flex items-center justify-between py-1">
<div class="flex items-center gap-2.5">
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span v-if="serie.needs_rename > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
{{ serie.needs_rename }} à renommer
</span>
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
</span>
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
✓ À jour
</span>
</div>
<div class="flex items-center gap-2">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
Tout renommer
</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
Désimporter
</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
Supprimer fichiers
</button>
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
<div class="flex items-center gap-3 px-5 py-3 flex-wrap">
<button
@click="toggleSerie(serie.serie_id)"
class="w-7 h-7 -ml-1 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
:aria-expanded="!collapsed.has(serie.serie_id)"
:aria-label="collapsed.has(serie.serie_id) ? 'Déplier' : 'Replier'"
>
<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 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-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, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
</div>
</div>
<!-- Liste épisodes -->
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
<div v-if="!collapsed.has(serie.serie_id)" class="pb-2">
<div
v-for="ep in serie.episodes"
:key="ep.episode_id"
class="flex items-center gap-3 py-2.5"
class="flex items-center gap-3 px-5 py-2 border-t border-hover/60 flex-wrap sm:flex-nowrap"
>
<!-- Numéro -->
<span class="text-xs text-muted font-mono shrink-0 w-14">
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
</span>
<!-- Noms -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5">
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
<p class="text-body text-primary truncate">{{ ep.title }}</p>
<div class="flex items-center gap-1.5 mt-0.5 min-w-0">
<span class="text-xs text-muted truncate" :title="ep.current_name">{{ ep.current_name }}</span>
<template v-if="ep.needs_rename">
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
</svg>
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
<ArrowRight :size="11" class="shrink-0 text-accent" />
<span class="text-xs text-accent truncate" :title="ep.expected_name">{{ ep.expected_name }}</span>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button
v-else
@click="renameSingle(ep, serie)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
<div class="flex items-center gap-1.5 shrink-0">
<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' }}
</button>
<button
@click="unimportEp(ep, false)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
>
Désimporter
</button>
<button
@click="unimportEp(ep, true)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
>
Supprimer
</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>
</div>
</div>
</div>
</div>
</div>
<!-- Entrées orphelines -->
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
{{ removingOrphans ? '…' : 'Tout retirer' }}
</button>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
<span class="text-xs text-muted font-mono shrink-0 w-14">
{{ 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-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
<button
@click="removeOrphans([o.episode_id])"
:disabled="removingOrphans"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
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 SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
@@ -223,11 +246,32 @@ const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false)
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
const removingOrphans = ref(false)
const orphansOpen = ref(false)
const staleFolders = ref<{ serie_id: number; serie_title: string; expected: string; current: string[] }[]>([])
const renamingFolders = ref(false)
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
const scanning = ref(false)
const checkingNfo = ref(false)
const lastNfoUpdate = ref<{ serieTitle: string; updatedAt: string } | null>(null)
const migrating = ref(false)
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
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)
const missingIn = (serie: any) => serie.episodes.filter((e: any) => !e.file_exists).length
const missingFiles = computed(() => series.value.reduce((acc, s) => acc + missingIn(s), 0))
// « 3 h », « 12 min », « 2 j »
const sinceScan = computed(() => {
if (!lastScan.value) return ''
const m = Math.max(0, Math.floor((Date.now() - new Date(lastScan.value.at).getTime()) / 60_000))
return m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} j`
})
const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
@@ -248,6 +292,86 @@ async function load() {
} finally {
loading.value = false
}
loadStatus()
}
async function loadStatus() {
try {
const [folders, scanRes, nfo] = await Promise.all([
fetch('/api/organized-folders', { credentials: 'include' }),
fetch('/api/scan', { credentials: 'include' }),
fetch('/api/nfo-updates/recent', { credentials: 'include' }),
])
if (folders.ok) staleFolders.value = await folders.json()
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
if (nfo.ok) lastNfoUpdate.value = (await nfo.json()).find((n: any) => n.status === 'updated') ?? null
} catch {}
}
async function scan() {
scanning.value = true
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')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
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
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 }
moved += d.moved ?? 0
}
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
renamingFolders.value = false
}
}
async function checkNfo() {
checkingNfo.value = true
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')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
checkingNfo.value = false
}
}
async function migrateIds() {
migrating.value = true
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 (data.updated > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
migrating.value = false
}
}
function toggleSerie(id: number) {
+49 -70
View File
@@ -1,73 +1,51 @@
<template>
<div class="flex flex-col gap-6">
<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'">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
</button>
</Teleport>
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Serveur Jellyfin</h2>
<p class="text-sm text-muted mt-1">
Configurez la connexion à votre serveur Jellyfin pour synchroniser les utilisateurs
et permettre l'authentification depuis le plugin.
</p>
</div>
<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" />
</section>
</SettingsSection>
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Synchronisation des utilisateurs</h2>
<p class="text-sm text-muted mt-1">
Importe les utilisateurs Jellyfin en tant que comptes FanKarr.
Les comptes existants sont ignorés. Les utilisateurs pourront ensuite se connecter
via le plugin Jellyfin sans configuration supplémentaire.
</p>
</div>
<div class="settings-card flex flex-col gap-4">
<div v-if="syncResult" class="flex flex-col gap-1">
<p class="text-sm text-primary font-medium">
{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
</p>
<p v-if="syncResult.users.length > 0" class="text-xs text-muted">
Nouveaux : {{ syncResult.users.join(', ') }}
</p>
</div>
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary w-fit disabled:opacity-50">
<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."
>
<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' }}
</button>
<p v-if="syncError" class="text-xs text-red-400">{{ syncError }}</p>
<p v-if="!hasToken" class="text-xs text-muted">Configurez d'abord le token admin.</p>
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</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>
</div>
</section>
</SettingsSection>
<!-- ── Référence API ──────────────────────────────────────── -->
<section class="flex flex-col gap-3">
<h2 class="text-base font-semibold text-primary">Référence API v1</h2>
<div class="settings-card overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-border">
<th class="text-left px-3 py-2 text-muted font-medium">Méthode</th>
<th class="text-left px-3 py-2 text-muted font-medium">Endpoint</th>
<th class="text-left px-3 py-2 text-muted font-medium">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-border/50">
<tr v-for="ep in apiEndpoints" :key="ep.path">
<td class="px-3 py-2">
<span class="font-mono font-semibold" :class="methodClass(ep.method)">{{ ep.method }}</span>
</td>
<td class="px-3 py-2">
<code class="text-muted">{{ ep.path }}</code>
</td>
<td class="px-3 py-2 text-muted">{{ ep.desc }}</td>
</tr>
</tbody>
</table>
<!-- ── 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.
</p>
</div>
<div
v-for="ep in apiEndpoints" :key="ep.method + ep.path"
class="flex items-center gap-x-3.5 gap-y-1 px-5 py-2.5 border-t border-hover flex-wrap sm:flex-nowrap"
>
<span class="pill h-[22px] px-2 text-[10.5px] w-14 justify-center shrink-0" :class="ep.method === 'GET' ? 'pill-neutral' : 'pill-wait'">{{ ep.method }}</span>
<code class="text-body text-primary sm:w-60 shrink-0 font-sans">{{ ep.path }}</code>
<span class="text-meta text-muted">{{ ep.desc }}</span>
</div>
</section>
@@ -76,24 +54,25 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const hasToken = ref(false)
const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
const apiEndpoints = [
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
{ method: 'GET', path: '/api/v1/series/search?q=', desc: 'Rechercher dans le catalogue FanKai' },
{ method: 'POST', path: '/api/v1/requests', desc: 'Créer ou mettre à jour une demande' },
{ method: 'GET', path: '/api/v1/requests', desc: 'Récupérer ses propres demandes' },
]
const apiBase = `${window.location.origin}/api/v1`
function methodClass(m: string) {
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
}
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' },
]
async function syncUsers() {
syncing.value = true
+135 -113
View File
@@ -1,109 +1,108 @@
<template>
<div class="flex flex-col gap-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
<div class="flex gap-2">
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
</div>
</div>
<Teleport defer to="#settings-actions">
<button @click="exportLogs" :disabled="entries.length === 0" class="btn-secondary pointer-fine:h-[38px]">
<Download :size="15" /> Exporter
</button>
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
</Teleport>
<!-- Filtres -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex gap-1">
<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
v-for="l in levels" :key="l.value"
@click="filterLevel = l.value; load()"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="filterLevel === l.value
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
class="chip"
:class="{
'is-active': filterLevel === l.value,
'border-err/30 text-err hover:text-err': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
}"
:aria-pressed="filterLevel === l.value"
>
{{ l.label }}
{{ l.label }}<span
class="chip-count"
:class="{
'text-err! font-bold': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
'text-accent! font-bold': l.value === 'warn' && (counts.warn ?? 0) > 0 && filterLevel !== 'warn',
}"
>{{ l.value === 'all' ? totalCount : (counts[l.value] ?? 0) }}</span>
</button>
</div>
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
<option value="">Toutes les sources</option>
<option value="organize">organize</option>
<option value="api">api</option>
<option value="auth">auth</option>
</select>
<div class="flex items-center gap-1 ml-auto">
<span class="text-xs text-muted mr-1">Afficher</span>
<button
v-for="n in [100, 500, 2000]" :key="n"
@click="limit = n; load()"
class="px-2 py-1 text-xs rounded-lg border transition-colors"
:class="limit === n
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ n === 2000 ? 'Tout' : n }}
<div class="flex items-center gap-2.5 flex-wrap">
<select v-model="filterSource" @change="load()" class="field h-[34px] pointer-coarse:h-10 py-0 rounded-full w-auto text-[13px]" aria-label="Source">
<option value="">Toutes les sources</option>
<option v-for="s in sources" :key="s" :value="s">{{ s }}</option>
</select>
<div class="segmented" role="group" aria-label="Nombre d'entrées">
<button
v-for="n in [100, 500, 2000]" :key="n"
@click="limit = n; load()"
class="segmented-item"
:class="{ 'is-active': limit === n }"
:aria-pressed="limit === n"
>{{ n === 2000 ? 'Tout' : n }}</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">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<!-- 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...
Chargement…
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
Aucun log
<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 -->
<div v-else class="settings-card p-0 overflow-hidden">
<section v-else class="bg-card rounded-card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-xs font-mono">
<thead>
<tr class="border-b border-border">
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, i) in entries" :key="i"
class="border-b border-border/50 hover:bg-hover transition-colors"
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
>
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
{{ entry.level.toUpperCase() }}
</span>
</td>
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
<td class="px-3 py-2 text-primary break-all">
{{ entry.msg }}
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Confirm effacement -->
<Teleport to="body">
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
<div>
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
<p class="text-xs text-muted">Cette action est irréversible.</p>
<div class="min-w-[640px]">
<div class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
<span>Heure</span><span>Niveau</span><span>Source</span><span>Message</span>
</div>
<div class="flex gap-2">
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
<div
v-for="(entry, i) in entries" :key="i"
class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-start gap-y-1 px-5 py-2 border-b border-hover last:border-b-0 hover:bg-hover/30 transition-colors"
>
<span class="text-meta text-muted tabular-nums pt-px" :title="fullDate(entry.at)">{{ formatTime(entry.at) }}</span>
<span>
<span class="pill h-5 px-2 text-[10.5px] gap-1" :class="levelInfo(entry.level).class">
<TriangleAlert v-if="entry.level === 'error'" :size="11" :stroke-width="2.25" />
{{ levelInfo(entry.level).label }}
</span>
</span>
<span class="text-meta text-muted truncate pt-px">{{ entry.source }}</span>
<span class="text-body break-words" :class="entry.level === 'error' ? 'text-err' : 'text-primary'">
{{ entry.msg }}
<span v-if="entry.meta" class="text-meta text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
</span>
</div>
</div>
</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>
<!-- 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">
<div class="flex flex-col gap-1">
<h3 id="logs-clear-title" class="card-title">Vider les journaux ?</h3>
<p class="text-meta text-muted">Cette action est irréversible.</p>
</div>
<div class="flex gap-2.5 justify-end">
<button @click="showConfirm = false" class="btn-ghost">Annuler</button>
<button @click="doClear" class="btn-danger">Vider</button>
</div>
</div>
</div>
@@ -113,12 +112,15 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatSize } from '@/utils/format'
const { add: toast } = useToast()
const entries = ref<any[]>([])
const allEntries = ref<any[]>([])
const fileSize = ref(0)
const loading = ref(true)
const showConfirm = ref(false)
@@ -127,27 +129,37 @@ const filterSource = ref('')
const limit = ref(100)
const levels = [
{ label: 'Tous', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warn', value: 'warn' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' },
{ label: 'Tout', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Avertissements', value: 'warn' },
{ label: 'Erreurs', value: 'error' },
{ label: 'Debug', 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
return c
})
const totalCount = computed(() => allEntries.value.length)
const sources = computed(() => [...new Set(allEntries.value.map(e => e.source).filter(Boolean))].sort())
async function fetchLogs(level: string, source: string, max: number) {
const params = new URLSearchParams({ limit: String(max), level, ...(source ? { source } : {}) })
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
return res.ok ? await res.json() : null
}
async function load() {
loading.value = true
try {
const params = new URLSearchParams({
limit: String(limit.value),
level: filterLevel.value,
...(filterSource.value ? { source: filterSource.value } : {}),
})
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
entries.value = data.entries
fileSize.value = data.size
}
const [filtered, all] = await Promise.all([
fetchLogs(filterLevel.value, filterSource.value, limit.value),
fetchLogs('all', '', 2000),
])
if (filtered) { entries.value = filtered.entries; fileSize.value = filtered.size }
if (all) allEntries.value = all.entries
} finally {
loading.value = false
}
@@ -156,30 +168,40 @@ async function load() {
async function doClear() {
showConfirm.value = false
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
toast('Logs effacés', 'success')
toast('Journaux vidés', 'success')
await load()
}
function formatDate(iso: string): string {
// 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' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `fankarr-journaux-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`
a.click()
URL.revokeObjectURL(url)
}
function formatTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const time = d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} ${time.slice(0, 5)}`
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
function fullDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR')
}
function levelClass(level: string): string {
function levelInfo(level: string): { label: string; class: string } {
return ({
debug: 'bg-border text-muted',
info : 'bg-blue-500/10 text-blue-400',
warn : 'bg-yellow-500/10 text-yellow-400',
error: 'bg-red-500/10 text-red-400',
} as Record<string, string>)[level] ?? 'bg-border text-muted'
debug: { label: 'Debug', class: 'pill-muted' },
info : { label: 'Info', 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' }
}
onMounted(load)
</script>
</script>
+154 -139
View File
@@ -1,190 +1,185 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Media Management</h2>
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
</div>
<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>
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</Teleport>
<!-- Loading -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
<!-- 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>
<!-- Chemins -->
<div class="settings-card flex flex-col gap-5">
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Dossier téléchargements</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
<!-- 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">
<span class="field-label flex items-center gap-2">
{{ field.label }}
<span v-if="isDocker" class="pill pill-neutral h-5 px-2 text-[10.5px]">Docker</span>
</span>
<button
@click="openPicker(field.key)"
class="field flex items-center gap-2.5 text-left h-[42px] py-0"
:class="isRootPath(form[field.key]) ? 'border-accent/50' : ''"
>
<span class="flex-1 truncate" :class="form[field.key] ? 'text-primary' : 'text-muted'">{{ form[field.key] || '/' }}</span>
<span class="text-meta text-secondary shrink-0">Parcourir</span>
</button>
<p v-if="isRootPath(form[field.key])" class="text-xs text-accent flex items-center gap-1.5">
<TriangleAlert :size="13" /> Chemin non configuré : cliquez pour choisir un dossier
</p>
<p v-else class="text-xs text-muted">{{ field.hint }}</p>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="settings-label">Médiathèque Fankai</label>
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
</SettingsSection>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
<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.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
<div class="flex gap-2">
<button
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
:key="mode"
@click="form.organizeMode = mode"
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
:class="form.organizeMode === mode
? 'border-accent text-accent bg-accent-muted'
: 'border-border text-muted hover:border-secondary'"
>
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
<!-- 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">
<button
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
:key="mode"
type="button"
@click="form.organizeMode = mode"
class="segmented-item"
:class="{ 'is-active': form.organizeMode === mode }"
:aria-pressed="form.organizeMode === mode"
>
{{ 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>
</div>
<p class="text-xs text-muted">
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
</p>
</div>
<!-- Toggles -->
<div class="settings-card flex flex-col gap-5">
<div class="h-px bg-hover" />
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO / Métadonnées"
description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)."
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."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si fichier manquant"
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
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."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
label="Dossiers « Season » plutôt que « Saison »"
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
/>
</div>
</SettingsSection>
<!-- Plex -->
<div class="settings-card flex items-center justify-between gap-4">
<div>
<p class="settings-label">Plex</p>
<p class="text-xs text-muted mt-0.5">
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
</p>
<!-- Serveurs média -->
<SettingsSection title="Serveurs média" 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">
Jellyfin
<span class="pill h-5 px-[9px] text-[11px]" :class="jellyfinConfigured ? 'pill-ok' : 'pill-muted'">{{ jellyfinConfigured ? 'Configuré' : 'Non configuré' }}</span>
</span>
<span class="text-meta text-muted">Synchronisation des comptes et connexion depuis le plugin FanKarr Search.</span>
</div>
<RouterLink to="/settings/jellyfin" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Réglages Jellyfin</RouterLink>
</div>
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
</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">Plex</span>
<span class="text-meta text-muted">Crée une bibliothèque Plex reliée à l'agent de métadonnées Fankai. Nécessite Plex Media Server 1.43 ou plus récent pour configurer l'agent automatiquement.</span>
</div>
<button @click="plexOpen = true" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Configurer Plex</button>
</div>
</SettingsSection>
<!-- Actions -->
<div class="flex items-center gap-3">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? '...' : 'Sauvegarder' }}
<!-- 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" />
<ScanSearch v-else :size="15" />
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary">
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
</button>
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
<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>
</span>
</div>
</template>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Choix de dossier -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Plex Wizard -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Assistant Plex -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div
v-if="nfoConfirmOpen"
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
@click.self="nfoConfirmOpen = false"
>
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
<p class="text-xs text-muted leading-relaxed">
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
et peut créer des conflits.
</p>
<p class="text-xs text-muted leading-relaxed">
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
ou un lecteur qui lit les fichiers NFO locaux.
</p>
</div>
<div class="flex gap-2 pt-1">
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
<!-- 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">
<div class="flex flex-col gap-2">
<h3 id="nfo-title" class="card-title">Activer les NFO et métadonnées ?</h3>
<p class="text-meta text-secondary leading-relaxed">
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
les métadonnées sont déjà gérées par l'agent : activer les NFO est inutile et peut créer des conflits.
</p>
<p class="text-meta text-secondary leading-relaxed">
Activez cette option seulement si vous utilisez <span class="text-primary">Infuse</span> ou un lecteur qui lit les fichiers NFO locaux.
</p>
</div>
<div class="flex gap-2.5 justify-end">
<button @click="nfoConfirmOpen = false" class="btn-ghost">Annuler</button>
<button @click="confirmNfo" class="btn-primary">Activer quand même</button>
</div>
</div>
</div>
</div>
</Teleport>
</Teleport>
</div>
</template>
<script setup lang="ts">
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 SettingsSection from '@/components/settings/SettingsSection.vue'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
@@ -197,7 +192,14 @@ const isDocker = ref(false)
const loaded = ref(false)
const plexOpen = ref(false)
const nfoConfirmOpen = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
const jellyfinConfigured = ref(false)
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.' },
] as const
function onNfoToggle(val: boolean) {
if (val && !form.value.nfoSupport) {
@@ -229,6 +231,13 @@ 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)
return (Object.keys(form.value) as (keyof typeof form.value)[]).filter(k => saved[k] !== form.value[k]).length
})
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
@@ -249,7 +258,7 @@ async function save() {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(form.value),
})
if (res.ok) toast('Configuration sauvegardée', 'success')
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
@@ -258,12 +267,11 @@ async function save() {
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
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')
} else {
toast("Erreur lors de l'analyse", 'error')
@@ -276,12 +284,19 @@ async function scan() {
}
onMounted(async () => {
const [settingsRes, systemRes, infoRes] = await Promise.all([
const [settingsRes, systemRes, infoRes, scanRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
fetch('/api/scan', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (settingsRes.ok) {
const s = await settingsRes.json()
for (const k of Object.keys(form.value) as (keyof typeof form.value)[]) if (k in s) (form.value as any)[k] = s[k]
jellyfinConfigured.value = !!s.jellyfinUrl
savedSnapshot.value = JSON.stringify(form.value)
}
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
loaded.value = true
+89 -82
View File
@@ -1,103 +1,105 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<!-- ── Informations du compte ────────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Mon compte</h2>
<p class="text-sm text-muted mt-1">Informations associées à votre compte FanKarr.</p>
</div>
<div class="settings-card flex flex-col gap-3">
<div>
<label class="settings-label mb-1">Nom d'utilisateur</label>
<p class="text-sm text-primary font-mono bg-shell px-3 py-2 rounded-lg border border-border w-fit">
{{ auth.username }}
</p>
</div>
<div>
<label class="settings-label mb-1">Rôle</label>
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="auth.isAdmin ? 'bg-violet-500/20 text-violet-300' : 'bg-blue-500/20 text-blue-300'">
{{ auth.isAdmin ? 'Administrateur' : 'Utilisateur' }}
<!-- ── Compte ─────────────────────────────────────────────── -->
<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() }}
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[5px]">
<div class="flex items-center gap-2.5 flex-wrap">
<span class="text-[17px] font-bold text-primary">{{ auth.username }}</span>
<span class="pill" :class="auth.isAdmin ? 'pill-wait' : 'pill-neutral'">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}</span>
</div>
<span v-if="me" class="text-meta text-muted">
<template v-if="me.createdAt">Compte créé le {{ formatDate(me.createdAt) }}</template>
<template v-if="me.createdAt && me.lastLoginAt"> · </template>
<template v-if="me.lastLoginAt">dernière connexion {{ formatRelative(me.lastLoginAt) }}</template>
</span>
</div>
</div>
</section>
</SettingsSection>
<!-- ── Changer de mot de passe ───────────────────────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Changer le mot de passe</h2>
<p class="text-sm text-muted mt-1">Votre nouveau mot de passe doit faire au moins 6 caractères.</p>
</div>
<div class="settings-card flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Mot de passe actuel</label>
<input v-model="currentPassword" type="password" class="settings-input" placeholder="••••••••" />
<!-- ── 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]">
<label for="pwd-current" class="field-label">Mot de passe actuel</label>
<input id="pwd-current" v-model="currentPassword" type="password" class="field" placeholder="••••••••" autocomplete="current-password" />
</div>
<div>
<label class="settings-label mb-1.5">Nouveau mot de passe</label>
<input v-model="newPassword" type="password" class="settings-input" placeholder="••••••••" />
<div class="flex flex-col gap-[7px]">
<label for="pwd-new" class="field-label">Nouveau mot de passe</label>
<input id="pwd-new" v-model="newPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" />
</div>
<div>
<label class="settings-label mb-1.5">Confirmer le nouveau mot de passe</label>
<input v-model="confirmPassword" type="password" class="settings-input" placeholder="••••••••" />
</div>
<div class="flex items-center gap-3 flex-wrap">
<button @click="changePassword" :disabled="changingPwd" class="btn-primary">
{{ changingPwd ? 'Modification…' : 'Modifier le mot de passe' }}
</button>
<span v-if="pwdSuccess" class="text-xs text-green-400">Mot de passe modifié.</span>
<span v-if="pwdError" class="text-xs text-red-400">{{ pwdError }}</span>
<div class="flex flex-col gap-[7px]">
<label for="pwd-confirm" class="field-label">Confirmer le nouveau mot de passe</label>
<input id="pwd-confirm" v-model="confirmPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" @keyup.enter="changePassword" />
</div>
</div>
</section>
<!-- ── Token API personnel ──────────────────────────────── -->
<section data-tour="profile-token" class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
<p class="text-sm text-muted mt-1">
Utilisez ce token pour accéder à l'API FanKarr depuis des applications tierces
(ex : plugin Jellyfin).
</p>
</div>
<div class="settings-card flex flex-col gap-3">
<div class="flex items-center gap-2">
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
</code>
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
{{ showToken ? 'Masquer' : 'Afficher' }}
</button>
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
{{ tokenCopied ? 'Copié !' : 'Copier' }}
</button>
</div>
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
Regénérer le token
<div class="flex items-center gap-3.5 flex-wrap">
<button @click="changePassword" :disabled="changingPwd" class="btn-secondary pointer-fine:h-[38px]">
{{ changingPwd ? 'Modification…' : 'Changer le mot de passe' }}
</button>
<p class="text-xs text-muted">
Authentification via header :
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">Authorization: Bearer &lt;token&gt;</code>
</p>
<span v-if="pwdSuccess" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Mot de passe modifié.</span>
<span v-else-if="pwdError" class="text-meta text-err">{{ pwdError }}</span>
<span v-else class="text-meta text-muted">Les autres sessions ouvertes resteront connectées.</span>
</div>
</section>
</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.">
<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.">
<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 }}
</code>
<button @click="showToken = !showToken" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :aria-label="showToken ? 'Masquer le jeton' : 'Afficher le jeton'" :title="showToken ? 'Masquer' : 'Afficher'">
<EyeOff v-if="showToken" :size="15" />
<Eye v-else :size="15" />
</button>
<button @click="copyToken" class="btn-secondary pointer-fine:h-[38px]">
<Check v-if="tokenCopied" :size="15" class="text-ok" />
<Copy v-else :size="15" />
{{ tokenCopied ? 'Copié' : 'Copier' }}
</button>
<button @click="regenerateToken" class="btn-secondary pointer-fine:h-[38px]">
<RefreshCw :size="15" /> Régénérer
</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>.
</p>
</SettingsSection>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { Check, Copy, Eye, EyeOff, RefreshCw } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { copyToClipboard } from '@/composables/useClipboard'
import { formatRelative } from '@/utils/format'
import ThemePicker from '@/components/settings/ThemePicker.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const auth = useAuthStore()
const me = ref<{ createdAt?: string; lastLoginAt?: string | null } | null>(null)
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
// ── Mot de passe ──────────────────────────────────────────────
const currentPassword = ref('')
@@ -141,20 +143,25 @@ async function changePassword() {
changingPwd.value = false
}
// ── Token API ─────────────────────────────────────────────────
// ── Jeton d'API 2───────────────────────────────────────────────
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
async function loadMyToken() {
const maskedToken = computed(() => myToken.value
? `${myToken.value.slice(0, 4)}${'•'.repeat(24)}${myToken.value.slice(-4)}`
: '•'.repeat(32))
async function loadMe() {
const res = await fetch('/api/auth/me', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
myToken.value = d.apiToken ?? ''
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt }
}
}
onMounted(loadMyToken)
onMounted(loadMe)
async function copyToken() {
try {
@@ -165,7 +172,7 @@ async function copyToken() {
}
async function regenerateToken() {
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
if (!confirm('Régénérer le jeton ? L\'ancien sera immédiatement invalidé.')) return
const res = await fetch('/api/auth/regenerate-token', {
method: 'POST', credentials: 'include',
})
+72 -17
View File
@@ -1,27 +1,82 @@
<template>
<div>
<div class="px-8 py-8 max-w-4xl">
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
<RouterView />
<div class="flex flex-col md:flex-row min-h-full">
<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
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>
<div v-for="group in groups" :key="group.label" class="flex md:flex-col gap-1 md:gap-0.5 shrink-0">
<span class="hidden md:block tag-label px-2.5 pb-2">{{ group.label }}</span>
<RouterLink
v-for="link in group.links"
:key="link.to"
:to="link.to"
class="flex items-center h-9 pointer-coarse:h-10 px-2.5 rounded-lg text-body whitespace-nowrap transition-colors"
:class="route.path === link.to ? 'bg-hover text-primary font-semibold' : 'text-secondary hover:text-primary hover:bg-hover/60'"
>
{{ link.label }}
</RouterLink>
</div>
</nav>
<div class="flex-1 min-w-0 px-4 md:px-10 pt-[26px] pb-16">
<div class="max-w-[960px] flex flex-col gap-5">
<header class="flex items-end justify-between gap-5 flex-wrap">
<div class="flex flex-col gap-1 min-w-0">
<h2 class="font-display text-[26px] font-bold text-primary leading-tight">{{ page.title }}</h2>
<p v-if="page.description" class="text-body text-muted">{{ page.description }}</p>
</div>
<div id="settings-actions" class="flex items-center gap-2.5 empty:hidden" />
</header>
<RouterView />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ref, computed, watch, nextTick } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const route = useRoute()
const auth = useAuthStore()
const navRef = ref<HTMLElement | null>(null)
const titles: Record<string, string> = {
'settings-profile' : 'Mon profil',
'settings-download' : 'Clients de téléchargement',
'settings-media' : 'Gestion des médias',
'settings-catalogue' : 'Catalogue Fankai',
'settings-logs' : 'Journaux',
'settings-users' : 'Utilisateurs & Invitations',
'settings-jellyfin' : 'Jellyfin & API',
watch(() => route.path, async () => {
await nextTick()
const nav = navRef.value
if (!nav || nav.scrollWidth <= nav.clientWidth) return
nav.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({ inline: 'center', block: 'nearest' })
}, { 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.' },
}
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
</script>
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
const groups = computed(() => {
const link = (to: string) => ({ to, label: pages[to]!.title })
if (!auth.isAdmin) return [{ label: 'Compte', links: [link('/settings/profile')] }]
return [
{ label: 'Compte', links: [link('/settings/profile'), link('/settings/users')] },
{ label: 'Médias', links: [link('/settings/media-management'), link('/settings/import-management'), link('/settings/catalogue')] },
{ label: 'Services', links: [link('/settings/download-client'), link('/settings/jellyfin')] },
{ label: 'Système', links: [link('/settings/logs'), link('/settings/advanced')] },
]
})
</script>
+245 -186
View File
@@ -1,224 +1,226 @@
<template>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<!-- ── Utilisateurs ─────────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Utilisateurs</h2>
<p class="text-sm text-muted mt-1">Gérez les comptes ayant accès à FanKarr.</p>
</div>
<button @click="openCreateUser" class="btn-secondary">+ Créer</button>
</div>
<Teleport defer to="#settings-actions">
<button @click="openCreateInvite" class="btn-primary pointer-fine:h-[38px]">
<Plus :size="15" :stroke-width="2.25" /> Créer une invitation
</button>
</Teleport>
<div v-if="users.length === 0" class="settings-card text-sm text-muted">
Aucun utilisateur.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="u in users" :key="u.id"
class="settings-card flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>
{{ u.username.charAt(0).toUpperCase() }}
</span>
<div>
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
<p class="text-xs text-muted mt-0.5">
{{ u.role === 'admin' ? 'Administrateur' : 'Utilisateur' }}
· créé le {{ formatDate(u.createdAt) }}
</p>
</div>
</div>
<div class="flex items-center gap-1">
<button @click="openEditUser(u)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button
@click="confirmDelete(u)"
:disabled="u.id === currentUserId"
class="btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
>Supprimer</button>
</div>
<!-- ── 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>
<div class="flex items-center gap-3 flex-wrap">
<span class="text-meta text-muted hidden sm:inline">Le jeton sert à l'API publique et aux applications tierces.</span>
<button @click="openCreateUser" class="btn-secondary btn-sm"><UserPlus :size="14" /> Créer un compte</button>
</div>
</div>
</section>
<!-- ── Invitations ──────────────────────────────────────── -->
<section class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-primary">Invitations</h2>
<p class="text-sm text-muted mt-1">Générez des liens pour permettre à de nouveaux utilisateurs de s'inscrire.</p>
</div>
<button @click="openCreateInvite" class="btn-secondary">+ Générer</button>
<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 />
</div>
<div v-if="invites.length === 0" class="settings-card text-sm text-muted">
Aucun lien d'invitation actif.
</div>
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="inv in invites" :key="inv.code"
class="settings-card flex items-center justify-between gap-4"
:class="{ 'opacity-50': isExpired(inv) || isExhausted(inv) }"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span v-if="isExpired(inv) || isExhausted(inv)" class="text-xs text-muted">[Invalide]</span>
<span v-if="inv.note" class="text-sm text-primary font-medium truncate">{{ inv.note }}</span>
<span v-else class="text-sm text-muted italic">Sans note</span>
</div>
<p class="text-xs text-muted">
{{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisations
<template v-if="inv.expiresAt"> · expire {{ formatDate(inv.expiresAt) }}</template>
<template v-else> · sans expiration</template>
</p>
<div class="flex items-center gap-2 mt-1.5">
<code class="text-xs text-muted bg-shell px-2 py-0.5 rounded truncate max-w-xs">
{{ inviteUrl(inv.code) }}
</code>
<button @click="copyInvite(inv.code)" class="btn-ghost text-xs shrink-0">
{{ copied === inv.code ? 'Copié !' : 'Copier' }}
</button>
</div>
<div
v-for="u in users" :key="u.id"
class="grid grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center gap-x-3 gap-y-2 px-5 py-3 md:py-0 md:min-h-[58px] border-b border-hover last:border-b-0"
>
<div class="flex items-center gap-3 min-w-0">
<span class="w-8 h-8 rounded-full bg-hover flex items-center justify-center font-display text-[15px] font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">
{{ u.username.charAt(0).toUpperCase() }}
</span>
<div class="flex flex-col gap-px min-w-0">
<span class="text-sm font-semibold text-primary truncate">{{ u.username }}</span>
<span class="text-xs text-muted truncate">{{ userSubtitle(u) }}</span>
</div>
<button @click="deleteInvite(inv.code)" class="btn-ghost text-xs text-red-400 hover:text-red-300 shrink-0">
Révoquer
</div>
<span class="pill w-fit order-3 md:order-none" :class="u.role === 'admin' ? 'pill-wait' : 'pill-neutral'">
{{ 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>
<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>
<button @click="copyToken(u)" class="text-meta text-secondary hover:text-primary transition-colors shrink-0">
{{ copied === `token-${u.id}` ? 'Copié' : 'Copier' }}
</button>
</div>
</div>
</section>
<!-- ── Téléchargement automatique des demandes ──────────── -->
<section class="flex flex-col gap-4">
<div>
<h2 class="text-base font-semibold text-primary">Téléchargement automatique</h2>
<p class="text-sm text-muted mt-1">
Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés.
</p>
</div>
<div class="settings-card flex flex-col gap-4">
<!-- Tous les utilisateurs -->
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
:checked="autoDownloadAll"
@change="toggleAutoDownloadAll"
class="w-4 h-4 rounded border-border accent-accent"
/>
<div>
<p class="text-sm text-primary font-medium">Tous les utilisateurs</p>
<p class="text-xs text-muted">Chaque approbation déclenche un téléchargement, quel que soit le demandeur.</p>
</div>
</label>
<!-- Séparateur -->
<div v-if="!autoDownloadAll && users.length > 0" class="border-t border-border/50" />
<!-- Par utilisateur -->
<template v-if="!autoDownloadAll">
<label
v-for="u in users" :key="u.id"
class="flex items-center gap-3 cursor-pointer"
<div class="relative justify-self-end order-2 md:order-none" @click.stop>
<button
@click="userMenu = userMenu === u.id ? null : u.id"
class="w-[30px] h-[30px] rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
:aria-label="`Actions pour ${u.username}`" aria-haspopup="menu" :aria-expanded="userMenu === u.id"
>
<input
type="checkbox"
:checked="autoDownloadUserIds.includes(u.id)"
@change="toggleAutoDownloadUser(u.id)"
class="w-4 h-4 rounded border-border accent-accent"
/>
<div class="flex items-center gap-2">
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>{{ u.username.charAt(0).toUpperCase() }}</span>
<span class="text-sm text-primary">{{ u.username }}</span>
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
</div>
</label>
<p v-if="users.length === 0" class="text-xs text-muted">Aucun utilisateur.</p>
</template>
<p v-if="autoDownloadSaved" class="text-xs text-green-400">Paramètre sauvegardé.</p>
<Ellipsis :size="16" />
</button>
<div v-if="userMenu === u.id" class="menu absolute right-0 top-full mt-1.5 w-48 z-20" role="menu">
<button role="menuitem" class="menu-item" @click="userMenu = null; openEditUser(u)"><Pencil :size="14" /> Modifier</button>
<button
role="menuitem"
class="menu-item text-err hover:text-err"
:disabled="u.id === currentUserId"
:title="u.id === currentUserId ? 'Vous ne pouvez pas supprimer votre propre compte' : undefined"
@click="userMenu = null; confirmDelete(u)"
><Trash2 :size="14" /> Supprimer</button>
</div>
</div>
</div>
</section>
<!-- ── Modal créer/modifier utilisateur ─────────────────── -->
<!-- ── 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>
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
</div>
<p v-if="invites.length === 0" class="text-body text-muted px-5 py-5">Aucun lien d'invitation. Créez-en un pour inviter quelqu'un.</p>
<div
v-for="inv in invites" :key="inv.code"
class="flex items-center gap-4 flex-wrap sm:flex-nowrap px-5 py-3.5 border-b border-hover last:border-b-0"
>
<span class="w-8 h-8 rounded-full border border-dashed flex items-center justify-center shrink-0 text-muted" :class="isInvalid(inv) ? 'border-border-light opacity-60' : 'border-border'">
<Link2 :size="15" />
</span>
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-body truncate" :class="isInvalid(inv) ? 'text-muted' : 'text-primary'">
<template v-if="inv.note">{{ inv.note }} · </template>{{ inviteUrl(inv.code).replace(/^https?:\/\//, '') }}
</span>
<span class="text-meta text-muted">
<template v-if="isExhausted(inv)">utilisations épuisées ({{ inv.uses }}/{{ inv.maxUses }})</template>
<template v-else-if="isExpired(inv)">expirée {{ formatRelative(inv.expiresAt!) }}</template>
<template v-else>
créée {{ formatRelative(inv.createdAt) }} · {{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisation{{ (inv.maxUses ?? 2) > 1 ? 's' : '' }}
· {{ inv.expiresAt ? `expire ${formatFuture(inv.expiresAt)}` : 'sans expiration' }}
</template>
</span>
</div>
<button v-if="!isInvalid(inv)" @click="copyInvite(inv.code)" class="btn-secondary btn-sm">
<Check v-if="copied === inv.code" :size="14" class="text-ok" />
<Copy v-else :size="14" />
{{ copied === inv.code ? 'Lien copié' : 'Copier le lien' }}
</button>
<button
@click="deleteInvite(inv.code)"
class="w-8 h-8 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
title="Révoquer le lien" aria-label="Révoquer le lien"
>
<Trash2 :size="14" />
</button>
</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."
>
<template #actions>
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
</template>
<SettingsToggle
: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."
/>
<template v-if="!autoDownloadAll">
<div class="h-px bg-hover" />
<div v-for="u in users" :key="u.id" class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2.5 min-w-0">
<span class="w-6 h-6 rounded-full bg-hover flex items-center justify-center font-display text-xs font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">{{ u.username.charAt(0).toUpperCase() }}</span>
<span class="text-body text-primary truncate">{{ u.username }}</span>
<span class="text-meta text-muted">{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}</span>
</div>
<button
type="button" role="switch"
:aria-checked="autoDownloadUserIds.includes(u.id)"
:aria-label="`Téléchargement automatique pour ${u.username}`"
@click="toggleAutoDownloadUser(u.id)"
class="switch"
><span class="switch-knob" /></button>
</div>
<p v-if="users.length === 0" class="text-meta text-muted">Aucun utilisateur.</p>
</template>
</SettingsSection>
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
<Teleport to="body">
<div v-if="userModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="userModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
<h3 class="text-base font-semibold text-primary">
{{ userModal.mode === 'create' ? 'Créer un utilisateur' : 'Modifier l\'utilisateur' }}
<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">
<h3 id="user-modal-title" class="card-title">
{{ userModal.mode === 'create' ? 'Créer un compte' : 'Modifier le compte' }}
</h3>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Identifiant</label>
<input v-model="userModal.username" type="text" class="settings-input" placeholder="username" />
<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" />
</div>
<div>
<label class="settings-label mb-1.5">
{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}
</label>
<input v-model="userModal.password" type="password" class="settings-input"
<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' : '••••••••'" />
</div>
<div>
<label class="settings-label mb-1.5">Rôle</label>
<select v-model="userModal.role" class="settings-input">
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
<div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span>
<div class="segmented w-fit">
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'user' }" @click="userModal.role = 'user'">Invité</button>
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'admin' }" @click="userModal.role = 'admin'">Administrateur</button>
</div>
</div>
</div>
<p v-if="userModal.error" class="text-xs text-red-400">{{ userModal.error }}</p>
<p v-if="userModal.error" class="text-meta text-err">{{ userModal.error }}</p>
<div class="flex gap-2 justify-end">
<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éer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Modal créer invitation ────────────────────────────── -->
<!-- ── Modal créer invitation ─────────────────────────────── -->
<Teleport to="body">
<div v-if="inviteModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="inviteModal = null">
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
<h3 class="text-base font-semibold text-primary">Générer une invitation</h3>
<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">
<h3 id="invite-modal-title" class="card-title">Créer une invitation</h3>
<div class="flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">Note (optionnelle)</label>
<input v-model="inviteModal.note" type="text" class="settings-input" placeholder="Pour qui est ce lien ?" />
<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 ?" />
</div>
<div>
<label class="settings-label mb-1.5">Utilisations max</label>
<input v-model.number="inviteModal.maxUses" type="number" min="1" class="settings-input" placeholder="1" />
<p class="text-xs text-muted mt-1">Laisser vide pour illimité</p>
</div>
<div>
<label class="settings-label mb-1.5">Expire dans (heures)</label>
<input v-model.number="inviteModal.expiresInHours" type="number" min="1" class="settings-input" placeholder="24" />
<p class="text-xs text-muted mt-1">Laisser vide pour pas d'expiration</p>
<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>
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="invite-hours" class="field-label">Expire dans (heures)</label>
<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>
</div>
<p v-if="inviteModal.error" class="text-xs text-red-400">{{ inviteModal.error }}</p>
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
<div class="flex gap-2 justify-end">
<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 ? '...' : 'Générer' }}
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
</button>
</div>
</div>
@@ -229,16 +231,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Check, Copy, Ellipsis, Link2, Pencil, Plus, Trash2, UserPlus } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth'
import { copyToClipboard } from '@/composables/useClipboard'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
interface User {
id : string
username : string
role : 'admin' | 'user'
apiToken : string
createdAt: string
id : string
username : string
role : 'admin' | 'user'
apiToken : string
createdAt : string
lastLoginAt?: string | null
}
interface Invite {
@@ -252,11 +259,13 @@ interface Invite {
}
const auth = useAuthStore()
const currentUserId = auth.username // utilisé pour désactiver la suppression de soi-même
const currentUserId = computed(() => auth.userId) // pas de suppression de son propre compte
const users = ref<User[]>([])
const invites = ref<Invite[]>([])
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 ──────────────────────────
const autoDownloadAll = ref(false)
@@ -294,6 +303,16 @@ async function loadInvites() {
if (res.ok) invites.value = await res.json()
}
async function loadPendingRequests() {
const res = await fetch('/api/requests', { credentials: 'include' })
if (!res.ok) return
const counts = new Map<string, number>()
for (const r of await res.json())
if (r.status === 'pending')
for (const q of r.requesters) counts.set(q.userId, (counts.get(q.userId) ?? 0) + 1)
pendingByUser.value = counts
}
async function loadAutoDownloadSetting() {
const res = await fetch('/api/settings', { credentials: 'include' })
if (!res.ok) return
@@ -321,8 +340,8 @@ async function saveAutoDownloadSetting() {
autoDownloadTimer = setTimeout(() => { autoDownloadSaved.value = false }, 2000)
}
function toggleAutoDownloadAll(e: Event) {
autoDownloadAll.value = (e.target as HTMLInputElement).checked
function setAutoDownloadAll(value: boolean) {
autoDownloadAll.value = value
if (autoDownloadAll.value) autoDownloadUserIds.value = []
saveAutoDownloadSetting()
}
@@ -334,7 +353,13 @@ function toggleAutoDownloadUser(userId: string) {
saveAutoDownloadSetting()
}
onMounted(() => { loadUsers(); loadInvites(); loadAutoDownloadSetting() })
const closeUserMenu = () => { userMenu.value = null }
onMounted(() => {
loadUsers(); loadInvites(); loadAutoDownloadSetting(); loadPendingRequests()
document.addEventListener('click', closeUserMenu)
})
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Users ─────────────────────────────────────────────────────
function openCreateUser() {
@@ -373,7 +398,30 @@ async function confirmDelete(u: User) {
loadUsers()
}
function userSubtitle(u: User) {
const pending = pendingByUser.value.get(u.id) ?? 0
const parts: string[] = []
if (u.id === currentUserId.value) parts.push('vous')
if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`)
if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`)
return parts.join(' · ')
}
function maskToken(token: string) {
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
}
async function copyToken(u: User) {
try {
await copyToClipboard(u.apiToken)
copied.value = `token-${u.id}`
setTimeout(() => { copied.value = null }, 2000)
} catch {}
}
// ── Invites ───────────────────────────────────────────────────
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
function openCreateInvite() {
inviteModal.value = { note: '', maxUses: 1, expiresInHours: 24, error: null, loading: false }
}
@@ -416,7 +464,14 @@ async function copyInvite(code: string) {
// ── Helpers ───────────────────────────────────────────────────
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
// « dans 5 j », « dans 3 h »
function formatFuture(iso: string) {
const diff = new Date(iso).getTime() - Date.now()
const h = Math.round(diff / 3_600_000)
return h < 24 ? `dans ${Math.max(1, h)} h` : `dans ${Math.round(h / 24)} j`
}
function isExpired(inv: Invite) {
@@ -426,4 +481,8 @@ function isExpired(inv: Invite) {
function isExhausted(inv: Invite) {
return inv.maxUses !== null && inv.uses >= inv.maxUses
}
function isInvalid(inv: Invite) {
return isExpired(inv) || isExhausted(inv)
}
</script>