refacto: réécriture des textes, messages et commentaires
This commit is contained in:
1 parent
6a3cb21adf
commit
586eccbbb3
118 files changed
+1621
-2159
No files matched your search
+3
-4
@@ -48,7 +48,7 @@ export interface Torrent {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Parse "Nom@url|Nom@url" → Torrent[]
|
||||
// "Nom@url|Nom@url" en liste de torrents
|
||||
export function parseTorrents(raw: string | null): Torrent[] {
|
||||
if (!raw) return []
|
||||
return raw.split('|').flatMap(item => {
|
||||
@@ -58,7 +58,6 @@ export function parseTorrents(raw: string | null): Torrent[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Préfixe les images relatives
|
||||
export function imageUrl(path: string | null): string | null {
|
||||
if (!path) return null
|
||||
const encoded = encodeURIComponent(`https://api.fankai.fr${path}`)
|
||||
@@ -67,14 +66,14 @@ export function imageUrl(path: string | null): string | null {
|
||||
|
||||
export async function fetchProductions(): Promise<Production[]> {
|
||||
const res = await fetch(`${BASE}/productions`)
|
||||
if (!res.ok) throw new Error('Erreur chargement catalogue')
|
||||
if (!res.ok) throw new Error('Impossible de charger le catalogue.')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
|
||||
export async function fetchProduction(id: number, name: string): Promise<ProductionDetail> {
|
||||
const res = await fetch(`${BASE}/productions/${id}?name=${encodeURIComponent(name)}`)
|
||||
if (!res.ok) throw new Error('Erreur chargement production')
|
||||
if (!res.ok) throw new Error('Impossible de charger la série.')
|
||||
const data = await res.json()
|
||||
return data.pageProps.data
|
||||
}
|
||||
+3
-3
@@ -167,7 +167,7 @@ body {
|
||||
.pill-err { @apply border-err/35 text-err; }
|
||||
.pill-muted { @apply border-border-light text-muted; }
|
||||
|
||||
/* Filtres et onglets compteurs */
|
||||
/* Filtres et onglets à compteur */
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
|
||||
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
|
||||
@@ -210,7 +210,7 @@ body {
|
||||
@apply translate-x-5 bg-on-accent;
|
||||
}
|
||||
|
||||
/* Sélecteur */
|
||||
/* Sélecteur segmenté */
|
||||
.segmented {
|
||||
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
|
||||
}
|
||||
@@ -236,7 +236,7 @@ body {
|
||||
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
|
||||
}
|
||||
|
||||
/* Menus déroulants et pop */
|
||||
/* Menus déroulants et modales */
|
||||
.menu {
|
||||
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="modal-backdrop" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0">
|
||||
<p class="card-title">Choisir un dossier</p>
|
||||
@@ -12,7 +11,6 @@
|
||||
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<House :size="15" />
|
||||
@@ -25,22 +23,20 @@
|
||||
aria-label="Chemin"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
|
||||
OK
|
||||
Aller
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Retour parent -->
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button
|
||||
@click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
|
||||
>
|
||||
<ArrowLeft :size="14" />
|
||||
..
|
||||
Dossier parent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
@@ -59,10 +55,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
|
||||
<span class="text-meta text-muted truncate">{{ current }}</span>
|
||||
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
|
||||
<button @click="selectCurrent()" class="btn-primary">
|
||||
Choisir
|
||||
</button>
|
||||
@@ -93,7 +87,7 @@ function selectCurrent() {
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
|
||||
// Lecteur Windows (ex. "C:\") : déjà absolu
|
||||
if (/^[A-Za-z]:\\$/.test(name)) return name
|
||||
if (base === '/') return `/${name}`
|
||||
const cleanBase = base.replace(/[\\/]+$/, '')
|
||||
@@ -107,7 +101,7 @@ async function navigateTo(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
error.value = (await res.json()).error ?? 'Erreur'
|
||||
error.value = (await res.json()).error ?? "Impossible d'ouvrir ce dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -117,7 +111,7 @@ async function navigateTo(p: string) {
|
||||
dirs.value = data.dirs
|
||||
drivesRoot.value = data.drivesRoot ?? false
|
||||
} catch {
|
||||
error.value = 'Impossible de lire ce dossier'
|
||||
error.value = 'Impossible de lire ce dossier.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -3,47 +3,42 @@
|
||||
<div class="modal-backdrop" @click.self="$emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">— {{ serieName }}</span></h3>
|
||||
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">· {{ serieName }}</span></h3>
|
||||
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
|
||||
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-body">Scan du dossier…</p>
|
||||
<p class="text-body">Analyse du dossier…</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-meta text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Rescanner</button>
|
||||
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</button>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-body text-err">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des fichiers -->
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
|
||||
<p class="text-meta text-muted">
|
||||
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-accent">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · {{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span>
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
|
||||
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
|
||||
</p>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Rescanner</button>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
@@ -80,7 +75,7 @@
|
||||
@change="onEpisodeChange(i)"
|
||||
:aria-label="`Épisode associé à ${item.file.name}`"
|
||||
>
|
||||
<option :value="null">— Non associé —</option>
|
||||
<option :value="null">Aucun épisode</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
<option
|
||||
v-for="ep in season.episodes"
|
||||
@@ -88,8 +83,8 @@
|
||||
:value="ep.id"
|
||||
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
|
||||
>
|
||||
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
|
||||
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
@@ -108,14 +103,13 @@
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
|
||||
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
|
||||
<span
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
@@ -127,16 +121,16 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
|
||||
<p class="text-body text-secondary">
|
||||
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
|
||||
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
|
||||
<span v-if="importResult.errors.length > 0" class="text-err">
|
||||
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
|
||||
· {{ plural(importResult.errors.length, 'erreur') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
|
||||
<p class="tag-label text-err mb-1.5">Erreurs</p>
|
||||
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
|
||||
<span class="text-primary">{{ e.file }}</span> — {{ e.error }}
|
||||
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
@@ -155,6 +149,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { matchEpisodeFile } from '@/utils/episode-match'
|
||||
import { formatSize, plural } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -171,7 +166,7 @@ const emit = defineEmits<{
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
// ── État ──
|
||||
const step = ref<'list' | 'done'>('list')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
@@ -189,7 +184,7 @@ const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
originalEpisodeId : number | null // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
|
||||
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
|
||||
hash : string | null
|
||||
alreadyImported : boolean
|
||||
willRename : boolean
|
||||
@@ -197,12 +192,12 @@ interface FileItem {
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
// ── Calculs ──
|
||||
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
|
||||
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
// ── Utilitaires ──
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
return !!props.organized?.[String(epId)]
|
||||
}
|
||||
@@ -236,13 +231,6 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
const item = items.value[i]
|
||||
if (!item) return
|
||||
@@ -252,8 +240,7 @@ function onEpisodeChange(i: number) {
|
||||
item.alreadyImported = false
|
||||
item.willRename = newEpId !== null
|
||||
|
||||
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
|
||||
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
|
||||
// Le fichier choisi remplace ceux déjà importés pour cet épisode
|
||||
if (newEpId !== null) {
|
||||
items.value = items.value.filter(other =>
|
||||
other === item || !(other.episode_id === newEpId && other.alreadyImported)
|
||||
@@ -261,7 +248,7 @@ function onEpisodeChange(i: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
@@ -271,7 +258,7 @@ async function scan() {
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
scanError.value = data.error ?? 'Erreur lors du scan'
|
||||
scanError.value = data.error ?? "Impossible d'analyser le dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -281,7 +268,6 @@ async function scan() {
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
items.value = files.map(f => {
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
@@ -291,7 +277,7 @@ async function scan() {
|
||||
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
} catch {
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
scanError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
@@ -306,7 +292,7 @@ async function doImport() {
|
||||
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
|
||||
|
||||
if (payload.length === 0) {
|
||||
importError.value = 'Aucun nouveau fichier à importer'
|
||||
importError.value = 'Aucun nouveau fichier à importer.'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
@@ -320,26 +306,26 @@ async function doImport() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
importError.value = data.error ?? 'Erreur inconnue'
|
||||
toast(data.error ?? 'Erreur lors de l\'import', 'error')
|
||||
importError.value = data.error ?? "Impossible d'importer les fichiers."
|
||||
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
|
||||
return
|
||||
}
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
|
||||
if (data.done > 0) {
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
|
||||
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
|
||||
}
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
|
||||
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
|
||||
}
|
||||
|
||||
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
|
||||
// Le parent recharge les épisodes importés
|
||||
emit('imported')
|
||||
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
importError.value = 'Impossible de contacter le serveur'
|
||||
importError.value = 'Impossible de contacter le serveur.'
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
@@ -348,7 +334,7 @@ async function doImport() {
|
||||
|
||||
function backToList() {
|
||||
step.value = 'list'
|
||||
scan() // re-scan → les fichiers importés passent en "déjà importé"
|
||||
scan() // nouvelle analyse pour marquer les fichiers importés
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
|
||||
|
||||
<!-- Nbre de messages regroupés -->
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 pill h-5 px-2 text-[10.5px]"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
|
||||
<!-- Onglets à compteurs -->
|
||||
<div class="chip-row">
|
||||
<button
|
||||
v-for="t in tabs" :key="t.value"
|
||||
@@ -32,7 +31,7 @@
|
||||
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
|
||||
/>
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer le filtre" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
</label>
|
||||
@@ -46,7 +45,6 @@
|
||||
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
|
||||
</label>
|
||||
|
||||
<!-- Tri, colonnes, seeds -->
|
||||
<div ref="optionsRef" class="relative">
|
||||
<button
|
||||
@click="optionsOpen = !optionsOpen"
|
||||
@@ -79,7 +77,7 @@
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<label class="menu-item cursor-pointer justify-between">
|
||||
Seeds uniquement
|
||||
Masquer les états inconnus
|
||||
<button
|
||||
type="button" role="switch" :aria-checked="seedingOnly"
|
||||
@click="emit('update:seedingOnly', !seedingOnly)"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
|
||||
|
||||
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||
<!-- Affiche -->
|
||||
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
|
||||
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
|
||||
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
|
||||
@@ -12,17 +11,15 @@
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
|
||||
<!-- Nom, état, client -->
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="relative group/eps min-w-0">
|
||||
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
|
||||
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
|
||||
</p>
|
||||
<!-- Épisodes du torrent -->
|
||||
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||
<div class="menu p-3 gap-0">
|
||||
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
|
||||
<p class="tag-label mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
|
||||
<p class="tag-label mb-2">{{ plural(episodesWithProgress.length, 'épisode') }}</p>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
|
||||
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
@@ -31,7 +28,7 @@
|
||||
class="pill h-5 px-2 text-[10.5px]"
|
||||
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
|
||||
>
|
||||
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
|
||||
{{ ep.priority === 0 ? 'Exclu' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,20 +40,18 @@
|
||||
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }} %</span>
|
||||
</div>
|
||||
|
||||
<!-- Progression, vitesse, ETA -->
|
||||
<template v-if="!isDone">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">reste {{ formatDuration(torrent.eta) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0 && formatDuration(torrent.eta) !== '∞'">reste {{ formatDuration(torrent.eta) }}</span>
|
||||
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
|
||||
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Terminé : erreurs d'import ou détails -->
|
||||
<template v-else>
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
|
||||
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
|
||||
@@ -75,20 +70,19 @@
|
||||
<span>
|
||||
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
|
||||
</span>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-accent">État non remonté par le client</span>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-accent">Le client n'indique pas l'état de ce torrent</span>
|
||||
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
|
||||
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
|
||||
<span v-if="columns.uploaded && torrent.uploaded">↑ {{ formatSize(torrent.uploaded) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
|
||||
<span v-if="columns.uploaded && torrent.uploaded">Envoyé {{ formatSize(torrent.uploaded) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">Envoi {{ formatSpeed(torrent.upspeed) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- État d'import et actions -->
|
||||
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
|
||||
<template v-if="!isDone">
|
||||
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import auto à la fin' : 'Import manuel à la fin') : 'Série non reconnue' }}</span>
|
||||
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import automatique à la fin' : 'À importer une fois terminé') : 'Série non reconnue' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
|
||||
@@ -109,30 +103,29 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
|
||||
:title="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
|
||||
:aria-label="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
|
||||
:title="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
|
||||
:aria-label="isDone ? 'Supprimer le torrent' : 'Arrêter le téléchargement'"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
|
||||
<p class="text-body text-primary font-medium">
|
||||
{{ isDone ? 'Supprimer ce torrent ?' : 'Annuler ce téléchargement ?' }}
|
||||
{{ isDone ? 'Supprimer ce torrent du client ?' : 'Arrêter ce téléchargement ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
|
||||
Supprimer aussi les fichiers
|
||||
Supprimer aussi les fichiers du disque
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
|
||||
{{ deleting ? '…' : 'Confirmer' }}
|
||||
{{ deleting ? 'Suppression…' : isDone ? 'Supprimer' : 'Arrêter' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">Annuler</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">{{ isDone ? 'Garder' : 'Continuer' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +138,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
|
||||
import { formatDuration, formatRelative, formatSize, formatSpeed } from '@/utils/format'
|
||||
import { formatDuration, formatRelative, formatSize, formatSpeed, plural } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent : any
|
||||
@@ -172,7 +165,7 @@ const isImported = computed(() => isDone.value && props.torrent.organizeState ==
|
||||
|
||||
const clientLine = computed(() => [
|
||||
props.columns.client ? props.torrent.client_name : null,
|
||||
isDone.value && props.columns.ratio ? `R ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
|
||||
isDone.value && props.columns.ratio ? `Ratio ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
|
||||
].filter(Boolean).join(' · '))
|
||||
|
||||
|
||||
@@ -184,7 +177,6 @@ const errorSummary = computed(() => {
|
||||
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
|
||||
})
|
||||
|
||||
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
|
||||
const episodesWithProgress = computed(() => {
|
||||
const eps: any[] = props.torrent.episodes ?? []
|
||||
if (!eps.length) return []
|
||||
@@ -208,5 +200,5 @@ const badge = computed(() => ({
|
||||
checking : { label: 'Vérification', class: 'pill-wait' },
|
||||
error : { label: 'Erreur', class: 'pill-err' },
|
||||
unknown : { label: 'État inconnu', class: 'pill-wait' },
|
||||
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'Inconnu', class: 'pill-muted' })
|
||||
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'État inconnu', class: 'pill-muted' })
|
||||
</script>
|
||||
@@ -13,7 +13,6 @@
|
||||
<Search :size="19" :stroke-width="1.75" />
|
||||
</button>
|
||||
|
||||
<!-- Menu du compte -->
|
||||
<div ref="menuRef" class="relative">
|
||||
<button
|
||||
data-tour="help"
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Nouvelle version disponible -->
|
||||
<a
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
@@ -51,12 +50,11 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
|
||||
<span class="block text-[11.5px] text-muted truncate">{{ latestVersion }} est disponible</span>
|
||||
<span class="block text-[11.5px] text-muted truncate">Version {{ latestVersion }}</span>
|
||||
</span>
|
||||
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
|
||||
<!-- Utilisateur et déconnexion -->
|
||||
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
|
||||
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
|
||||
{{ initial }}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="reject-message" class="field-label">Motif (facultatif)</label>
|
||||
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Raison du refus…" @keyup.enter="emit('confirm', message)" />
|
||||
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Visible par l'invité" @keyup.enter="emit('confirm', message)" />
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="emit('close')" class="btn-ghost">Annuler</button>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50 pointer-events-none"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
|
||||
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
|
||||
<section class="relative">
|
||||
<!-- overflow-hidden ici et pas sur la section, qui couperait les menus -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
|
||||
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
|
||||
</div>
|
||||
|
||||
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
|
||||
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
|
||||
@@ -39,7 +42,7 @@
|
||||
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
|
||||
>{{ statusLabel(serie.status) }}</span>
|
||||
<span v-if="episodeCount" class="text-body text-secondary">
|
||||
<template v-if="seasonCount">{{ seasonCount }} saison{{ seasonCount > 1 ? 's' : '' }} · </template>{{ episodeCount }} épisode{{ episodeCount > 1 ? 's' : '' }}
|
||||
<template v-if="seasonCount">{{ plural(seasonCount, 'saison') }} · </template>{{ plural(episodeCount, 'épisode') }}
|
||||
</span>
|
||||
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
|
||||
<Rss :size="11" :stroke-width="2.5" /> Surveillée
|
||||
@@ -75,7 +78,7 @@
|
||||
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-meta text-secondary">{{ organizedCount }} épisode{{ organizedCount > 1 ? 's' : '' }} {{ organizedLabel }}{{ organizedCount > 1 ? 's' : '' }} sur {{ episodeCount }}</span>
|
||||
<span class="text-meta text-secondary">{{ plural(organizedCount, `épisode ${organizedLabel}`, `épisodes ${organizedLabel}s`) }} sur {{ episodeCount }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
|
||||
@@ -92,6 +95,7 @@ import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
|
||||
import { statusLabel } from '@/utils/series'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
defineProps<{
|
||||
serie : any
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<section class="bg-card rounded-card">
|
||||
<!-- En-tête de saison -->
|
||||
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
|
||||
<button
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
|
||||
@@ -14,13 +13,13 @@
|
||||
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
|
||||
<h2 class="font-display text-[17px] font-bold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">— {{ season.title }}</span>
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">· {{ season.title }}</span>
|
||||
</h2>
|
||||
<p class="text-meta text-muted">{{ seasonMeta }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<!-- ── Mode demande ──── -->
|
||||
<!-- ── Mode demande ── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
@@ -34,11 +33,11 @@
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
{{ missingCount === season.episodes.length ? 'Demander la saison' : `Demander ${missingCount > 1 ? `les ${missingCount} manquants` : 'le manquant'}` }}
|
||||
{{ missingCount === season.episodes.length ? 'Demander la saison' : missingCount > 1 ? `Demander ${missingCount} épisodes` : "Demander l'épisode manquant" }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ── Mode téléchargement (admin) ──────── -->
|
||||
<!-- ── Mode téléchargement (admin) ── -->
|
||||
<template v-else>
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
@@ -80,7 +79,6 @@
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Pack saison unique -->
|
||||
<button
|
||||
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
|
||||
class="btn-secondary btn-sm"
|
||||
@@ -91,7 +89,6 @@
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Packs saison multiples -->
|
||||
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
|
||||
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
|
||||
<Download :size="14" />
|
||||
@@ -114,13 +111,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Désimport de la saison -->
|
||||
<button
|
||||
v-if="season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Désimporter la saison"
|
||||
aria-label="Désimporter la saison"
|
||||
title="Retirer la saison de la médiathèque"
|
||||
aria-label="Retirer la saison de la médiathèque"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
@@ -128,7 +124,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
@@ -136,7 +131,6 @@
|
||||
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
|
||||
>
|
||||
<div class="flex items-center gap-3 sm:gap-3.5">
|
||||
<!-- Vignette -->
|
||||
<div
|
||||
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
|
||||
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
|
||||
@@ -145,7 +139,6 @@
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
|
||||
@@ -157,13 +150,13 @@
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
title="Ce fichier n'est pas une version officielle Fankai"
|
||||
>Hors Fankai</span>
|
||||
</div>
|
||||
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ── État et actions, mode demande ─────────────────── -->
|
||||
<!-- ── État et actions, mode demande ── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
|
||||
@@ -177,7 +170,7 @@
|
||||
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Choisir le torrent</p>
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Choisir la version</p>
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
@@ -193,7 +186,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── État et actions, admin ────────────────────────── -->
|
||||
<!-- ── État et actions, admin ── -->
|
||||
<template v-else>
|
||||
<template v-if="ep.organized">
|
||||
<button
|
||||
@@ -246,14 +239,13 @@
|
||||
@click.stop="openUnimportModal(ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Désimporter l'épisode"
|
||||
aria-label="Désimporter l'épisode"
|
||||
title="Retirer l'épisode de la médiathèque"
|
||||
aria-label="Retirer l'épisode de la médiathèque"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<button
|
||||
v-if="ep.plot"
|
||||
@click.stop="togglePlot(ep.id)"
|
||||
@@ -266,12 +258,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression de l'épisode -->
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
|
||||
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
|
||||
{{ parseEpChap(ep.plot).cleanPlot }}
|
||||
</p>
|
||||
@@ -279,13 +269,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal désimport épisode -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-ep-title" class="card-title">Désimporter l'épisode</h3>
|
||||
<p class="text-meta text-muted">L'épisode sera retiré de la bibliothèque.</p>
|
||||
<h3 id="unimport-ep-title" class="card-title">Retirer l'épisode de la médiathèque</h3>
|
||||
<p class="text-meta text-muted">L'épisode sera retiré de la médiathèque. Le fichier reste sur le disque, sauf si vous cochez la case.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
|
||||
@@ -294,20 +283,19 @@
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimport" class="btn-danger">
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal désimport saison -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-season-title" class="card-title">Désimporter la saison</h3>
|
||||
<p class="text-meta text-muted">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
|
||||
<h3 id="unimport-season-title" class="card-title">Retirer la saison de la médiathèque</h3>
|
||||
<p class="text-meta text-muted">Les épisodes importés de cette saison seront retirés de la médiathèque. Les fichiers restent sur le disque, sauf si vous cochez la case.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
|
||||
@@ -316,7 +304,7 @@
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="btn-danger">
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +315,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
|
||||
|
||||
@@ -341,9 +330,9 @@ const props = defineProps<{
|
||||
downloadingSeason ?: boolean
|
||||
nfoSupport ?: boolean
|
||||
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
|
||||
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
|
||||
requestStatus ?: string // statut de la demande en cours de cet user
|
||||
requestedSeasons ?: number[] // saisons déjà demandées par l'utilisateur pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par l'utilisateur pour cette série
|
||||
requestStatus ?: string // statut de la demande en cours de l'utilisateur
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -362,7 +351,6 @@ const isSeasonRequested = computed(() =>
|
||||
props.requestedSeasons?.includes(props.season.season_number) ?? false
|
||||
)
|
||||
|
||||
/** Un épisode est "demandé" si sa saison l'est, ou si son ID est dans requestedEpisodes */
|
||||
function isEpisodeRequested(episodeId: number): boolean {
|
||||
return isSeasonRequested.value || (props.requestedEpisodes?.includes(episodeId) ?? false)
|
||||
}
|
||||
@@ -378,7 +366,7 @@ const deleteSeasonFiles = ref(false)
|
||||
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
|
||||
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
|
||||
|
||||
// ── Helpers hash / progress ────────────────────────────────────
|
||||
// ── Hash et progression ──
|
||||
function extractHash(torrent: any): string | null {
|
||||
// Préférer l'infohash direct (présent même si le magnet est absent)
|
||||
if (torrent?.infohash) return torrent.infohash.toLowerCase()
|
||||
@@ -393,16 +381,14 @@ function torrentProgress(hash: string | null | undefined): ActiveTorrent | null
|
||||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||||
// On s'appuie uniquement sur downloading/downloaded pour ces cas.
|
||||
// Fichier d'un pack : le hash ne suffit pas, on se fie à downloading/downloaded
|
||||
if (torrent?.file_index != null) return false
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
// ── État de la saison ──
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
@@ -414,8 +400,8 @@ const canDownloadSeason = computed(() =>
|
||||
)
|
||||
const seasonBtnLabel = computed(() => {
|
||||
if (props.season.organized_state === 'complete') return 'Importée'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajoutée'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà dans le client'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée au client'
|
||||
return 'Télécharger la saison'
|
||||
})
|
||||
const seasonBtnIcon = computed(() => {
|
||||
@@ -425,7 +411,7 @@ const seasonBtnIcon = computed(() => {
|
||||
return h(Download, { size: 14 })
|
||||
})
|
||||
|
||||
// ── Résumés de saison ──────────────────────────────────────────
|
||||
// ── Résumés de saison ──
|
||||
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
|
||||
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
|
||||
const downloadableCount = computed(() =>
|
||||
@@ -433,36 +419,35 @@ const downloadableCount = computed(() =>
|
||||
)
|
||||
const downloadableLabel = computed(() => {
|
||||
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
|
||||
return downloadableCount.value > 1 ? `les ${downloadableCount.value} manquants` : 'le manquant'
|
||||
return downloadableCount.value > 1 ? `${downloadableCount.value} épisodes` : "l'épisode"
|
||||
})
|
||||
|
||||
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
|
||||
const seasonMeta = computed(() => {
|
||||
const total = props.season.episodes.length
|
||||
const parts = [`${total} épisode${total > 1 ? 's' : ''}`]
|
||||
const parts = [plural(total, 'épisode')]
|
||||
if (props.requestMode) {
|
||||
const ready = props.season.organized_count ?? 0
|
||||
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : `${ready} disponible${ready > 1 ? 's' : ''}`)
|
||||
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : plural(ready, 'disponible'))
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (availableCount.value > 0) parts.push(`${availableCount.value} disponible${availableCount.value > 1 ? 's' : ''}`)
|
||||
if (props.season.organized_count > 0) parts.push(`${props.season.organized_count} importé${props.season.organized_count > 1 ? 's' : ''}`)
|
||||
if (availableCount.value > 0) parts.push(plural(availableCount.value, 'disponible'))
|
||||
if (props.season.organized_count > 0) parts.push(plural(props.season.organized_count, 'importé'))
|
||||
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
// Date · durée · langue · sources · plages d'épisodes et de chapitres, en texte
|
||||
// Date · durée · langue · versions · plages d'épisodes et de chapitres, en texte
|
||||
function episodeMeta(ep: any): string {
|
||||
const { episodes, chapters } = parseEpChap(ep.plot)
|
||||
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} sources` : ''
|
||||
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} versions` : ''
|
||||
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// ── État épisode ───────────────────────────────────────────────
|
||||
// ── État de l'épisode ──
|
||||
|
||||
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
|
||||
// (couvre ep-{id} pour torrent unique ET ep-{id}-{i} pour multi-torrent)
|
||||
// Clés ep-{id} (torrent unique) et ep-{id}-{i} (plusieurs torrents)
|
||||
function epAnyDownloaded(ep: any): boolean {
|
||||
if (isDownloaded(`ep-${ep.id}`)) return true
|
||||
const count = ep.torrents?.length ?? 0
|
||||
@@ -495,15 +480,12 @@ function epProgress(ep: any): ActiveTorrent | null {
|
||||
const sessionDl = isDownloaded(key0) || isDownloaded(keyI)
|
||||
if (active.files && active.files.length > 0) {
|
||||
const file = active.files.find(f => f.index === t.file_index)
|
||||
// Avec données par fichier on se fie à la progression réelle du fichier précis.
|
||||
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
|
||||
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
|
||||
// Fichier à 100 % hors session (ex. retiré puis revu) : pas d'indicateur
|
||||
if (file != null && file.priority !== 0 && (sessionDl || file.progress < 1)) {
|
||||
return { ...active, progress: Math.round(file.progress * 100) }
|
||||
}
|
||||
if (!sessionDl) continue
|
||||
} else {
|
||||
// Pas de données par fichier : ignorer si pas de session active
|
||||
if (!sessionDl) continue
|
||||
return { ...active, progress: active.state === 'seeding' ? 100 : active.progress }
|
||||
}
|
||||
@@ -517,11 +499,9 @@ function epProgress(ep: any): ActiveTorrent | null {
|
||||
function handleEpBtnClick(ep: any) {
|
||||
if (ep.organized) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
// Plusieurs options → toggle dropdown
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
// Option unique → téléchargement direct
|
||||
if (ep.torrent && !isAlreadyQueued(ep.torrent)) {
|
||||
emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet, ep.torrent.file_index ?? null, ep.torrent.file_path ?? null, ep.torrent.infohash ?? null)
|
||||
}
|
||||
@@ -530,25 +510,22 @@ function handleEpBtnClick(ep: any) {
|
||||
function handleEpRequestClick(ep: any) {
|
||||
if (isEpisodeRequested(ep.id)) return
|
||||
if (ep.torrents && ep.torrents.length > 1) {
|
||||
// Plusieurs torrents → afficher le picker
|
||||
epOptionsOpen.value = epOptionsOpen.value === ep.id ? null : ep.id
|
||||
return
|
||||
}
|
||||
// Torrent unique ou aucun → émettre directement
|
||||
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
}
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
// Vert uniquement quand importé dans la bibliothèque
|
||||
if (ep.organized) return 'done'
|
||||
// Spinner si : envoyé au client, déjà en cours dans le client, ou fichier en cours/terminé
|
||||
if (epAnyDownloaded(ep) || epAnyDownloading(ep) || isAlreadyQueued(ep.torrent)) return 'loading'
|
||||
const prog = epProgress(ep)
|
||||
if (prog) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
// ── Rename helpers ─────────────────────────────────────────────
|
||||
|
||||
// ── Renommage ──
|
||||
function epExpectedName(ep: any): string {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return ''
|
||||
@@ -556,9 +533,6 @@ function epExpectedName(ep: any): string {
|
||||
if (props.nfoSupport) {
|
||||
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
|
||||
} else {
|
||||
// Préférer le formatted_name du torrent qui correspond au fichier importé.
|
||||
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
|
||||
// cherche le premier torrent qui a un formatted_name.
|
||||
const usedHash = entry.hash?.toLowerCase() ?? null
|
||||
const matchedName = usedHash
|
||||
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
|
||||
@@ -575,15 +549,10 @@ function epNeedsRename(ep: any): boolean {
|
||||
return entry.needs_rename === true
|
||||
}
|
||||
|
||||
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
|
||||
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
|
||||
// ── Épisodes et chapitres cités dans le synopsis ──
|
||||
// Cache : le synopsis est analysé plusieurs fois par rendu
|
||||
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
|
||||
/**
|
||||
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
|
||||
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
|
||||
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
|
||||
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
|
||||
*/
|
||||
// Extrait les plages d'épisodes et de chapitres, et retire du synopsis les lignes qui les citent
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
season: string | null
|
||||
@@ -637,7 +606,7 @@ function parseEpChap(plot: string | null | undefined) {
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Détection langue ───────────────────────────────────────────
|
||||
// ── Langue ──
|
||||
function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||||
const sources: string[] = [
|
||||
ep.formatted_name ?? '',
|
||||
@@ -653,9 +622,7 @@ function epLang(ep: any): 'MULTI' | 'VOSTFR' | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Labels de groupe ───────────────────────────────────────────
|
||||
// Si tous les torrent_name sont présents ET distincts → on les utilise.
|
||||
// Sinon → titre Nyaa brut (raw), plus informatif quand les noms se ressemblent.
|
||||
// ── Libellés des versions ──
|
||||
function labelFromRaw(raw: string, index: number): string {
|
||||
if (!raw) return `Option ${index + 1}`
|
||||
return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||||
@@ -666,18 +633,13 @@ function groupLabels(torrents: any[]): string[] {
|
||||
const allPresent = names.every(n => n !== null && String(n).trim() !== '')
|
||||
const allUnique = allPresent && new Set(names).size === names.length
|
||||
if (allUnique) return names as string[]
|
||||
// Fallback : titre Nyaa brut
|
||||
return torrents.map((t: any, i: number) => labelFromRaw(t.raw ?? '', i))
|
||||
}
|
||||
|
||||
// Packs parents distincts couvrant cette saison (quand pas de pack saison explicite)
|
||||
// Permet le dropdown "Saison ▾" quand une saison est couverte par plusieurs intégrales.
|
||||
// Un torrent individuel par épisode (hash unique) n'est PAS un pack → ignoré ici,
|
||||
// le bouton "Saison" simple suffira pour lancer tous les épisodes.
|
||||
// Packs couvrant ≥ 2 épisodes, proposés si la saison n'a pas de pack dédié
|
||||
const uniquePackOptions = computed(() => {
|
||||
if (props.season.torrents?.length) return []
|
||||
|
||||
// Compter combien d'épisodes chaque hash couvre
|
||||
const hashCount = new Map<string, number>()
|
||||
const hashTorrent = new Map<string, any>()
|
||||
for (const ep of props.season.episodes) {
|
||||
@@ -690,7 +652,6 @@ const uniquePackOptions = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Garder uniquement les hash qui couvrent ≥ 2 épisodes (= vrais packs / intégrales)
|
||||
const packs = [...hashCount.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([hash]) => ({ infohash: hash, torrent: hashTorrent.get(hash)! }))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<header class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5">
|
||||
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
|
||||
<span class="text-[13px] text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
|
||||
<span class="text-[13px] text-muted">{{ plural(count, 'série') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 w-full md:w-auto">
|
||||
@@ -16,7 +16,7 @@
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.esc="emit('update:search', '')"
|
||||
type="text"
|
||||
:placeholder="selectable ? 'Rechercher dans la médiathèque' : 'Rechercher une série'"
|
||||
placeholder="Rechercher une série"
|
||||
aria-label="Rechercher une série"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
|
||||
/>
|
||||
@@ -39,7 +39,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filtres, tri et taille -->
|
||||
<div ref="rowRef" class="relative">
|
||||
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
|
||||
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
|
||||
@@ -81,7 +80,7 @@
|
||||
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
|
||||
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
|
||||
:aria-pressed="posterSize === size"
|
||||
:title="`Affiches ${size}`"
|
||||
:title="`Taille des affiches : ${size}`"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
@@ -120,6 +119,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
|
||||
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
|
||||
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Tableau de bord → Clés API'" />
|
||||
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord → Clés API → Créer une clé.</p>
|
||||
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Collez la clé API'" />
|
||||
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord › Clés API › Créer une clé.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
|
||||
</span>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
<span v-if="saveError" class="text-meta text-err">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,6 +47,7 @@ const jellyfinAdminToken = ref('')
|
||||
const hasToken = ref(false)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<JellyfinTestResult | null>(null)
|
||||
|
||||
@@ -64,15 +66,19 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
saveError.value = null
|
||||
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
|
||||
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
|
||||
const res = await fetch('/api/jellyfin/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
}).catch(() => null)
|
||||
if (!res?.ok) {
|
||||
const d = await res?.json().catch(() => ({}))
|
||||
saveError.value = d?.error ?? 'Impossible d\'enregistrer les réglages Jellyfin.'
|
||||
} else {
|
||||
const d = await res.json()
|
||||
hasToken.value = d.hasToken
|
||||
jellyfinAdminToken.value = ''
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div>
|
||||
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
|
||||
@@ -12,13 +11,13 @@
|
||||
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
|
||||
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
|
||||
plexStep === 'done' ? 'Configuration terminée' :
|
||||
'Erreur' }}
|
||||
'' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Auth -->
|
||||
<!-- Étape 1 : connexion -->
|
||||
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
|
||||
<button
|
||||
@@ -30,7 +29,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
|
||||
<p class="text-meta text-muted">Google, Apple, ou compte Plex</p>
|
||||
<p class="text-meta text-muted">Google, Apple ou compte Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -43,22 +42,20 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
|
||||
<p class="text-meta text-muted">Connexion directe avec vos identifiants</p>
|
||||
<p class="text-meta text-muted">Sans passer par la page de connexion Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OAuth en attente -->
|
||||
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
|
||||
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
|
||||
<p class="text-meta text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
|
||||
<p class="text-meta text-muted mt-1">Connectez-vous dans l'onglet Plex qui vient de s'ouvrir. Rien ne s'est ouvert ? Autorisez les fenêtres pop-up.</p>
|
||||
</div>
|
||||
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire credentials -->
|
||||
<template v-else-if="plexAuthMethod === 'credentials'">
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
|
||||
@@ -69,7 +66,7 @@
|
||||
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<div v-if="plexNeeds2FA">
|
||||
<label class="field-label mb-1 block">Code 2FA</label>
|
||||
<label class="field-label mb-1 block">Code de validation (6 chiffres)</label>
|
||||
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
@@ -85,9 +82,9 @@
|
||||
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Choix serveur -->
|
||||
<!-- Étape 2 : choix du serveur -->
|
||||
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
|
||||
<p class="text-meta text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
|
||||
<p class="text-meta text-muted">{{ plural(plexServers.length, 'serveur trouvé', 'serveurs trouvés') }}</p>
|
||||
<div
|
||||
v-for="(server, i) in plexServers" :key="i"
|
||||
@click="selectServer(server)"
|
||||
@@ -96,7 +93,7 @@
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
|
||||
<p class="text-meta text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
|
||||
<p class="text-meta text-muted">{{ server.owned ? 'Votre serveur' : 'Partagé avec vous' }}</p>
|
||||
</div>
|
||||
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
|
||||
</div>
|
||||
@@ -117,7 +114,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
|
||||
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
|
||||
<p class="text-xs text-muted mb-1.5">Le dossier de la médiathèque tel que Plex le voit (il peut différer du chemin FanKarr).</p>
|
||||
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
@@ -147,9 +144,9 @@
|
||||
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
|
||||
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
|
||||
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
|
||||
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
|
||||
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
|
||||
<li>Rafraîchissez les métadonnées</li>
|
||||
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent qui utilise ce fournisseur</li>
|
||||
<li>Dans le menu ⋯ de votre bibliothèque, <span class="text-primary">Manage Library › Edit › Advanced › Agent</span> : sélectionnez l'agent Fankai</li>
|
||||
<li>Actualisez les métadonnées</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +161,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Check, Clock3, User, X } from 'lucide-vue-next'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{ mediaPath: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
@@ -190,21 +188,21 @@ const plexForm = ref({
|
||||
libraryPath: props.mediaPath,
|
||||
})
|
||||
|
||||
// ── OAuth ─────────────────────────────────────────────────────
|
||||
// ── Connexion via Plex ──
|
||||
async function startOAuth() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
|
||||
if (!res.ok) { plexError.value = data.error ?? "Impossible d'ouvrir la connexion Plex."; return }
|
||||
plexOAuthPinId.value = String(data.pinId)
|
||||
plexAuthMethod.value = 'oauth'
|
||||
window.open(data.authUrl, '_blank')
|
||||
let attempts = 0
|
||||
plexOAuthTimer = setInterval(async () => {
|
||||
attempts++
|
||||
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
|
||||
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai dépassé. Réessayez.'; return }
|
||||
try {
|
||||
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
|
||||
const d = await r.json()
|
||||
@@ -218,7 +216,7 @@ async function startOAuth() {
|
||||
} catch {}
|
||||
}, 2000)
|
||||
} catch {
|
||||
plexError.value = "Impossible de démarrer l'authentification"
|
||||
plexError.value = "Impossible d'ouvrir la connexion Plex."
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
@@ -230,7 +228,7 @@ function cancelOAuth() {
|
||||
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────
|
||||
// ── Identifiants ──
|
||||
async function plexConnect() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
@@ -241,8 +239,8 @@ async function plexConnect() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
|
||||
else plexError.value = data.error ?? 'Erreur de connexion'
|
||||
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez le code de validation en deux étapes.' }
|
||||
else plexError.value = data.error ?? 'Impossible de se connecter à Plex. Vérifiez vos identifiants.'
|
||||
return
|
||||
}
|
||||
plexToken.value = data.token
|
||||
@@ -250,7 +248,7 @@ async function plexConnect() {
|
||||
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
plexError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
@@ -266,11 +264,11 @@ async function plexConnectServer() {
|
||||
const conns = plexSelectedServer.value.connections ?? []
|
||||
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
|
||||
plexServerUrl.value = conn?.uri ?? ''
|
||||
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
|
||||
if (!plexServerUrl.value) throw new Error('Ce serveur Plex ne propose aucune adresse de connexion.')
|
||||
plexForm.value.libraryPath = props.mediaPath
|
||||
plexStep.value = 'library'
|
||||
} catch (err) {
|
||||
plexError.value = err instanceof Error ? err.message : 'Erreur'
|
||||
plexError.value = err instanceof Error ? err.message : 'Impossible de se connecter à ce serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
@@ -288,9 +286,9 @@ async function plexSetup() {
|
||||
plexSteps.value = data.steps ?? []
|
||||
plexManualSetup.value = data.manualSetup ?? false
|
||||
plexStep.value = 'done'
|
||||
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
|
||||
if (!data.ok) plexError.value = data.error ?? 'Impossible de configurer Plex.'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
plexError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Accent">
|
||||
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Couleur de l'interface">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.id"
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs principaux -->
|
||||
<template v-if="currentDefinition">
|
||||
<div v-for="field in basicFields" :key="field.key">
|
||||
<SettingsToggle
|
||||
@@ -39,7 +38,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres avancés -->
|
||||
<div v-if="advancedFields.length > 0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -92,7 +90,7 @@
|
||||
<button @click="test" :disabled="testing || !type" class="btn-secondary">
|
||||
<Loader v-if="testing" :size="14" class="animate-spin" />
|
||||
<Check v-else-if="tested" :size="14" class="text-ok" />
|
||||
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester' }}
|
||||
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester la connexion' }}
|
||||
</button>
|
||||
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
|
||||
</div>
|
||||
@@ -122,10 +120,10 @@ const { add: toast } = useToast()
|
||||
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
|
||||
|
||||
const FIELD_TOOLTIPS: Record<string, string> = {
|
||||
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
|
||||
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
|
||||
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
|
||||
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
|
||||
savePath : 'Facultatif. Dossier où le client enregistre les torrents envoyés par FanKarr (ex. /downloads/fankai). Vide, le client utilise son dossier par défaut.',
|
||||
remotePath : "Si le client tourne sur une autre machine ou dans un autre conteneur : son dossier de téléchargement, tel qu'il le voit (ex. /downloads).",
|
||||
localPath : 'Le même dossier, tel que FanKarr le voit (ex. /mnt/nas/downloads).',
|
||||
ignoreCertificateErrors: "Accepte les certificats auto-signés ou invalides en HTTPS. À n'utiliser que sur un réseau de confiance.",
|
||||
}
|
||||
|
||||
const name = ref(props.client?.name ?? '')
|
||||
@@ -177,7 +175,7 @@ async function test() {
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
tested.value = ok
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
toast(ok ? 'Connexion réussie' : (message ?? 'Impossible de se connecter au client torrent'), ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -199,11 +197,11 @@ async function save() {
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
|
||||
toast(isEdit ? 'Client modifié' : 'Client enregistré', 'success')
|
||||
emit('saved', await res.json(), isEdit)
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||
toast(error ?? "Impossible d'enregistrer le client", 'error')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Catalogue non téléchargé' : `${status.count} séries disponibles` }}
|
||||
{{ status.empty ? 'Catalogue non synchronisé' : plural(status.count, 'série disponible', 'séries disponibles') }}
|
||||
</p>
|
||||
<p class="text-meta text-muted mt-0.5">
|
||||
{{ status.empty
|
||||
@@ -24,7 +24,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0">
|
||||
{{ updating ? 'Téléchargement…' : status.empty ? 'Télécharger' : 'Mettre à jour' }}
|
||||
{{ updating ? 'Synchronisation…' : status.empty ? 'Synchroniser le catalogue' : 'Synchroniser' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
|
||||
<p class="text-meta text-muted mt-0.5">
|
||||
<template v-if="scanResult">{{ scanResult.found }} fichiers analysés · {{ scanResult.added }} référencés.</template>
|
||||
<template v-else>Analysez la médiathèque pour référencer les fichiers présents, sans les déplacer.</template>
|
||||
<template v-if="scanResult">{{ plural(scanResult.found, 'fichier trouvé', 'fichiers trouvés') }}, {{ plural(scanResult.added, 'ajouté', 'ajoutés') }} à la médiathèque.</template>
|
||||
<template v-else>Analysez la médiathèque pour reconnaître les épisodes déjà présents. Aucun fichier n'est déplacé.</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
|
||||
@@ -45,7 +45,7 @@
|
||||
</section>
|
||||
|
||||
<p v-if="warnEmpty" class="text-body text-accent" role="alert">
|
||||
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
|
||||
Le catalogue est encore vide. Synchronisez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Library, ScanSearch } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { plural } from '@/utils/format'
|
||||
import type { SetupSettings } from './setup'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
@@ -77,9 +78,9 @@ async function update() {
|
||||
if (res.ok) {
|
||||
status.value = { exists: true, count: data.count, empty: data.count === 0 }
|
||||
warnEmpty.value = false
|
||||
toast(`${data.count} séries chargées ✓`, 'success')
|
||||
toast(`Catalogue synchronisé : ${plural(data.count, 'série')}`, 'success')
|
||||
} else {
|
||||
toast(data.error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
toast(data.error ?? 'Impossible de synchroniser le catalogue', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -96,7 +97,7 @@ async function scan() {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
toast("Impossible d'analyser la médiathèque", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Clients existants -->
|
||||
<div v-if="clients.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="client in clients" :key="client.uuid" class="card flex items-center gap-3">
|
||||
<span
|
||||
@@ -25,7 +24,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<div v-if="formOpen" class="card flex flex-col gap-4">
|
||||
<p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
|
||||
<TorrentClientForm
|
||||
@@ -41,7 +39,6 @@
|
||||
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
|
||||
</button>
|
||||
|
||||
<!-- Rappel chemins -->
|
||||
<div class="flex items-start gap-3 px-4 py-3 rounded-field border border-border bg-card/50">
|
||||
<Info :size="15" class="text-muted mt-0.5 shrink-0" />
|
||||
<p class="text-meta text-muted leading-relaxed">
|
||||
@@ -58,8 +55,8 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-body text-accent font-medium">Aucun client configuré</p>
|
||||
<p class="text-meta text-muted">
|
||||
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même,
|
||||
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement.
|
||||
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour passer cette étape.
|
||||
Vous pourrez en ajouter un plus tard dans Paramètres › Clients torrent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,23 +5,23 @@
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importe dès qu'un téléchargement est terminé, puis vérifie toutes les 5 minutes. Sinon, l'import se lance à la main depuis Activité."
|
||||
description="Importe les téléchargements terminés (vérification toutes les 5 minutes). Désactivé, vous lancez l'import depuis la page Activité."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Dossiers « Season » au lieu de « Saison »"
|
||||
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »."
|
||||
label="Dossiers de saison en anglais"
|
||||
description="« Season 01 » au lieu de « Saison 1 »."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si le fichier disparaît"
|
||||
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant."
|
||||
label="Retirer les épisodes supprimés du disque"
|
||||
description="À la prochaine analyse, un épisode dont le fichier a disparu est retiré de la médiathèque."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="settings.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Retire automatiquement le torrent du client une fois le fichier déplacé."
|
||||
description="Le torrent ne peut plus être partagé une fois le fichier déplacé : autant le retirer du client."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
label="Fichiers NFO et images"
|
||||
description="Télécharge NFO et visuels à côté de chaque épisode lors de l'import."
|
||||
description="Enregistre à côté de chaque épisode un fichier .nfo (titre, résumé…) et ses images."
|
||||
/>
|
||||
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-field border border-border bg-main/50 text-meta text-muted leading-relaxed">
|
||||
<Info :size="13" class="shrink-0 mt-0.5" />
|
||||
@@ -67,7 +67,7 @@ const error = ref<string | null>(null)
|
||||
async function submit(): Promise<boolean> {
|
||||
error.value = null
|
||||
if (await postSettings(form)) return true
|
||||
error.value = "Erreur lors de l'enregistrement."
|
||||
error.value = "Impossible d'enregistrer les réglages d'import."
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Jellyfin -->
|
||||
<section class="card flex flex-col gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
@@ -13,7 +12,7 @@
|
||||
<span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
|
||||
</p>
|
||||
<p class="text-meta text-muted mt-0.5 leading-relaxed">
|
||||
Permet à vos utilisateurs Jellyfin de se connecter à FanKarr et de faire des demandes depuis le plugin FanKarr Search.
|
||||
Vos utilisateurs Jellyfin se connectent à FanKarr et font leurs demandes depuis le plugin FanKarr Search.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
|
||||
@@ -31,7 +30,7 @@
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
|
||||
</button>
|
||||
<span v-if="syncResult" class="text-meta text-ok">
|
||||
{{ syncResult.created }} créé{{ syncResult.created > 1 ? 's' : '' }} · {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
|
||||
{{ plural(syncResult.created, 'créé', 'créés') }} · {{ plural(syncResult.skipped, 'déjà existant', 'déjà existants') }}
|
||||
</span>
|
||||
<span v-if="syncError" class="text-meta text-err">{{ syncError }}</span>
|
||||
</div>
|
||||
@@ -40,7 +39,6 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Plex -->
|
||||
<section class="card flex flex-col gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="w-9 h-9 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
@@ -53,7 +51,7 @@
|
||||
</p>
|
||||
<p class="text-meta text-muted mt-0.5 leading-relaxed">
|
||||
Crée une bibliothèque Plex sur votre médiathèque, reliée à l'agent de métadonnées Fankai.
|
||||
Requiert Plex Media Server 1.43 ou plus récent pour la configuration automatique.
|
||||
Nécessite Plex Media Server 1.43 ou plus récent pour la configuration automatique.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary btn-sm shrink-0">
|
||||
@@ -63,7 +61,7 @@
|
||||
<p v-if="!settings.mediaPath" class="text-meta text-accent">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
|
||||
</section>
|
||||
|
||||
<p class="text-meta text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
|
||||
<p class="text-meta text-muted">Pas encore de Jellyfin ni de Plex ? Passez cette étape : vous pourrez les configurer plus tard dans les paramètres.</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -76,6 +74,7 @@ import { Server, Tv } from 'lucide-vue-next'
|
||||
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
|
||||
import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
import { setupContextKey, type SetupSettings } from './setup'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
@@ -101,7 +100,7 @@ async function syncUsers() {
|
||||
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (res.ok) syncResult.value = data
|
||||
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
|
||||
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin'
|
||||
} catch {
|
||||
syncError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
class="field"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Parcourir — ${field.label}`">
|
||||
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :title="`Choisir le dossier : ${field.label}`">
|
||||
<FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -34,15 +34,14 @@
|
||||
</div>
|
||||
|
||||
<p v-if="result?.relation === 'same'" class="text-meta text-err flex items-start gap-1.5">
|
||||
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque doit être distincte du dossier de téléchargements.
|
||||
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque et le dossier des téléchargements doivent être différents.
|
||||
</p>
|
||||
<p v-else-if="result?.relation === 'nested'" class="text-meta text-accent flex items-start gap-1.5">
|
||||
<TriangleAlert :size="12" class="shrink-0 mt-0.5" />
|
||||
Un dossier est à l'intérieur de l'autre : ça fonctionne, mais le scan de la médiathèque verra aussi les téléchargements.
|
||||
L'un des dossiers est dans l'autre. C'est possible, mais l'analyse de la médiathèque trouvera aussi les téléchargements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode d'import -->
|
||||
<div class="card flex flex-col gap-3">
|
||||
<label class="field-label">Mode d'import</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
@@ -62,7 +61,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Résultat du test hardlink -->
|
||||
<div v-if="result && form.organizeMode === 'hardlink'" class="text-meta">
|
||||
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-ok flex items-center gap-1.5">
|
||||
<Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
|
||||
@@ -71,7 +69,7 @@
|
||||
<p class="text-accent flex items-start gap-1.5">
|
||||
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
|
||||
</p>
|
||||
<p class="text-muted">Sans hardlink, FanKarr copiera les fichiers (espace disque doublé).</p>
|
||||
<p class="text-muted">Les hardlinks ne fonctionnent pas entre ces dossiers : en mode Copier, chaque fichier prendra deux fois la place.</p>
|
||||
<button @click="form.organizeMode = 'copy'" class="btn-secondary btn-sm self-start">Passer en mode Copier</button>
|
||||
</div>
|
||||
<p v-else class="text-muted">{{ result.hardlink.message }}</p>
|
||||
@@ -104,7 +102,7 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
|
||||
{
|
||||
key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
|
||||
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete',
|
||||
help: 'Là où votre client torrent dépose les fichiers terminés. Sert aussi à tester les hardlinks.',
|
||||
help: 'Dossier où votre client torrent place les fichiers terminés.',
|
||||
},
|
||||
{
|
||||
key: 'mediaPath', label: 'Médiathèque Fankai', required: true,
|
||||
@@ -114,9 +112,9 @@ const fields: { key: PathKey; label: string; required: boolean; placeholder: str
|
||||
]
|
||||
|
||||
const modes = [
|
||||
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Aucune copie : le fichier reste en partage dans le client torrent.' },
|
||||
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Le fichier apparaît dans la médiathèque sans être copié ; le client continue de le partager.' },
|
||||
{ id: 'copy' as const, label: 'Copier', text: 'Duplique le fichier. Fonctionne partout, prend deux fois la place.' },
|
||||
{ id: 'move' as const, label: 'Déplacer', text: 'Retire le fichier du client : le partage s\'arrête.' },
|
||||
{ id: 'move' as const, label: 'Déplacer', text: "Retire le fichier du client : le partage s'arrête." },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
@@ -175,7 +173,7 @@ async function submit(): Promise<boolean> {
|
||||
if (!res.ok) { error.value = 'Corrigez les dossiers signalés avant de continuer.'; return false }
|
||||
|
||||
if (!(await postSettings({ ...form, mediaPath: res.mediaPath.resolved, completePath: res.completePath?.resolved ?? '' }))) {
|
||||
error.value = "Erreur lors de l'enregistrement."
|
||||
error.value = "Impossible d'enregistrer les dossiers."
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary">
|
||||
<Compass :size="15" />
|
||||
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
|
||||
{{ context.relaunch ? 'Revoir la visite guidée' : 'Terminer avec la visite guidée' }}
|
||||
</button>
|
||||
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
|
||||
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!context.relaunch" class="text-meta text-muted -mt-3">
|
||||
La visite guidée présente chaque écran en une minute. Vous pourrez la relancer depuis le bouton « ? » de la barre latérale.
|
||||
La visite guidée présente les écrans principaux en une minute. Vous pourrez la relancer avec le bouton « ? ».
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -38,6 +38,7 @@
|
||||
import { ref, reactive, inject, onMounted } from 'vue'
|
||||
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
|
||||
interface Row { label: string; value: string; state: RowState; step: SetupStepId }
|
||||
@@ -55,7 +56,7 @@ const stateClass: Record<RowState, string> = {
|
||||
neutral: 'bg-hover text-muted',
|
||||
}
|
||||
|
||||
const modeLabel = { hardlink: 'Hardlink', copy: 'Copie', move: 'Déplacement' }
|
||||
const modeLabel = { hardlink: 'Hardlink', copy: 'Copier', move: 'Déplacer' }
|
||||
|
||||
const row = (label: string, step: SetupStepId, value = '…', state: RowState = 'loading'): Row => ({ label, step, value, state })
|
||||
|
||||
@@ -64,11 +65,11 @@ const rows = reactive({
|
||||
mode : row("Mode d'import", 'paths'),
|
||||
client : row('Client torrent', 'client'),
|
||||
import : row('Import automatique', 'import',
|
||||
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis Activité',
|
||||
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis la page Activité',
|
||||
props.settings.autoImport ? 'ok' : 'neutral'),
|
||||
nfo : row('NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
|
||||
nfo : row('Fichiers NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
|
||||
jellyfin: row('Jellyfin', 'media-server'),
|
||||
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant Plex lancé' : 'Non configuré', 'neutral'),
|
||||
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant ouvert, non vérifié' : 'Non configuré', 'neutral'),
|
||||
catalog : row('Catalogue Fankai', 'catalog'),
|
||||
})
|
||||
|
||||
@@ -90,7 +91,7 @@ async function loadPaths() {
|
||||
set(rows.paths, 'Médiathèque non configurée', 'error')
|
||||
} else {
|
||||
set(rows.paths,
|
||||
`${s.mediaPath}${s.completePath ? ` ← ${s.completePath}` : ' · téléchargements non renseignés'}`,
|
||||
`Médiathèque : ${s.mediaPath} · ${s.completePath ? `Téléchargements : ${s.completePath}` : 'dossier des téléchargements non renseigné'}`,
|
||||
!res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ async function loadCatalog() {
|
||||
try {
|
||||
const { count, empty } = await getJson('/api/torrents/status')
|
||||
if (empty) set(rows.catalog, 'Catalogue vide', 'warn')
|
||||
else set(rows.catalog, `${count} séries disponibles`, 'ok')
|
||||
else set(rows.catalog, plural(count, 'série disponible', 'séries disponibles'), 'ok')
|
||||
} catch {
|
||||
set(rows.catalog, 'Impossible de vérifier', 'warn')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- Fonctionnement -->
|
||||
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<li v-for="(item, i) in flow" :key="item.title" class="card flex gap-3">
|
||||
<span class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
@@ -14,7 +13,6 @@
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Ce qu'on va configurer -->
|
||||
<div class="card flex flex-col gap-3">
|
||||
<p class="field-label">Au programme</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
@@ -22,14 +20,13 @@
|
||||
<Check :size="14" class="text-accent mt-0.5 shrink-0" />
|
||||
<span class="text-secondary">
|
||||
{{ item.label }}
|
||||
<span v-if="item.optional" class="text-muted text-meta">(optionnel)</span>
|
||||
<span v-if="item.optional" class="text-muted text-meta">(facultatif)</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-meta text-muted">Comptez environ 5 minutes.</p>
|
||||
</div>
|
||||
|
||||
<!-- Docker -->
|
||||
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-field border border-accent/30 bg-accent-muted">
|
||||
<HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -54,16 +51,16 @@ defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>
|
||||
|
||||
const flow = [
|
||||
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' },
|
||||
{ icon: Download, title: 'Téléchargement', text: 'Un clic envoie le torrent à votre client (qBittorrent, Transmission…).' },
|
||||
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' },
|
||||
{ icon: Download, title: 'Téléchargement', text: 'Le torrent est envoyé à votre client (qBittorrent, Transmission…).' },
|
||||
{ icon: FolderOpen, title: 'Import', text: 'Une fois le téléchargement terminé, le fichier est renommé et rangé par série et par saison.' },
|
||||
{ icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
|
||||
]
|
||||
|
||||
const checklist = [
|
||||
{ label: 'Dossier de téléchargements et médiathèque' },
|
||||
{ label: 'Dossier des téléchargements et médiathèque' },
|
||||
{ label: 'Client torrent' },
|
||||
{ label: 'Import automatique et nommage' },
|
||||
{ label: 'Jellyfin ou Plex', optional: true },
|
||||
{ label: 'Téléchargement du catalogue' },
|
||||
{ label: 'Synchronisation du catalogue' },
|
||||
]
|
||||
</script>
|
||||
@@ -171,7 +171,7 @@ async function showStep() {
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.top < 0 || r.bottom > window.innerHeight) {
|
||||
el.scrollIntoView({ block: 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
|
||||
el.scrollIntoView({ block: r.height > window.innerHeight ? 'start' : 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Copie du texte dans le presse-papiers avec fallback pour HTTP non-localhost. */
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
// Fallback : execCommand (HTTP non sécurisé)
|
||||
// Hors HTTPS, navigator.clipboard est absent : repli sur execCommand
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
|
||||
|
||||
@@ -11,31 +11,23 @@ interface Toast {
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
const DURATION = 3500
|
||||
const MAX_VISIBLE = 4
|
||||
|
||||
function add(message: string, type: Toast['type'] = 'info') {
|
||||
// Cherche un toast récent du même type à grouper
|
||||
const existing = toasts.value.find(t => t.type === type)
|
||||
const existing = toasts.value.find(t => t.type === type && t.message === message)
|
||||
|
||||
if (existing) {
|
||||
// Grouper — incrémenter le count et mettre à jour le message
|
||||
clearTimeout(existing.timer)
|
||||
existing.count++
|
||||
existing.message = buildGroupMessage(type, existing.count)
|
||||
existing.timer = setTimeout(() => remove(existing.id), DURATION)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau toast
|
||||
const id = nextId++
|
||||
const timer = setTimeout(() => remove(id), DURATION)
|
||||
|
||||
toasts.value.push({ id, message, type, count: 1, timer })
|
||||
}
|
||||
|
||||
function buildGroupMessage(type: Toast['type'], count: number): string {
|
||||
if (type === 'success') return `${count} opérations réussies`
|
||||
if (type === 'error') return `${count} erreurs`
|
||||
return `${count} notifications`
|
||||
if (toasts.value.length > MAX_VISIBLE) remove(toasts.value[0]!.id)
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
|
||||
@@ -29,7 +29,6 @@ function isNewer(latest: string, current: string): boolean {
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
// Version locale
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) {
|
||||
const { version } = await vRes.json()
|
||||
@@ -38,7 +37,6 @@ async function checkForUpdates() {
|
||||
|
||||
if (currentVersion.value === 'dev') return
|
||||
|
||||
// Dernière release GitHub
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
@@ -57,7 +55,6 @@ export function useUpdateCheck() {
|
||||
onMounted(() => {
|
||||
if (users++ > 0) return
|
||||
checkForUpdates()
|
||||
// Revérifier toutes les 6h
|
||||
timer = setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Première visite : une seule fois par session, après l'assistant pour un admin
|
||||
// Visite guidée auto : une fois par session, après l'assistant pour un admin
|
||||
let tourAutoStarted = false
|
||||
watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
|
||||
if (tourAutoStarted || tour.active) return
|
||||
@@ -64,7 +64,6 @@ watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Compteurs de pour la barre latérale
|
||||
let pendingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let downloadsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -84,7 +83,6 @@ onUnmounted(() => {
|
||||
|
||||
watch(() => route.path, () => reqStore.refreshPending())
|
||||
|
||||
// Mobile
|
||||
const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings'))
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
@@ -120,6 +118,5 @@ const navItems = computed<NavItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// Mobile 2
|
||||
const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings')))
|
||||
</script>
|
||||
+5
-11
@@ -12,7 +12,7 @@ import AppLayout from '@/layouts/AppLayout.vue'
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// ─── Pages publiques hors layout ─────────────────────────
|
||||
// ── Pages hors layout (connexion, invitation, assistant) ──
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
@@ -30,7 +30,7 @@ const router = createRouter({
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
|
||||
// ─── App principale avec layout sidebar ──────────────────
|
||||
// ── Pages avec barre latérale ──
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
@@ -38,7 +38,6 @@ const router = createRouter({
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'downloads', redirect: '/activity' },
|
||||
|
||||
// Accueil
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
@@ -64,7 +63,6 @@ const router = createRouter({
|
||||
component: () => import('@/views/DownloadsView.vue'),
|
||||
},
|
||||
|
||||
// Paramètres
|
||||
{
|
||||
path: 'settings',
|
||||
component: () => import('@/views/settings/SettingsLayout.vue'),
|
||||
@@ -98,7 +96,7 @@ const router = createRouter({
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'settings-logs',
|
||||
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
||||
component: () => import('@/views/settings/LogsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
@@ -120,7 +118,6 @@ const router = createRouter({
|
||||
],
|
||||
},
|
||||
|
||||
// Demandes
|
||||
{
|
||||
path: 'requests',
|
||||
name: 'requests',
|
||||
@@ -133,25 +130,22 @@ const router = createRouter({
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
if (to.name === 'setup') return { top: 0 }
|
||||
// Retour depuis le détail vers la liste — ne pas scroller en haut
|
||||
// Retour à la liste : garder la position de défilement
|
||||
if (to.name === 'series' && from.name === 'serie-detail') return false
|
||||
return { el: '#main-scroll', top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Guard auth ───────────────────────────────────────────────
|
||||
// ── Contrôle d'accès ──
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (auth.loading) await auth.checkStatus()
|
||||
|
||||
// Redirige vers /auth si non connecté
|
||||
if (!to.meta.public && !auth.loggedIn) return '/auth'
|
||||
|
||||
// Redirige vers l'accueil si déjà connecté et tente d'accéder à /auth
|
||||
if (to.path === '/auth' && auth.loggedIn) return '/dashboard'
|
||||
|
||||
// Redirige les non-admins hors des pages admin
|
||||
if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard'
|
||||
|
||||
// Premier lancement : l'assistant de configuration est obligatoire
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
await checkStatus()
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
return data.error ?? 'Impossible de se connecter.'
|
||||
}
|
||||
|
||||
async function setupAccount(u: string, password: string): Promise<string | null> {
|
||||
@@ -63,7 +63,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
tourSeen.value = false
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
return data.error ?? 'Impossible de créer le compte.'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -18,8 +18,8 @@ export const useCatalogStore = defineStore('catalog', () => {
|
||||
error.value = null
|
||||
try {
|
||||
productions.value = await fetchProductions()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} catch {
|
||||
error.value = 'Impossible de charger le catalogue.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
+9
-10
@@ -88,18 +88,17 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
error.value = null
|
||||
try {
|
||||
const res = await fetch('/api/series')
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
if (!res.ok) { error.value = 'Impossible de charger les séries.'; return }
|
||||
const data = await res.json()
|
||||
series.value = data.series
|
||||
// Précache des posters en arrière-plan
|
||||
for (const s of data.series as Serie[]) {
|
||||
if (s.poster_image && !_imageCache.has(s.poster_image)) {
|
||||
_imageCache.add(s.poster_image)
|
||||
new Image().src = s.poster_image
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} catch {
|
||||
error.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
loadingSeries.value = false
|
||||
}
|
||||
@@ -111,10 +110,10 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
currentSerie.value = null
|
||||
try {
|
||||
const res = await fetch(`/api/series/${id}`)
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
if (!res.ok) { error.value = 'Impossible de charger la série.'; return }
|
||||
currentSerie.value = await res.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} catch {
|
||||
error.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
loadingDetail.value = false
|
||||
}
|
||||
@@ -129,9 +128,9 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) return { success: true }
|
||||
return { success: false, error: data.error }
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
return { success: false, error: data.error ?? "Impossible d'envoyer le torrent au client" }
|
||||
} catch {
|
||||
return { success: false, error: 'Impossible de contacter le serveur' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-20
@@ -12,58 +12,58 @@ export interface TourStep {
|
||||
const welcome: TourStep = {
|
||||
id: 'welcome',
|
||||
title: 'Bienvenue dans FanKarr',
|
||||
body: 'Une visite rapide des écrans principaux. Utilisez les flèches ← → pour naviguer et Échap pour quitter.',
|
||||
body: 'Une visite rapide des écrans principaux. Au clavier : touches fléchées pour avancer ou revenir, Échap pour quitter.',
|
||||
}
|
||||
|
||||
const help = (isAdmin: boolean): TourStep => ({
|
||||
id: 'help', target: ['help'], placement: 'right',
|
||||
title: 'Besoin d\'aide ?',
|
||||
title: "Besoin d'aide ?",
|
||||
body: isAdmin
|
||||
? 'Ce bouton relance la visite ou l\'assistant de configuration à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.'
|
||||
: 'Ce bouton relance la visite à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.',
|
||||
? "Ce bouton relance la visite ou l'assistant de configuration. Sur mobile, ces options sont dans le menu de votre avatar, en haut de l'Accueil et des Paramètres."
|
||||
: "Ce bouton relance la visite. Sur mobile, elle se trouve dans le menu de votre avatar, en haut de l'Accueil et des Paramètres.",
|
||||
})
|
||||
|
||||
const adminSteps: TourStep[] = [
|
||||
welcome,
|
||||
{
|
||||
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
|
||||
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
|
||||
title: 'Accueil',
|
||||
body: 'Le dernier import en grand, les séries ajoutées récemment, et les chiffres du catalogue : séries dans la médiathèque et séries surveillées.',
|
||||
body: 'Le dernier import, les séries ajoutées récemment et les chiffres clés : séries au catalogue, dans la médiathèque et surveillées.',
|
||||
},
|
||||
{
|
||||
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
|
||||
title: 'À traiter',
|
||||
body: 'Demandes en attente, imports en erreur et dossiers à renommer : chaque ligne porte son action. Les demandes arrivent de FanKarr ou du plugin Jellyfin.',
|
||||
body: "Demandes en attente, imports en erreur et dossiers à renommer, chacun avec son bouton d'action. Les demandes viennent de FanKarr ou du plugin Jellyfin.",
|
||||
},
|
||||
{
|
||||
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
|
||||
title: 'Médiathèque',
|
||||
body: 'Tout le catalogue Fankai. L\'état est écrit sur l\'affiche : importée, partielle, en téléchargement, erreur, dossier à renommer ou sans torrent. Cliquez pour ouvrir la fiche.',
|
||||
body: "Tout le catalogue Fankai. Chaque affiche indique l'état de la série (importée, partielle, en téléchargement…). Cliquez dessus pour ouvrir sa fiche.",
|
||||
},
|
||||
{
|
||||
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
|
||||
title: 'Recherche et filtres',
|
||||
body: 'Chaque filtre affiche son nombre de séries ; « Plus » garde les filtres secondaires. Triez, et changez la taille des affiches.',
|
||||
body: 'Chaque filtre indique son nombre de séries, les filtres secondaires sont sous « Plus ». Vous pouvez aussi trier et changer la taille des affiches.',
|
||||
},
|
||||
{
|
||||
id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
|
||||
title: 'Actions groupées',
|
||||
body: 'Sélectionnez plusieurs séries pour les renommer ou les surveiller. Une série surveillée télécharge seule ses nouveaux épisodes.',
|
||||
body: 'Sélectionnez plusieurs séries pour renommer leurs fichiers ou les surveiller. FanKarr télécharge automatiquement les nouveaux épisodes des séries surveillées.',
|
||||
},
|
||||
{
|
||||
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
|
||||
title: 'Demandes',
|
||||
body: '« Approuver et télécharger » lance le téléchargement ; une demande sans torrent est signalée avant d\'être approuvée. Les demandes avec torrent s\'approuvent aussi en une fois.',
|
||||
body: 'Approuvez ou refusez les demandes des invités. « Approuver et télécharger » envoie aussi le torrent au client ; les demandes sans torrent sont signalées. Le bouton du haut approuve toutes celles qui ont un torrent.',
|
||||
},
|
||||
{
|
||||
id: 'activity', route: '/activity', target: ['activity-header'], placement: 'bottom',
|
||||
title: 'Activité',
|
||||
body: 'Une ligne par torrent, du téléchargement à l\'import. Les onglets séparent ce qui est en cours, à importer ou en erreur ; une erreur d\'import s\'affiche dans sa ligne, avec de quoi relancer.',
|
||||
body: "Un torrent par ligne, du téléchargement à l'import. Les onglets séparent les torrents en cours, à importer et en erreur. En cas d'erreur d'import, un bouton « Réessayer » apparaît sur la ligne.",
|
||||
},
|
||||
{
|
||||
id: 'settings', route: '/settings/download-client', target: ['settings-menu', 'nav-settings'], placement: 'right',
|
||||
title: 'Paramètres',
|
||||
body: 'Les réglages sont rangés en quatre groupes : Compte (profil, utilisateurs), Médias (dossiers, séries, catalogue), Services (clients torrent, Jellyfin) et Système (journaux, avancé).',
|
||||
body: 'Quatre groupes de réglages : Compte, Médias, Services (clients torrent, Jellyfin) et Système.',
|
||||
},
|
||||
help(true),
|
||||
]
|
||||
@@ -71,24 +71,24 @@ const adminSteps: TourStep[] = [
|
||||
const userSteps: TourStep[] = [
|
||||
welcome,
|
||||
{
|
||||
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
|
||||
id: 'dashboard', route: '/dashboard', target: ['dashboard-main', 'dashboard-stats'], placement: 'right',
|
||||
title: 'Accueil',
|
||||
body: 'Les nouveautés de la médiathèque et le nombre de séries prêtes à regarder.',
|
||||
},
|
||||
{
|
||||
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
|
||||
title: 'Mes demandes',
|
||||
body: 'L\'état de vos dernières demandes. Juste en dessous, « Pas encore là » propose des séries à demander en un clic.',
|
||||
title: 'Vos dernières demandes',
|
||||
body: "Leur état en un coup d'œil. Juste en dessous, « Pas encore là » suggère des séries que vous pouvez demander.",
|
||||
},
|
||||
{
|
||||
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
|
||||
title: 'Médiathèque',
|
||||
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez la fiche pour demander la série, une saison ou un épisode.',
|
||||
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez leur fiche pour demander la série, une saison ou un épisode.',
|
||||
},
|
||||
{
|
||||
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
|
||||
title: 'Recherche et filtres',
|
||||
body: 'Retrouvez une série et filtrez : prêtes à regarder, bientôt là, pas encore là ou vos demandes.',
|
||||
body: 'Cherchez une série ou filtrez : « Prêtes à regarder », « Bientôt là », « Pas encore là » ou « Mes demandes ».',
|
||||
},
|
||||
{
|
||||
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
|
||||
@@ -97,8 +97,8 @@ const userSteps: TourStep[] = [
|
||||
},
|
||||
{
|
||||
id: 'profile-token', route: '/settings/profile', target: ['profile-token'], placement: 'top',
|
||||
title: 'Jeton d’API',
|
||||
body: 'Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte pour faire vos demandes depuis Jellyfin. Le choix de l’accent de couleur se fait juste à côté.',
|
||||
title: "Jeton d'API",
|
||||
body: "Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte, pour faire vos demandes depuis Jellyfin. Sur cette page, vous pouvez aussi changer la couleur de l'interface.",
|
||||
},
|
||||
help(false),
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ export function splitTitle(title: string): { name: string; suffix: string } {
|
||||
return m ? { name: m[1]!, suffix: m[2]! } : { name: title, suffix: '' }
|
||||
}
|
||||
|
||||
// Nouveauté : si importe il y a moins de 7 jours
|
||||
export function isNew(lastImportedAt: string | null | undefined): boolean {
|
||||
return !!lastImportedAt && Date.now() - new Date(lastImportedAt).getTime() < 7 * 86_400_000
|
||||
}
|
||||
+8
-12
@@ -3,26 +3,22 @@
|
||||
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
|
||||
<div class="relative w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<FankarrLogo class="w-9 h-9" />
|
||||
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="tag-label text-accent">
|
||||
{{ isSetup ? 'Première connexion' : 'Connexion' }}
|
||||
</p>
|
||||
<p v-if="isSetup" class="tag-label text-accent">Premier lancement</p>
|
||||
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
{{ isSetup ? 'Créer le compte administrateur' : 'Connexion' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="auth-username" class="field-label">Identifiant</label>
|
||||
<label for="auth-username" class="field-label">Nom d'utilisateur</label>
|
||||
<input id="auth-username" v-model="username" type="text" placeholder="admin" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
@@ -30,14 +26,14 @@
|
||||
<input id="auth-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div v-if="isSetup">
|
||||
<label class="field-label mb-1.5">Confirmer</label>
|
||||
<label class="field-label mb-1.5">Confirmer le mot de passe</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
|
||||
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
|
||||
{{ submitting ? '…' : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
{{ submitting ? (isSetup ? 'Création…' : 'Connexion…') : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +61,9 @@ const isSetup = computed(() => !auth.setup)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
|
||||
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit faire au moins 6 caractères'; return }
|
||||
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
|
||||
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis.'; return }
|
||||
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return }
|
||||
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas.'; return }
|
||||
|
||||
submitting.value = true
|
||||
const err = isSetup.value
|
||||
|
||||
+40
-48
@@ -22,12 +22,11 @@
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div class="grid lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
|
||||
|
||||
<!-- ── Colonne principale-->
|
||||
<div class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
|
||||
<!-- ── Colonne principale ── -->
|
||||
<div data-tour="dashboard-main" class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
|
||||
|
||||
<!-- Dernier import -->
|
||||
<section v-if="hero" class="relative sm:min-h-[344px] rounded-card overflow-hidden bg-card max-lg:order-1">
|
||||
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" alt="" class="absolute inset-0 w-full h-full object-cover blur-[34px] saturate-[1.3] scale-[1.35] opacity-60" />
|
||||
<div class="absolute inset-0 bg-linear-to-r from-main/97 via-main/84 via-48% to-main/35" />
|
||||
@@ -40,13 +39,13 @@
|
||||
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-2 top-3">NOUVEAU</span>
|
||||
</RouterLink>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
|
||||
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importé' : 'Ajouté' }} {{ formatRelative(hero.at) }}</span>
|
||||
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importée' : 'Ajoutée' }} {{ formatRelative(hero.at) }}</span>
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col">
|
||||
<span class="font-display text-[28px] leading-[1.05] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
|
||||
<span v-if="heroTitle.suffix" class="font-display text-xl leading-snug font-semibold text-accent">{{ heroTitle.suffix }}</span>
|
||||
</RouterLink>
|
||||
<p class="text-meta text-secondary">
|
||||
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ plural(hero.episodes, 'épisode') }} {{ auth.isAdmin ? (hero.episodes > 1 ? 'ajoutés' : 'ajouté') : 'à regarder' }}.
|
||||
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : `${plural(hero.episodes, 'épisode')} à regarder` }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,7 +66,7 @@
|
||||
</RouterLink>
|
||||
<p class="text-[15px] leading-[1.6] text-secondary max-w-[420px] text-pretty">
|
||||
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>
|
||||
<span class="text-primary">{{ plural(hero.episodes, 'épisode') }}{{ auth.isAdmin ? (hero.episodes > 1 ? ' ajoutés' : ' ajouté') : '' }}</span>
|
||||
<span class="text-primary">{{ auth.isAdmin ? plural(hero.episodes, 'épisode ajouté', 'épisodes ajoutés') : plural(hero.episodes, 'épisode') }}</span>
|
||||
{{ auth.isAdmin ? 'à la médiathèque.' : 'à regarder.' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-5 mt-1.5 flex-wrap">
|
||||
@@ -75,7 +74,7 @@
|
||||
Voir la série <ArrowRight :size="16" />
|
||||
</RouterLink>
|
||||
<RouterLink :to="auth.isAdmin ? '/activity' : '/series'" class="text-body text-secondary hover:text-primary transition-colors">
|
||||
{{ auth.isAdmin ? 'Tous les imports récents' : 'Toutes les nouveautés' }}
|
||||
{{ auth.isAdmin ? 'Tous les imports récents' : 'Parcourir la médiathèque' }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,16 +93,15 @@
|
||||
<p class="text-body text-secondary max-w-[460px]">
|
||||
{{ auth.isAdmin
|
||||
? 'Les séries importées apparaîtront ici dès la fin de leur téléchargement.'
|
||||
: 'Les séries ajoutées à la médiathèque apparaîtront ici. En attendant, parcourez le catalogue et demandez ce qui vous manque.' }}
|
||||
: 'Les séries ajoutées apparaîtront ici. En attendant, parcourez la médiathèque et demandez celles qui vous intéressent.' }}
|
||||
</p>
|
||||
<RouterLink to="/series" class="btn-secondary w-fit mt-1">Parcourir la médiathèque</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Récemment ajoutés -->
|
||||
<section v-if="recentList.length > 0" class="flex flex-col gap-3.5 max-lg:order-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="section-title">Récemment ajoutés</h2>
|
||||
<h2 class="section-title">Récemment ajoutées</h2>
|
||||
<RouterLink to="/series" class="text-[13px] text-secondary hover:text-primary transition-colors flex items-center gap-1">
|
||||
Médiathèque <ChevronRight :size="14" />
|
||||
</RouterLink>
|
||||
@@ -120,12 +118,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Chiffres -->
|
||||
<section data-tour="dashboard-stats" class="border-t border-card pt-[18px] max-lg:order-5">
|
||||
<div v-if="auth.isAdmin" class="flex flex-col sm:flex-row gap-3 sm:gap-0">
|
||||
<div class="flex-1 flex items-baseline gap-2.5">
|
||||
<span class="font-display text-[34px] font-bold text-primary">{{ stats.catalogue }}</span>
|
||||
<span class="text-[13px] text-muted">séries au catalogue</span>
|
||||
<span class="text-[13px] text-muted">{{ stats.catalogue > 1 ? 'séries au catalogue' : 'série au catalogue' }}</span>
|
||||
</div>
|
||||
<div class="hidden sm:block w-px bg-card mx-6" />
|
||||
<div class="flex-1 flex items-baseline gap-2.5">
|
||||
@@ -146,12 +143,11 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Panneau debug -->
|
||||
<section v-if="devMode" class="flex flex-col gap-3 max-lg:order-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h2 class="section-title">Diagnostic</h2>
|
||||
<span class="pill pill-wait">DEV</span>
|
||||
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Rafraîchir</button>
|
||||
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
|
||||
</div>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div v-for="d in debugCards" :key="d.label" class="card flex flex-col gap-1">
|
||||
@@ -160,11 +156,11 @@
|
||||
<span class="text-meta text-muted">{{ d.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card text-meta text-muted py-4 text-center">Chargement des stats…</div>
|
||||
<div v-else class="card text-meta text-muted py-4 text-center">Chargement…</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Admin --- -->
|
||||
<!-- ── Colonne droite : admin ── -->
|
||||
<div v-if="auth.isAdmin" class="flex flex-col gap-7 min-w-0 max-lg:contents">
|
||||
|
||||
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
|
||||
@@ -177,10 +173,9 @@
|
||||
<div v-if="loadingTodo" class="flex items-center gap-2 text-muted text-meta px-[18px] py-5">
|
||||
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
|
||||
</div>
|
||||
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur.</p>
|
||||
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur ni dossier à renommer.</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Demandes -->
|
||||
<div v-for="req in visibleRequests" :key="req.id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
|
||||
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px]" />
|
||||
@@ -193,7 +188,7 @@
|
||||
<span v-if="req.hasTorrents === false" class="text-meta text-err flex items-center gap-1.5"><TriangleAlert :size="13" /> Aucun torrent disponible</span>
|
||||
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
|
||||
<button @click="approve(req)" :disabled="busy[req.id]" class="btn-sm max-sm:flex-1" :class="req.hasTorrents === false ? 'btn-secondary' : 'btn-primary'">
|
||||
{{ req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
|
||||
{{ req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
|
||||
</button>
|
||||
<button @click="rejectTarget = req" class="text-meta text-secondary hover:text-err transition-colors max-sm:hidden">Refuser</button>
|
||||
<button @click="rejectTarget = req" class="btn-icon btn-sm sm:hidden hover:text-err" title="Refuser" aria-label="Refuser la demande"><X :size="15" /></button>
|
||||
@@ -201,7 +196,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imports en erreur -->
|
||||
<div v-for="err in visibleErrors" :key="err.hash" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<span class="w-10 h-10 rounded-field bg-err/12 text-err flex items-center justify-center shrink-0"><TriangleAlert :size="18" :stroke-width="1.75" /></span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
@@ -213,18 +207,17 @@
|
||||
{{ busy[err.hash] ? 'Import…' : 'Réessayer' }}
|
||||
</button>
|
||||
<button v-if="err.serieId" @click="openManualImport(err)" class="text-meta text-secondary hover:text-primary transition-colors max-sm:h-10 max-sm:px-2">Import manuel</button>
|
||||
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir dans Activité</RouterLink>
|
||||
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir l'activité</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dossiers à renommer -->
|
||||
<div v-for="f in visibleFolders" :key="f.serie_id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<span class="w-10 h-10 rounded-field bg-accent/10 text-accent flex items-center justify-center shrink-0"><Folder :size="18" :stroke-width="1.75" /></span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="tag-label">Dossier à renommer</span>
|
||||
<RouterLink :to="`/series/${f.serie_id}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ f.serie_title }}</RouterLink>
|
||||
<span class="text-meta text-secondary break-words">{{ f.current.map(basename).join(', ') }} → {{ basename(f.expected) }}</span>
|
||||
<span class="text-meta text-secondary break-words">Actuel : {{ f.current.map(basename).join(', ') }} · Attendu : {{ basename(f.expected) }}</span>
|
||||
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] max-sm:-ml-[54px]">
|
||||
<button @click="renameFolder(f)" :disabled="busy[`folder-${f.serie_id}`]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
|
||||
{{ busy[`folder-${f.serie_id}`] ? 'Renommage…' : 'Renommer le dossier' }}
|
||||
@@ -259,13 +252,13 @@
|
||||
<div class="progress-bar" :class="{ 'bg-muted': t.state === 'paused', 'bg-err': t.state === 'error' }" :style="{ width: `${Math.min(100, t.progress)}%` }" />
|
||||
</div>
|
||||
<span class="text-xs text-muted">
|
||||
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
|
||||
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0 && formatDuration(t.eta) !== '∞'"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Colonne droite : invité ─────────────────────────── -->
|
||||
<!-- ── Colonne droite : invité ── -->
|
||||
<div v-else class="flex flex-col gap-7 min-w-0 max-lg:contents">
|
||||
|
||||
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
|
||||
@@ -380,7 +373,7 @@ const manualImport = ref<{ serieId: number; serieName: string; seasons: any[]
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Séries et utilitaires ─────────────────────────────
|
||||
// ── Séries et utilitaires ──
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
|
||||
const titleOf = (id: number, fallback: string) => seriesById.value.get(id)?.title ?? fallback
|
||||
@@ -395,7 +388,7 @@ const recentList = computed(() => recentWithSerie.value.slice(1, 7))
|
||||
const heroTitle = computed(() => splitTitle(hero.value?.serie.title ?? ''))
|
||||
const heroMeta = computed(() => [hero.value?.serie.year, statusLabel(hero.value?.serie.status)].filter(Boolean).map(String))
|
||||
|
||||
// ── Chiffres
|
||||
// ── Chiffres ──
|
||||
const stats = computed(() => ({
|
||||
catalogue : catalogueCount.value || seriesStore.series.length,
|
||||
imported : seriesStore.series.filter(s => s.download_state === 'complete').length,
|
||||
@@ -412,8 +405,7 @@ function torrentStateLabel(state: string) {
|
||||
return ({ paused: 'en pause', checking: 'vérification', error: 'erreur du client' } as Record<string, string>)[state] ?? state
|
||||
}
|
||||
|
||||
// ── À traiter (admin)
|
||||
// Les plus anciennes d'abord
|
||||
// ── À traiter (admin) ──
|
||||
const pendingRequests = computed(() => requests.value.filter(r => r.status === 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
|
||||
|
||||
|
||||
@@ -459,7 +451,7 @@ const overflowLinks = computed(() => {
|
||||
return links
|
||||
})
|
||||
|
||||
// ── Invité ────────────────────────────────────────────────────
|
||||
// ── Invité ──
|
||||
const myPendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length)
|
||||
|
||||
const guestStatus: Record<string, { label: string; class: string }> = {
|
||||
@@ -476,7 +468,7 @@ const notYetHere = computed(() => {
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
// ── Chargement ──
|
||||
async function fetchSettings() {
|
||||
if (!auth.isAdmin) return
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
@@ -493,12 +485,12 @@ async function fetchDebug() {
|
||||
}
|
||||
|
||||
const debugCards = computed(() => debug.value ? [
|
||||
{ label: 'Heap utilisé', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
|
||||
{ label: 'RSS', value: `${debug.value.memory.rss} Mo`, hint: 'mémoire système' },
|
||||
{ label: 'Uptime', value: debug.value.uptime, hint: 'depuis le démarrage' },
|
||||
{ label: 'Cache GitHub', value: debug.value.cache.entries, hint: `entrées (TTL ${debug.value.cache.ttlHours} h)` },
|
||||
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
|
||||
{ label: 'Worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', hint: `${debug.value.requests.notifs} import(s) en mémoire`, accent: debug.value.worker.running },
|
||||
{ label: 'Mémoire utilisée', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
|
||||
{ label: 'Mémoire système (RSS)', value: `${debug.value.memory.rss} Mo`, hint: 'occupée par le serveur' },
|
||||
{ label: 'Durée de fonctionnement', value: debug.value.uptime, hint: 'depuis le démarrage' },
|
||||
{ label: 'Entrées en cache', value: debug.value.cache.entries, hint: `gardées ${debug.value.cache.ttlHours} h` },
|
||||
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
|
||||
{ label: "Tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', hint: `${plural(debug.value.requests.notifs, 'import récent', 'imports récents')} en mémoire`, accent: debug.value.worker.running },
|
||||
] : [])
|
||||
|
||||
async function fetchCatalogue() {
|
||||
@@ -545,7 +537,7 @@ async function fetchFolders() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Actions
|
||||
// ── Actions ──
|
||||
async function patchRequest(req: SerieRequest, body: object) {
|
||||
busy[req.id] = true
|
||||
try {
|
||||
@@ -553,7 +545,7 @@ async function patchRequest(req: SerieRequest, body: object) {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
|
||||
await fetchRequests()
|
||||
reqStore.refreshPending()
|
||||
return true
|
||||
@@ -580,9 +572,9 @@ async function retryImport(err: { hash: string; torrent: any }) {
|
||||
body: JSON.stringify({ hash: err.torrent.hash, save_path: err.torrent.save_path, name: err.torrent.name }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (!res.ok) toast(d.error ?? "Erreur lors de l'import", 'error')
|
||||
else if (d.errors?.length) toast(`${d.done} importé(s), ${d.errors.length} erreur(s)`, 'error')
|
||||
else toast(`${d.done} fichier(s) importé(s) ✓`, 'success')
|
||||
if (!res.ok) toast(d.error ?? "Impossible d'importer ce torrent", 'error')
|
||||
else if (d.errors?.length) toast(`${plural(d.done, 'fichier importé', 'fichiers importés')}, ${plural(d.errors.length, 'erreur')}`, 'error')
|
||||
else toast(plural(d.done, 'fichier importé', 'fichiers importés'), 'success')
|
||||
await Promise.all([fetchTorrents(), fetchRecent()])
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[err.hash] = false }
|
||||
@@ -609,14 +601,14 @@ async function openManualImport(err: { serieId: number | null; title: string })
|
||||
}
|
||||
|
||||
async function renameFolder(f: { serie_id: number; expected: string }) {
|
||||
if (!confirm(`Déplacer le contenu vers « ${basename(f.expected)} » ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
if (!confirm(`Renommer le dossier en « ${basename(f.expected)} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
|
||||
const key = `folder-${f.serie_id}`
|
||||
busy[key] = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
|
||||
toast(`Dossier renommé ✓ (${plural(d.moved, 'fichier')} ${d.moved > 1 ? 'déplacés' : 'déplacé'})`, 'success')
|
||||
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
|
||||
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
|
||||
await fetchFolders()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[key] = false }
|
||||
@@ -630,8 +622,8 @@ async function requestSerie(s: Serie) {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieId: s.id, serieName: s.title, seasons: [] }),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
toast(`${s.title} demandée ✓`, 'success')
|
||||
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
|
||||
toast(`« ${s.title} » demandée`, 'success')
|
||||
await fetchRequests()
|
||||
reqStore.refreshPending()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
|
||||
+20
-22
@@ -2,25 +2,24 @@
|
||||
<div class="px-4 md:px-10 pt-[26px] pb-12">
|
||||
<div class="max-w-[1400px] flex flex-col gap-4">
|
||||
|
||||
<!-- En-tête -->
|
||||
<header data-tour="activity-header" class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5 flex-wrap">
|
||||
<h1 class="page-title tracking-[0.01em]">Activité</h1>
|
||||
<span class="flex items-center gap-[7px] text-meta text-secondary">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-ok animate-pulse' : 'bg-muted'" />
|
||||
{{ polling ? 'En direct' : 'En pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
|
||||
{{ polling ? 'Actualisation auto' : 'Actualisation en pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button @click="importAll" :disabled="importingAll || toImportCount === 0" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Loader v-if="importingAll" :size="15" class="animate-spin" />
|
||||
<Upload v-else :size="15" :stroke-width="2.25" />
|
||||
Importer tout<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
|
||||
Tout importer<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
|
||||
</button>
|
||||
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Rafraîchir" aria-label="Rafraîchir">
|
||||
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
|
||||
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Mettre en pause' : 'Reprendre'" :aria-label="polling ? 'Mettre en pause' : 'Reprendre'">
|
||||
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'" :aria-label="polling ? 'Suspendre l\'actualisation' : 'Reprendre l\'actualisation'">
|
||||
<Pause v-if="polling" :size="16" />
|
||||
<Play v-else :size="16" />
|
||||
</button>
|
||||
@@ -42,26 +41,22 @@
|
||||
@toggle-col="(key) => (columns as any)[key] = !(columns as any)[key]"
|
||||
/>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">Aucun client torrent configuré</p>
|
||||
<p class="text-body text-muted">Ajoutez un client pour suivre les téléchargements FanKarr.</p>
|
||||
<RouterLink to="/settings/download-client" class="btn-secondary btn-sm mt-1">Configurer un client</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="visibleTorrents.length === 0" class="card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">{{ emptyState.title }}</p>
|
||||
<p class="text-body text-muted">{{ emptyState.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Une ligne par torrent -->
|
||||
<div v-else class="flex flex-col gap-2.5">
|
||||
<TorrentCard
|
||||
v-for="t in visibleTorrents"
|
||||
@@ -90,6 +85,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { Loader, Pause, Play, RefreshCw, Upload } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { plural } from '@/utils/format'
|
||||
import TorrentCard from '@/components/downloads/TorrentCard.vue'
|
||||
import DownloadsToolbar, { type ActivityTab } from '@/components/downloads/DownloadsToolbar.vue'
|
||||
|
||||
@@ -120,7 +116,7 @@ const columnOptions = [
|
||||
{ key: 'size', label: 'Taille / téléchargé' },
|
||||
{ key: 'ratio', label: 'Ratio' },
|
||||
{ key: 'uploaded', label: 'Envoyé (total)' },
|
||||
{ key: 'upspeed', label: 'Vitesse d’envoi' },
|
||||
{ key: 'upspeed', label: "Vitesse d'envoi" },
|
||||
]
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
@@ -133,7 +129,7 @@ const sortOptions = [
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let clockInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Onglets ───────────────────────────────────────────────────
|
||||
// ── Onglets ──
|
||||
const isActive = (t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)
|
||||
const isDone = (t: any) => t.state === 'seeding' || t.state === 'unknown'
|
||||
const isToImport = (t: any) => isDone(t) && t.organizeState !== 'done'
|
||||
@@ -185,13 +181,13 @@ const visibleTorrents = computed(() => {
|
||||
})
|
||||
|
||||
const emptyState = computed(() => ({
|
||||
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents de la catégorie « fankai » apparaîtront ici.' },
|
||||
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents envoyés par FanKarr apparaîtront ici.' },
|
||||
'done' : { title: 'Aucun torrent terminé', hint: hideImported.value ? 'Tous les torrents terminés sont importés.' : '' },
|
||||
'to-import': { title: 'Rien à importer', hint: 'Tous les torrents terminés sont dans la médiathèque.' },
|
||||
'errors' : { title: 'Aucune erreur', hint: 'Ni le client ni l’import ne signalent de problème.' },
|
||||
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents de la catégorie « fankai » apparaîtront ici.' })
|
||||
'errors' : { title: 'Aucune erreur', hint: "Ni le client ni l'import ne signalent de problème." },
|
||||
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents envoyés par FanKarr apparaîtront ici.' })
|
||||
|
||||
// ── Présentation ──────────────────────────────────────────────
|
||||
// ── Présentation ──
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number | undefined) => (id != null ? seriesById.value.get(id)?.poster_image : null) ?? null
|
||||
|
||||
@@ -211,7 +207,7 @@ function setSort(val: string) {
|
||||
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
|
||||
}
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
// ── Chargement ──
|
||||
async function fetchTorrents() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -236,7 +232,7 @@ function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorre
|
||||
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
|
||||
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
@@ -244,8 +240,8 @@ async function importTorrent(torrent: any) {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }),
|
||||
})
|
||||
if (res.ok) { toast(`${torrent.name} importé ✓`, 'success'); await fetchTorrents() }
|
||||
else { const { error } = await res.json(); toast(error ?? "Erreur lors de l'import", 'error') }
|
||||
if (res.ok) { toast(`« ${torrent.name} » importé`, 'success'); await fetchTorrents() }
|
||||
else { const { error } = await res.json(); toast(error ?? `Impossible d'importer « ${torrent.name} »`, 'error') }
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { importing.value[torrent.hash] = false }
|
||||
}
|
||||
@@ -266,15 +262,17 @@ async function importAll() {
|
||||
}
|
||||
importingAll.value = false
|
||||
await fetchTorrents()
|
||||
errors === 0 ? toast(`${done} torrent(s) importé(s) ✓`, 'success') : toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
errors === 0
|
||||
? toast(plural(done, 'torrent importé', 'torrents importés'), 'success')
|
||||
: toast(`${plural(done, 'torrent importé', 'torrents importés')}, ${plural(errors, 'erreur')}`, 'error')
|
||||
}
|
||||
|
||||
async function deleteTorrent(torrent: any, withFiles: boolean) {
|
||||
deleting.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch(`/api/torrent/${torrent.hash}?deleteFiles=${withFiles}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) { toast(`"${torrent.name}" supprimé ✓`, 'success'); confirmDelete.value = null; await fetchTorrents() }
|
||||
else toast('Erreur lors de la suppression', 'error')
|
||||
if (res.ok) { toast(`« ${torrent.name} » supprimé`, 'success'); confirmDelete.value = null; await fetchTorrents() }
|
||||
else toast('Impossible de supprimer le torrent', 'error')
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { deleting.value[torrent.hash] = false }
|
||||
}
|
||||
|
||||
+10
-15
@@ -3,33 +3,28 @@
|
||||
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
|
||||
<div class="relative w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<FankarrLogo class="w-9 h-9" />
|
||||
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="status === 'loading'" class="card px-7 py-8 text-center text-body text-muted">
|
||||
Vérification du lien…
|
||||
</div>
|
||||
|
||||
<!-- Lien invalide -->
|
||||
<div v-else-if="status === 'invalid'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
|
||||
<span class="w-11 h-11 rounded-full bg-err/10 text-err flex items-center justify-center"><TriangleAlert :size="20" /></span>
|
||||
<p class="font-display text-xl font-bold text-primary">Lien invalide</p>
|
||||
<p class="text-body text-muted">{{ invalidReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Succès -->
|
||||
<div v-else-if="status === 'done'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
|
||||
<span class="w-11 h-11 rounded-full bg-ok/10 text-ok flex items-center justify-center"><Check :size="20" :stroke-width="2.5" /></span>
|
||||
<p class="font-display text-xl font-bold text-primary">Compte créé</p>
|
||||
<p class="text-body text-muted">Tu peux maintenant te connecter.</p>
|
||||
<p class="text-body text-muted">Vous pouvez maintenant vous connecter.</p>
|
||||
<RouterLink to="/auth" class="btn-primary w-full h-11 mt-1">Se connecter</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'inscription -->
|
||||
<div v-else class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="tag-label text-accent">Invitation FanKarr</p>
|
||||
@@ -39,22 +34,22 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-username" class="field-label">Identifiant</label>
|
||||
<input id="invite-username" v-model="username" type="text" placeholder="username" class="field" @keyup.enter="submit" />
|
||||
<label for="invite-username" class="field-label">Nom d'utilisateur</label>
|
||||
<input id="invite-username" v-model="username" type="text" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-password" class="field-label">Mot de passe</label>
|
||||
<input id="invite-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-confirm" class="field-label">Confirmer</label>
|
||||
<label for="invite-confirm" class="field-label">Confirmer le mot de passe</label>
|
||||
<input id="invite-confirm" v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
|
||||
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
|
||||
{{ submitting ? '…' : 'Créer le compte' }}
|
||||
{{ submitting ? 'Création…' : 'Créer le compte' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,7 +83,7 @@ onMounted(async () => {
|
||||
const res = await fetch(`/api/invites/${code}/info`)
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.valid) {
|
||||
invalidReason.value = data.reason ?? 'Lien invalide'
|
||||
invalidReason.value = data.reason ?? "Ce lien d'invitation n'est pas valide. Demandez-en un nouveau à l'administrateur."
|
||||
status.value = 'invalid'
|
||||
return
|
||||
}
|
||||
@@ -99,10 +94,10 @@ onMounted(async () => {
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!username.value || !password.value || !confirm.value) {
|
||||
error.value = 'Tous les champs sont requis'; return
|
||||
error.value = 'Tous les champs sont requis.'; return
|
||||
}
|
||||
if (password.value !== confirm.value) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'; return
|
||||
error.value = 'Les mots de passe ne correspondent pas.'; return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
@@ -111,10 +106,10 @@ async function submit() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({ username: username.value, password: password.value }),
|
||||
})
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
submitting.value = false
|
||||
|
||||
if (!res.ok) { error.value = data.error; return }
|
||||
if (!res.ok) { error.value = data.error ?? 'Impossible de créer le compte.'; return }
|
||||
status.value = 'done'
|
||||
}
|
||||
</script>
|
||||
+31
-35
@@ -17,7 +17,7 @@
|
||||
>
|
||||
<Loader v-if="bulkBusy" :size="15" class="animate-spin" />
|
||||
<Check v-else :size="15" :stroke-width="2.25" />
|
||||
Approuver {{ approvableCount > 1 ? `les ${approvableCount}` : 'la demande' }}<span v-if="approvableCount > 1" class="max-sm:hidden"> demandes</span> avec torrent
|
||||
Approuver et télécharger ({{ approvableCount }})
|
||||
</button>
|
||||
<div ref="headerMenuRef" class="relative">
|
||||
<button @click="headerMenuOpen = !headerMenuOpen" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="headerMenuOpen">
|
||||
@@ -36,7 +36,6 @@
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<!-- Cmpteurs -->
|
||||
<div class="chip-row">
|
||||
<button
|
||||
v-for="f in visibleFilters" :key="f.value"
|
||||
@@ -49,7 +48,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-muted text-body py-8">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
@@ -58,7 +56,7 @@
|
||||
<div v-else-if="filtered.length === 0" class="card flex flex-col items-center gap-2 py-12 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">Aucune demande{{ activeFilter !== 'all' ? ' avec ce statut' : '' }}</p>
|
||||
<p class="text-body text-muted">
|
||||
{{ auth.isAdmin ? 'Les demandes arrivent de FanKarr ou du plugin Jellyfin.' : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
|
||||
{{ auth.isAdmin ? "Les demandes des invités (depuis FanKarr ou le plugin Jellyfin) s'afficheront ici." : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -90,27 +88,25 @@
|
||||
|
||||
<span class="text-body" :class="req.status === 'rejected' ? 'text-muted' : 'text-secondary'">{{ scopeLine(req) }}</span>
|
||||
|
||||
<!-- Admin : demandeurs et avertissement torrent -->
|
||||
<div v-if="auth.isAdmin && (req.status === 'pending' || req.status === 'approved')" class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-for="r in req.requesters" :key="r.userId"
|
||||
class="flex items-center gap-[7px] h-[26px] pl-1 pr-2.5 rounded-full bg-hover text-meta text-secondary"
|
||||
:title="`Demandé ${formatRelative(r.requestedAt)}`"
|
||||
:title="`Demande envoyée ${formatRelative(r.requestedAt)}`"
|
||||
>
|
||||
<span class="w-[18px] h-[18px] rounded-full bg-border text-primary text-[10px] font-bold flex items-center justify-center">{{ r.username.charAt(0).toUpperCase() }}</span>
|
||||
{{ r.username }}<span v-if="req.requesters.length > 1 && requesterScope(r)" class="text-muted">· {{ requesterScope(r) }}</span>
|
||||
</span>
|
||||
<span v-if="req.status === 'pending' && req.hasTorrents === false" class="flex items-center gap-[7px] text-meta text-err">
|
||||
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible — approuver ne lancera aucun téléchargement.
|
||||
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible : l'approbation ne lancera pas de téléchargement.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="auth.isAdmin && req.status === 'approved'" class="text-meta text-secondary">
|
||||
Téléchargement lancé à l'approbation.
|
||||
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir dans Activité</RouterLink>
|
||||
{{ req.hasTorrents === false ? 'Approuvée sans torrent : rien n\'a été téléchargé.' : 'Téléchargement lancé à l\'approbation.' }}
|
||||
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir l'activité</RouterLink>
|
||||
</p>
|
||||
|
||||
<!-- Invité : où en est la demande -->
|
||||
<p v-if="!auth.isAdmin && req.status !== 'rejected'" class="text-meta text-muted">{{ guestStatusLine[req.status] }}</p>
|
||||
|
||||
<p v-if="req.status === 'rejected'" class="text-meta text-err">
|
||||
@@ -119,7 +115,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions admin -->
|
||||
<div v-if="auth.isAdmin" class="flex max-sm:items-center flex-col max-sm:flex-row gap-2 shrink-0 sm:w-52">
|
||||
<button
|
||||
v-if="req.status === 'pending' || req.status === 'rejected'"
|
||||
@@ -131,14 +126,14 @@
|
||||
: 'border border-border text-primary font-semibold hover:bg-hover'"
|
||||
>
|
||||
<Check v-if="req.status === 'pending' && req.hasTorrents !== false" :size="15" :stroke-width="2.25" />
|
||||
{{ req.status === 'rejected' ? 'Approuver malgré tout' : req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
|
||||
{{ req.status === 'rejected' ? 'Approuver finalement' : req.hasTorrents === false ? 'Approuver sans télécharger' : 'Approuver et télécharger' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="req.status === 'approved'"
|
||||
@click="complete(req)"
|
||||
:disabled="busy[req.id]"
|
||||
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors disabled:opacity-50"
|
||||
>Marquer disponible</button>
|
||||
>Marquer comme disponible</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="`/series/${req.serieId}`"
|
||||
@@ -150,8 +145,8 @@
|
||||
v-if="req.status === 'pending' || req.status === 'approved'"
|
||||
@click="rejectTarget = req"
|
||||
class="flex-1 h-[34px] max-sm:flex-none max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors flex items-center justify-center"
|
||||
:aria-label="req.status === 'approved' ? 'Annuler la demande' : 'Refuser la demande'"
|
||||
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">{{ req.status === 'approved' ? 'Annuler' : 'Refuser' }}</span></button>
|
||||
aria-label="Refuser la demande"
|
||||
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">Refuser</span></button>
|
||||
<button
|
||||
v-else
|
||||
@click="remove(req)"
|
||||
@@ -182,7 +177,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions invité -->
|
||||
<div v-else class="flex flex-col max-sm:flex-row gap-2 shrink-0 sm:w-[190px]">
|
||||
<RouterLink
|
||||
:to="`/series/${req.serieId}`"
|
||||
@@ -199,7 +193,7 @@
|
||||
@click="requestAgain(req)"
|
||||
:disabled="busy[req.id]"
|
||||
class="h-[34px] max-sm:h-11 max-sm:flex-1 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-primary hover:bg-hover transition-colors disabled:opacity-50"
|
||||
>Redemander</button>
|
||||
>Demander à nouveau</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -266,7 +260,7 @@ const filtered = computed(() => {
|
||||
|
||||
const approvableCount = computed(() => requests.value.filter(r => r.status === 'pending' && r.hasTorrents).length)
|
||||
|
||||
// ── Présentation ──────────────────────────────────────────────
|
||||
// ── Présentation ──
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
|
||||
const titleOf = (req: SerieRequest) => seriesById.value.get(req.serieId)?.title ?? req.serieName
|
||||
@@ -288,31 +282,31 @@ function dateLabel(req: SerieRequest) {
|
||||
return `ajoutée ${formatRelative(req.updatedAt)}`
|
||||
}
|
||||
|
||||
// « Saisons 3 et 4 · 8 épisodes concernés », « 2 épisodes ciblés », « Saison 3 · demandée par marco »
|
||||
// « Saisons 3 et 4 · 8 épisodes demandés », « 2 épisodes demandés », « Saison 3 · demandée par marco »
|
||||
function scopeLine(req: SerieRequest) {
|
||||
const episodes = mergedEpisodes(req)
|
||||
const parts = [episodes.length > 0 ? `${plural(episodes.length, 'épisode')} ${episodes.length > 1 ? 'ciblés' : 'ciblé'}` : seasonsLabel(mergedSeasons(req))]
|
||||
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(`${plural(req.episodeCount, 'épisode')} ${req.episodeCount > 1 ? 'concernés' : 'concerné'}`)
|
||||
const parts = [episodes.length > 0 ? plural(episodes.length, 'épisode demandé', 'épisodes demandés') : seasonsLabel(mergedSeasons(req))]
|
||||
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(plural(req.episodeCount, 'épisode demandé', 'épisodes demandés'))
|
||||
if (auth.isAdmin) {
|
||||
if (req.status !== 'pending') parts.push(`demandée par ${req.requesters.map(r => r.username).join(', ')}`)
|
||||
} else if (req.requesters.length > 1) {
|
||||
parts.push('demandée aussi par quelqu’un d’autre')
|
||||
parts.push("demandée aussi par quelqu'un d'autre")
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function requesterScope(r: Requester) {
|
||||
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'ép.', 'ép.')
|
||||
return r.seasons.map(s => s === 0 ? 'SP' : `S${s}`).join(', ')
|
||||
return r.seasons.map(s => s === 0 ? 'Spéciaux' : `S${s}`).join(', ')
|
||||
}
|
||||
|
||||
const guestStatusLine: Record<string, string> = {
|
||||
pending : 'L’administrateur n’a pas encore répondu.',
|
||||
pending : "L'administrateur n'a pas encore répondu.",
|
||||
approved : 'Demande approuvée : la série arrive bientôt.',
|
||||
completed: 'Prête à regarder dans la médiathèque.',
|
||||
}
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
// ── Chargement ──
|
||||
async function load() {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) requests.value = await res.json()
|
||||
@@ -327,14 +321,14 @@ onMounted(() => {
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', closeCardMenu))
|
||||
|
||||
// ── Menus ─────────────────────────────────────────────────────
|
||||
// ── Menus ──
|
||||
const cardMenu = ref<string | null>(null)
|
||||
const headerMenuOpen = ref(false)
|
||||
const headerMenuRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(headerMenuRef, () => { headerMenuOpen.value = false })
|
||||
function closeCardMenu() { cardMenu.value = null }
|
||||
|
||||
// ── Actions admin ─────────────────────────────────────────────
|
||||
// ── Actions admin ──
|
||||
async function patch(req: SerieRequest, body: object): Promise<boolean> {
|
||||
busy[req.id] = true
|
||||
try {
|
||||
@@ -342,7 +336,7 @@ async function patch(req: SerieRequest, body: object): Promise<boolean> {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Impossible de mettre à jour la demande', 'error'); return false }
|
||||
return true
|
||||
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
|
||||
finally { busy[req.id] = false }
|
||||
@@ -362,7 +356,9 @@ async function approveAllWithTorrents() {
|
||||
let done = 0
|
||||
for (const req of targets) if (await patch(req, { action: 'approve' })) done++
|
||||
bulkBusy.value = false
|
||||
toast(`${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`, done === targets.length ? 'success' : 'error')
|
||||
toast(done === targets.length
|
||||
? `${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`
|
||||
: `${plural(done, 'demande approuvée', 'demandes approuvées')} sur ${targets.length}`, done === targets.length ? 'success' : 'error')
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -371,7 +367,7 @@ async function complete(req: SerieRequest) {
|
||||
}
|
||||
|
||||
async function remove(req: SerieRequest) {
|
||||
if (!confirm(`Supprimer la demande pour "${titleOf(req)}" ?`)) return
|
||||
if (!confirm(`Supprimer la demande pour « ${titleOf(req)} » ?`)) return
|
||||
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
load()
|
||||
}
|
||||
@@ -379,7 +375,7 @@ async function remove(req: SerieRequest) {
|
||||
async function removeAll() {
|
||||
const n = requests.value.length
|
||||
if (!n) return
|
||||
if (!confirm(`Supprimer les ${n} demande${n > 1 ? 's' : ''} ?`)) return
|
||||
if (!confirm(n > 1 ? `Supprimer les ${n} demandes ?` : 'Supprimer la demande ?')) return
|
||||
await fetch('/api/requests', { method: 'DELETE', credentials: 'include' })
|
||||
load()
|
||||
}
|
||||
@@ -391,13 +387,13 @@ async function confirmReject(message: string) {
|
||||
if (req && await patch(req, { action: 'reject', rejectionMessage: message || undefined })) load()
|
||||
}
|
||||
|
||||
// ── Actions invité ────────────────────────────────────────────
|
||||
// ── Actions invité ──
|
||||
async function withdraw(req: SerieRequest) {
|
||||
if (!confirm(`Annuler votre demande pour "${titleOf(req)}" ?`)) return
|
||||
if (!confirm(`Annuler votre demande pour « ${titleOf(req)} » ?`)) return
|
||||
busy[req.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/requests/${req.id}/mine`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'annuler la demande", 'error'); return }
|
||||
toast('Demande annulée', 'success')
|
||||
load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
@@ -412,7 +408,7 @@ async function requestAgain(req: SerieRequest) {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieId: req.serieId, serieName: req.serieName, seasons: mine?.seasons ?? [], episodes: mine?.episodes ?? [] }),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
if (!res.ok) { toast((await res.json()).error ?? "Impossible d'envoyer la demande", 'error'); return }
|
||||
toast('Nouvelle demande envoyée', 'success')
|
||||
load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
|
||||
+47
-78
@@ -1,20 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
|
||||
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-body">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-body text-err">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="load">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Bandeau : affiche, titre, progression et actions -->
|
||||
<SerieHero
|
||||
:serie="data.serie"
|
||||
:season-count="seasonCount"
|
||||
@@ -24,7 +21,6 @@
|
||||
:watched="auth.isAdmin && rssSync"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- Admin -->
|
||||
<template v-if="auth.isAdmin">
|
||||
<div v-if="hasSomethingToDownload" class="relative max-sm:flex-1 max-sm:min-w-0" @click.stop>
|
||||
<button
|
||||
@@ -38,7 +34,6 @@
|
||||
<ChevronDown v-if="data.torrents_integrale.length > 1" :size="13" :stroke-width="2.5" />
|
||||
</button>
|
||||
|
||||
<!-- Le choix -->
|
||||
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1" class="menu absolute left-0 top-full mt-1.5 w-80 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
@@ -75,13 +70,12 @@
|
||||
class="menu-item text-err hover:text-err"
|
||||
@click="openUnimportSerieModal(); moreMenuOpen = false"
|
||||
>
|
||||
<Trash2 :size="14" class="shrink-0" /> Supprimer la série…
|
||||
<Trash2 :size="14" class="shrink-0" /> Retirer de la médiathèque…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Invité -->
|
||||
<template v-else>
|
||||
<button v-if="canRequestMore" @click="openRequestModal()" class="btn-primary max-sm:flex-1 max-sm:min-w-0">
|
||||
<Clock3 :size="16" :stroke-width="2.25" />
|
||||
@@ -104,9 +98,9 @@
|
||||
<div v-if="auth.isAdmin && !data.scraper_synced" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Série absente du scraper</p>
|
||||
<p class="card-title text-accent">Torrents pas encore disponibles</p>
|
||||
<p class="text-meta text-secondary">
|
||||
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
|
||||
Cette série n'est pas encore dans les données du catalogue. L'import manuel et le renommage restent possibles ; les torrents apparaîtront après la prochaine synchronisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,9 +110,9 @@
|
||||
<div class="flex items-start gap-3.5 min-w-0">
|
||||
<Folder :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<p class="card-title">Dossier différent du titre de la série</p>
|
||||
<p class="card-title">Le nom du dossier ne correspond plus au titre</p>
|
||||
<p class="text-meta text-secondary break-all">
|
||||
{{ staleFolders.map(f => basename(f.path)).join(', ') }} → <span class="text-primary">{{ folderTarget }}</span>
|
||||
Actuel : {{ staleFolders.map(f => basename(f.path)).join(', ') }} · Attendu : <span class="text-primary">{{ folderTarget }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,7 +121,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<SerieSeasonCard
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
@@ -155,7 +148,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modal demande (utilisateurs) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="requestModal" class="modal-backdrop" @click.self="requestModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="request-title">
|
||||
@@ -178,7 +170,7 @@
|
||||
/>
|
||||
<span class="text-body text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">— {{ season.title }}</span>
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">· {{ season.title }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -193,29 +185,27 @@
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<!-- Modal désimport série -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSerieModal" class="modal-backdrop" @click.self="unimportSerieModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-serie-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="unimport-serie-title" class="card-title">Supprimer la série</h2>
|
||||
<h2 id="unimport-serie-title" class="card-title">Retirer de la médiathèque</h2>
|
||||
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer également les fichiers physiques du disque</span>
|
||||
<span class="text-body text-secondary">Supprimer aussi les fichiers du disque</span>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2.5">
|
||||
<button @click="unimportSerieModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimportSerie" class="btn-danger">
|
||||
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
|
||||
{{ deleteSerieFiles ? 'Retirer et supprimer les fichiers' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal import manuel -->
|
||||
<ManualImportModal
|
||||
v-if="manualImportOpen && data"
|
||||
:serie-id="Number(route.params.id)"
|
||||
@@ -249,17 +239,16 @@ const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ── Mode demande (utilisateurs non-admin) ─────────────────────
|
||||
// ── Demandes (invités) ──
|
||||
const myRequest = ref<any>(null) // demande existante de l'utilisateur pour cette série
|
||||
const requestModal = ref(false)
|
||||
const requestSeasons = ref<number[]>([]) // saisons cochées dans le modal
|
||||
const requestSeasons = ref<number[]>([])
|
||||
|
||||
const requestedSeasonNumbers = computed<number[]>(() => {
|
||||
if (!myRequest.value) return []
|
||||
const r = myRequest.value.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
if (!r) return []
|
||||
// seasons=[] ET episodes=[] → toutes les saisons demandées
|
||||
// seasons=[] mais episodes=[…] → seulement des épisodes ciblés, pas toute une saison
|
||||
// Ni saison ni épisode précisés = série entière
|
||||
if (r.seasons.length === 0 && (r.episodes ?? []).length === 0) {
|
||||
return data.value?.seasons?.map((s: any) => s.season_number) ?? []
|
||||
}
|
||||
@@ -287,7 +276,6 @@ async function fetchMyRequest() {
|
||||
}
|
||||
|
||||
function openRequestModal(preselectedSeason?: number) {
|
||||
// Pré-cocher soit la saison cliquée, soit toutes les saisons disponibles
|
||||
if (preselectedSeason !== undefined) {
|
||||
requestSeasons.value = [preselectedSeason]
|
||||
} else {
|
||||
@@ -326,10 +314,10 @@ async function handleRequestEpisode(_seasonNumber: number, episodeId: number, to
|
||||
})
|
||||
if (res.ok) {
|
||||
myRequest.value = await res.json()
|
||||
toast('Épisode demandé ✓', 'success')
|
||||
toast('Épisode demandé', 'success')
|
||||
} else {
|
||||
const d = await res.json()
|
||||
toast(d.error ?? 'Erreur', 'error')
|
||||
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
@@ -350,10 +338,10 @@ async function submitRequest() {
|
||||
if (res.ok) {
|
||||
myRequest.value = await res.json()
|
||||
requestModal.value = false
|
||||
toast('Demande envoyée ✓', 'success')
|
||||
toast('Demande envoyée', 'success')
|
||||
} else {
|
||||
const d = await res.json()
|
||||
toast(d.error ?? 'Erreur', 'error')
|
||||
toast(d.error ?? "Impossible d'envoyer la demande", 'error')
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
@@ -392,9 +380,9 @@ export interface ActiveTorrent { hash: string; progress: number; state: string;
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Suivi des états précédents pour détecter les transitions (download → seeding)
|
||||
const _prevTorrentStates = new Map<string, string>() // hash → state
|
||||
const _prevFileProgresses = new Map<string, number>() // hash:index → progress
|
||||
// État au poll précédent, pour repérer les téléchargements terminés
|
||||
const _prevTorrentStates = new Map<string, string>()
|
||||
const _prevFileProgresses = new Map<string, number>()
|
||||
const _recentlyOrganized = new Set<string>() // hash:fileIndex ou hash seul
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
@@ -404,21 +392,19 @@ const episodeCount = computed(() => data.value?.seasons.reduce((n, s) => n + s
|
||||
const organizedCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.filter(e => e.organized).length, 0) ?? 0)
|
||||
const allOrganized = computed(() => episodeCount.value > 0 && organizedCount.value >= episodeCount.value)
|
||||
|
||||
// Invité : reste-t-il des saisons à demander ?
|
||||
const canRequestMore = computed(() => {
|
||||
if (!data.value || allOrganized.value) return false
|
||||
if (!myRequest.value) return true
|
||||
return data.value.seasons.some(s => s.organized_state !== 'complete' && !requestedSeasonNumbers.value.includes(s.season_number))
|
||||
})
|
||||
|
||||
// ── Helpers intégrale ─────────────────────────────────────────
|
||||
// ── Intégrales ──
|
||||
function integraleGroupLabels(): string[] {
|
||||
const torrents = data.value?.torrents_integrale ?? []
|
||||
const names = torrents.map((t: any) => t.torrent_name ?? null)
|
||||
const allPresent = names.every((n: any) => n !== null && String(n).trim() !== '')
|
||||
const allUnique = allPresent && new Set(names).size === names.length
|
||||
if (allUnique) return names as string[]
|
||||
// Fallback : titre Nyaa brut
|
||||
return torrents.map((t: any, i: number) => {
|
||||
const raw = t.raw ?? ''
|
||||
if (raw) return raw.length > 65 ? raw.slice(0, 65) + '…' : raw
|
||||
@@ -426,9 +412,7 @@ function integraleGroupLabels(): string[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Épisodes couverts par une intégrale donnée (hash commun)
|
||||
// Épisodes déjà présents dans la médiathèque — sert de garde-fou pour ne pas
|
||||
// redescendre une autre version (x264 / x265) de fichiers déjà importés.
|
||||
// Épisodes déjà importés : évite de retélécharger une autre version (x264/x265)
|
||||
const organizedEpisodeIds = computed(() => {
|
||||
const ids = new Set<number>()
|
||||
for (const season of data.value?.seasons ?? [])
|
||||
@@ -450,7 +434,6 @@ function integraleEpisodeCoverage(integraleIndex: number): Set<number> {
|
||||
return covered
|
||||
}
|
||||
|
||||
// Vrai s'il reste des épisodes/saisons non couverts par cette intégrale
|
||||
function hasUncoveredByIntegrale(integraleIndex: number): boolean {
|
||||
if (!data.value) return false
|
||||
const covered = integraleEpisodeCoverage(integraleIndex)
|
||||
@@ -460,7 +443,7 @@ function hasUncoveredByIntegrale(integraleIndex: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Helpers d'état ────────────────────────────────────────────
|
||||
// ── État local ──
|
||||
function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
|
||||
@@ -479,15 +462,14 @@ function extractHash(torrent: any): string | null {
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
// Pour un fichier dans un pack (file_index défini), le hash seul ne suffit pas :
|
||||
// le pack peut être actif sans que CE fichier spécifique soit en téléchargement.
|
||||
// Fichier d'un pack : le hash ne dit pas si ce fichier précis est en cours
|
||||
if (torrent?.file_index != null) return false
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── Fetch ─────────────────────────────────────────────────────
|
||||
// ── Chargement ──
|
||||
function load() { store.fetchSerieDetail(Number(route.params.id)) }
|
||||
|
||||
async function fetchOrganized() {
|
||||
@@ -506,13 +488,13 @@ async function fetchFolderStatus() {
|
||||
}
|
||||
|
||||
async function renameFolder() {
|
||||
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
if (!confirm(`Renommer le dossier en « ${folderTarget.value} » ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
|
||||
renamingFolder.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
|
||||
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
|
||||
if (!res.ok) { toast(d.error ?? 'Impossible de renommer le dossier', 'error'); return }
|
||||
toast(`Dossier renommé (${plural(d.moved, 'fichier déplacé', 'fichiers déplacés')})`, 'success')
|
||||
await fetchOrganized()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { renamingFolder.value = false }
|
||||
@@ -544,9 +526,9 @@ async function toggleRssSync() {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieName }),
|
||||
})
|
||||
if (res.ok) { rssSync.value = true; toast('Surveillance activée — les nouveaux épisodes seront téléchargés automatiquement ✓', 'success') }
|
||||
if (res.ok) { rssSync.value = true; toast('Surveillance activée : les nouveaux épisodes seront téléchargés automatiquement', 'success') }
|
||||
}
|
||||
} catch { toast('Erreur lors de la mise à jour de la surveillance', 'error') }
|
||||
} catch { toast('Impossible de mettre à jour la surveillance', 'error') }
|
||||
}
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
@@ -562,7 +544,7 @@ async function fetchActiveDownloads() {
|
||||
for (const t of list) {
|
||||
const prevState = _prevTorrentStates.get(t.hash)
|
||||
|
||||
// Détecter fichier individuel terminé (progression par fichier)
|
||||
// Fichier terminé (client avec progression par fichier)
|
||||
if (t.files) {
|
||||
for (const f of t.files) {
|
||||
const key = `${t.hash}:${f.index}`
|
||||
@@ -575,7 +557,7 @@ async function fetchActiveDownloads() {
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter torrent entier passé en seeding (épisodes standalone sans per-file data)
|
||||
// Torrent entier terminé (client sans progression par fichier)
|
||||
if (!isFirstPoll && t.state === 'seeding' && prevState !== undefined && prevState !== 'seeding' && !_recentlyOrganized.has(t.hash)) {
|
||||
_recentlyOrganized.add(t.hash)
|
||||
toOrganize.set(t.hash, t)
|
||||
@@ -609,7 +591,7 @@ async function triggerOrganize(torrent: any) {
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
if (result.done > 0) {
|
||||
toast(`${result.done} épisode(s) importé(s) automatiquement ✓`, 'success')
|
||||
toast(`${plural(result.done, 'épisode importé', 'épisodes importés')} automatiquement`, 'success')
|
||||
await fetchOrganized()
|
||||
load()
|
||||
}
|
||||
@@ -619,33 +601,29 @@ async function triggerOrganize(torrent: any) {
|
||||
|
||||
function openManualImport() { fetchOrganized(); manualImportOpen.value = true }
|
||||
|
||||
// ── Download ──────────────────────────────────────────────────
|
||||
// ── Téléchargement ──
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null, file_index?: number | null, file_path?: string | null, infohash?: string | null) {
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(key)
|
||||
const result = await store.download(torrent_url, magnet, file_index, file_path, infohash)
|
||||
removeDownloading(key)
|
||||
if (result.success) { addDownloaded(key); toast('Téléchargement lancé ✓', 'success'); fetchActiveDownloads() }
|
||||
else toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
if (result.success) { addDownloaded(key); toast('Téléchargement lancé', 'success'); fetchActiveDownloads() }
|
||||
else toast(result.error ?? 'Impossible de lancer le téléchargement', 'error')
|
||||
}
|
||||
|
||||
type DlItem = { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null; infohash?: string | null }
|
||||
|
||||
// Construit la liste de tout ce qui doit être téléchargé.
|
||||
// integraleIndex : si défini, utilise uniquement cette intégrale (+ épisodes non couverts).
|
||||
// si omis, utilise toutes les intégrales disponibles.
|
||||
// Reste à envoyer : une intégrale (ou toutes), puis packs et épisodes non couverts
|
||||
function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
if (!data.value) return []
|
||||
const result: DlItem[] = []
|
||||
const covered = new Set<number>()
|
||||
|
||||
// Une autre version déjà envoyée couvre déjà ses épisodes
|
||||
data.value.torrents_integrale.forEach((t: any, i: number) => {
|
||||
if (isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
|
||||
for (const id of integraleEpisodeCoverage(i)) covered.add(id)
|
||||
})
|
||||
|
||||
// ── 1. Intégrale(s) ───────────────────────────────────────────
|
||||
const integrales: { t: any; i: number }[] = integraleIndex !== undefined
|
||||
? [{ t: data.value.torrents_integrale[integraleIndex], i: integraleIndex }]
|
||||
: data.value.torrents_integrale.map((t: any, i: number) => ({ t, i }))
|
||||
@@ -653,7 +631,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
for (const { t, i } of integrales) {
|
||||
if (!t) continue
|
||||
|
||||
// Épisodes apportés par CETTE intégrale
|
||||
const hash = t.infohash?.toLowerCase() ?? extractHash(t)
|
||||
const coveredByThis = new Set<number>()
|
||||
if (hash)
|
||||
@@ -671,10 +648,8 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
for (const id of coveredByThis) covered.add(id)
|
||||
}
|
||||
|
||||
// ── 2. Packs saison non couverts ──────────────────────────────
|
||||
for (const season of data.value.seasons) {
|
||||
if (!season.torrents?.length || season.organized_state === 'complete') continue
|
||||
// Skiper si tous les épisodes disponibles sont déjà couverts par l'intégrale
|
||||
const hasUncovered = season.episodes.some((ep: any) => ep.available && !ep.organized && !covered.has(ep.id))
|
||||
if (!hasUncovered) continue
|
||||
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
|
||||
@@ -684,7 +659,6 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
for (const ep of season.episodes) covered.add(ep.id)
|
||||
}
|
||||
|
||||
// ── 3. Épisodes individuels non couverts ─────────────────────
|
||||
for (const season of data.value.seasons)
|
||||
for (const ep of season.episodes) {
|
||||
if (!ep.torrent || !ep.available || ep.organized || isAlreadyQueued(ep.torrent) || isDownloaded(`ep-${ep.id}`) || covered.has(ep.id)) continue
|
||||
@@ -694,9 +668,7 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
|
||||
return result
|
||||
}
|
||||
|
||||
// Le bouton "Tout télécharger" s'affiche s'il y a quoi que ce soit à lancer
|
||||
// Dérivé de collectDownloadables pour que le bouton n'apparaisse jamais alors
|
||||
// que le garde-fou versions ne laisserait passer aucun torrent.
|
||||
// Même calcul que downloadAll : jamais de bouton sans effet
|
||||
const hasSomethingToDownload = computed(() => collectDownloadables().length > 0)
|
||||
|
||||
async function downloadAll(integraleIndex?: number) {
|
||||
@@ -707,7 +679,7 @@ async function downloadAll(integraleIndex?: number) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingAll.value = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
|
||||
else toast('Aucun nouveau torrent à télécharger', 'success')
|
||||
}
|
||||
|
||||
@@ -728,7 +700,6 @@ async function downloadSeason(season: Season, packHash?: string) {
|
||||
}
|
||||
} else {
|
||||
for (const ep of seasonAny.episodes ?? []) {
|
||||
// Si un hash de pack est précisé (choix depuis dropdown), utiliser le torrent correspondant
|
||||
const epTorrent = packHash
|
||||
? (ep.torrents?.find((t: any) => (t.infohash ?? extractHash(t)) === packHash) ?? ep.torrent)
|
||||
: ep.torrent
|
||||
@@ -741,11 +712,11 @@ async function downloadSeason(season: Season, packHash?: string) {
|
||||
try { const r = await store.download(t.torrent_url, t.magnet, t.file_index, t.file_path, t.infohash); if (r.success) { addDownloaded(t.key); sent++ } } catch {}
|
||||
}
|
||||
downloadingSeason.value[season.id] = false
|
||||
if (sent > 0) { toast(`${sent} torrent(s) envoyé(s) ✓`, 'success'); fetchActiveDownloads() }
|
||||
if (sent > 0) { toast(`${plural(sent, 'torrent envoyé', 'torrents envoyés')} au client`, 'success'); fetchActiveDownloads() }
|
||||
else toast('Aucun nouveau torrent à télécharger pour cette saison', 'success')
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
function toggleSeason(id: number) {
|
||||
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
|
||||
else collapsedSeasons.value.add(id)
|
||||
@@ -757,9 +728,8 @@ async function confirmUnimportSerie() { unimportSerieModal.value = false; await
|
||||
async function unimportSeason(season: any, deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/seasons/${season.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport saison', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la saison', 'error'); return }
|
||||
toast(deleteFile ? 'Saison retirée et fichiers supprimés' : 'Saison retirée de la médiathèque', 'success')
|
||||
for (const ep of season.episodes ?? []) clearEpDownloaded(ep)
|
||||
removeDownloaded(`season-${season.id}`)
|
||||
await fetchOrganized(); load()
|
||||
@@ -769,9 +739,8 @@ async function unimportSeason(season: any, deleteFile: boolean) {
|
||||
async function unimportSerie(deleteFile: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
const d = await res.json()
|
||||
toast(`${d.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Impossible de retirer la série', 'error'); return }
|
||||
toast(deleteFile ? 'Série retirée et fichiers supprimés' : 'Série retirée de la médiathèque', 'success')
|
||||
downloaded.value = []
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
@@ -786,7 +755,7 @@ async function renameEpisode(ep: any, _season: any) {
|
||||
body: JSON.stringify({ serie_id: Number(route.params.id), episode_id: ep.id, torrent_hash: torrentHash }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur rename', 'error'); return }
|
||||
if (!res.ok) { toast(d.error ?? "Impossible de renommer l'épisode", 'error'); return }
|
||||
if (d.renamed) { toast(`Renommé : ${d.new_name}`, 'success'); await fetchOrganized() }
|
||||
else toast(d.message ?? 'Nom déjà correct', 'success')
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
@@ -797,8 +766,8 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
epActionLoading.value[ep.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${route.params.id}/${ep.id}?deleteFile=${deleteFile}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur désimport', 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
|
||||
clearEpDownloaded(ep)
|
||||
await fetchOrganized(); load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
|
||||
+28
-30
@@ -16,12 +16,11 @@
|
||||
:selectable="auth.isAdmin"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
<div data-tour="series-grid" class="pt-1.5">
|
||||
|
||||
<div v-if="store.loadingSeries && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
|
||||
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-body">Chargement du catalogue…</p>
|
||||
<p class="text-body">Chargement de la médiathèque…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
@@ -42,17 +41,16 @@
|
||||
>
|
||||
<template v-for="item in gridItems" :key="item.key">
|
||||
|
||||
<!-- Tuile -->
|
||||
<!-- Tuile lettre (tri alphabétique) -->
|
||||
<div
|
||||
v-if="item.type === 'letter'"
|
||||
class="@container aspect-[2/3] rounded-poster border border-border-light bg-linear-to-b from-card to-main max-sm:hidden flex flex-col justify-between px-[11%] pt-[11%] pb-[10%] overflow-hidden select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="font-display text-[80cqw] leading-[0.9] font-extrabold text-accent">{{ item.letter }}</span>
|
||||
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ item.count }} série{{ item.count > 1 ? 's' : '' }}</span>
|
||||
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ plural(item.count, 'série') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Affiche -->
|
||||
<component
|
||||
v-else
|
||||
:is="selecting ? 'button' : RouterLink"
|
||||
@@ -90,7 +88,7 @@
|
||||
<Check :size="13" :stroke-width="3" />
|
||||
</span>
|
||||
|
||||
<span v-if="item.state.watched" title="Surveillée" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
|
||||
<span v-if="item.state.watched" title="Surveillée : nouveaux épisodes téléchargés automatiquement" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
|
||||
<Rss :size="12" :stroke-width="2.5" />
|
||||
</span>
|
||||
<span v-else-if="item.state.requested" class="absolute right-2 top-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-accent text-[11.5px] font-bold flex items-center gap-1.5">
|
||||
@@ -141,14 +139,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions de masse -->
|
||||
<div v-if="selecting" class="sticky bottom-4 z-10 mt-8 mx-auto w-fit max-w-full bg-card border border-border rounded-card px-4 py-3 flex items-center gap-x-3 gap-y-2 flex-wrap shadow-[0_16px_40px_rgb(0_0_0/0.5)]">
|
||||
<span class="text-body font-bold text-primary">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
|
||||
<span class="text-body font-bold text-primary">{{ plural(selected.size, 'sélectionnée') }}</span>
|
||||
<button @click="selectAllFiltered" class="text-meta text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Désélectionner</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Tout désélectionner</button>
|
||||
<span class="hidden sm:block w-px h-6 bg-hover" />
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer</button>
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer les fichiers</button>
|
||||
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Surveiller</button>
|
||||
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Ne plus surveiller</button>
|
||||
<button @click="selecting = false" class="btn-icon btn-sm" title="Quitter la sélection" aria-label="Quitter la sélection"><X :size="14" /></button>
|
||||
@@ -169,6 +166,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { usePosterSize } from '@/composables/usePosterSize'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import { isNew, statusLabel } from '@/utils/series'
|
||||
import { plural } from '@/utils/format'
|
||||
import SeriesToolbar, { type LibraryFilter } from '@/components/series/SeriesToolbar.vue'
|
||||
|
||||
defineOptions({ name: 'SeriesView' })
|
||||
@@ -185,13 +183,13 @@ const activeFilter = ref('all')
|
||||
const activeSort = ref('alpha')
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'A → Z', value: 'alpha' },
|
||||
{ label: 'Z → A', value: 'alpha-desc' },
|
||||
{ label: 'Récentes', value: 'recent' },
|
||||
{ label: 'Importées d’abord', value: 'imported' },
|
||||
{ label: 'De A à Z', value: 'alpha' },
|
||||
{ label: 'De Z à A', value: 'alpha-desc' },
|
||||
{ label: 'Année de sortie', value: 'recent' },
|
||||
{ label: "Importées d'abord", value: 'imported' },
|
||||
]
|
||||
|
||||
// ── Filtres à compteurs ──────────────────────────────────────
|
||||
// ── Filtres à compteur ──
|
||||
const myRequested = ref<Set<number>>(new Set())
|
||||
|
||||
const predicates: Record<string, (s: Serie) => boolean> = {
|
||||
@@ -223,7 +221,7 @@ const filters = computed<LibraryFilter[]>(() => (auth.isAdmin
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'complete', label: 'Prêtes à regarder' },
|
||||
{ value: 'partial', label: 'Partielles' },
|
||||
{ value: 'downloading', label: 'Bientôt', attention: true },
|
||||
{ value: 'downloading', label: 'Bientôt là', attention: true },
|
||||
{ value: 'not-here', label: 'Pas encore là' },
|
||||
{ value: 'mine', label: 'Mes demandes', attention: true },
|
||||
]
|
||||
@@ -263,7 +261,7 @@ function normalize(text: string) {
|
||||
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
// ── État des séries ─────────────────────────────────────
|
||||
// ── État des séries ──
|
||||
const errorsBySerie = ref<Map<number, number>>(new Map())
|
||||
const staleFolders = ref<Set<number>>(new Set())
|
||||
|
||||
@@ -307,7 +305,7 @@ function posterState(s: Serie): PosterState {
|
||||
dim : notHere,
|
||||
isNew : s.download_state !== 'none' && isNew(s.last_imported_at),
|
||||
progress : null,
|
||||
chip : s.download_state === 'downloading' ? { label: 'Bientôt', tone: 'accent', icon: 'soon' }
|
||||
chip : s.download_state === 'downloading' ? { label: 'Bientôt là', tone: 'accent', icon: 'soon' }
|
||||
: partial ?? (notHere ? { label: 'Pas encore là', tone: 'muted' } : null),
|
||||
}
|
||||
}
|
||||
@@ -320,13 +318,13 @@ function posterState(s: Serie): PosterState {
|
||||
dim : s.download_state === 'none' && !s.has_torrents,
|
||||
isNew : isNew(s.last_imported_at),
|
||||
progress : errors ? null : downloadProgress(s),
|
||||
chip : errors ? { label: `${errors} erreur${errors > 1 ? 's' : ''}`, tone: 'err', icon: 'error', title: 'Import en erreur' }
|
||||
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Dossier différent du titre de la série' }
|
||||
chip : errors ? { label: plural(errors, 'erreur'), tone: 'err', icon: 'error', title: 'Import en erreur' }
|
||||
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Nom de dossier différent du titre' }
|
||||
: partial ?? (s.download_state === 'none' && !s.has_torrents ? { label: 'Sans torrent', tone: 'muted' } : null),
|
||||
}
|
||||
}
|
||||
|
||||
// Grille──
|
||||
// ── Grille ──
|
||||
function letterOf(title: string) {
|
||||
const c = normalize(title).charAt(0).toUpperCase()
|
||||
return /[A-Z]/.test(c) ? c : '#'
|
||||
@@ -353,7 +351,7 @@ const gridItems = computed<GridItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
// ── Sélection multiple ───────────────────────────────────────
|
||||
// ── Sélection multiple ──
|
||||
const selecting = ref(false)
|
||||
const selected = ref<Set<number>>(new Set())
|
||||
const bulkBusy = ref(false)
|
||||
@@ -377,9 +375,9 @@ async function bulkRename() {
|
||||
body: JSON.stringify({ serie_ids: [...selected.value] }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du renommage', 'error'); return }
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
|
||||
const errs = data.errors?.length ?? 0
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} renommé${data.done > 1 ? 's' : ''}${errs ? ` · ${errs} erreur${errs > 1 ? 's' : ''}` : ''}`, errs ? 'error' : 'success')
|
||||
toast(`${plural(data.done, 'fichier renommé', 'fichiers renommés')}${errs ? ` · ${plural(errs, 'erreur')}` : ''}`, errs ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -396,12 +394,12 @@ async function bulkSync(enabled: boolean) {
|
||||
body: JSON.stringify({ enabled, series: targets.map((s: Serie) => ({ id: s.id, name: s.title })) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la mise à jour de la surveillance', 'error'); return }
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de mettre à jour la surveillance', 'error'); return }
|
||||
for (const s of targets) s.rss_synced = enabled
|
||||
const n = data.changed
|
||||
toast(enabled
|
||||
? `${n} série${n > 1 ? 's' : ''} ajoutée${n > 1 ? 's' : ''} à la surveillance`
|
||||
: `${n} série${n > 1 ? 's' : ''} retirée${n > 1 ? 's' : ''} de la surveillance`, 'success')
|
||||
? `${plural(n, 'série ajoutée', 'séries ajoutées')} à la surveillance`
|
||||
: `${plural(n, 'série retirée', 'séries retirées')} de la surveillance`, 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -409,7 +407,7 @@ async function bulkSync(enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polling downloads + notifs import ────────────────────────
|
||||
// ── Suivi des téléchargements et des imports ──
|
||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||
const seenNotifs = new Set<string>()
|
||||
let notifsPrimed = false
|
||||
@@ -441,8 +439,8 @@ async function fetchOrganizeNotifs() {
|
||||
hasNew = true
|
||||
if (n.done > 0) {
|
||||
const msg = n.errors > 0
|
||||
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
|
||||
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
|
||||
? `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}, ${plural(n.errors, 'erreur')}`
|
||||
: `${n.name} : ${plural(n.done, 'fichier importé', 'fichiers importés')}`
|
||||
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||
}
|
||||
}
|
||||
|
||||
+9
-12
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-main flex flex-col md:flex-row">
|
||||
|
||||
<!-- Rail des étapes -->
|
||||
<aside class="md:w-[280px] md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-card flex flex-col shrink-0">
|
||||
<div class="flex items-center gap-[11px] pl-5 pr-3 pt-[22px] pb-5">
|
||||
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
|
||||
@@ -29,7 +28,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression mobile -->
|
||||
<div class="md:hidden px-5 pb-4">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="index + 1" aria-valuemin="1" :aria-valuemax="steps.length">
|
||||
<div class="progress-bar" :style="{ width: `${((index + 1) / steps.length) * 100}%` }" />
|
||||
@@ -70,7 +68,6 @@
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<!-- Contenu -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<div v-if="!loaded" class="flex items-center justify-center py-32">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
@@ -103,7 +100,7 @@
|
||||
Passer
|
||||
</button>
|
||||
<button @click="next(true)" :disabled="busy" class="btn-primary">
|
||||
{{ busy ? '…' : index === 0 ? 'Commencer' : 'Suivant' }}
|
||||
{{ busy ? 'Enregistrement…' : index === 0 ? 'Commencer' : 'Suivant' }}
|
||||
<ArrowRight v-if="!busy" :size="15" />
|
||||
</button>
|
||||
</footer>
|
||||
@@ -142,9 +139,9 @@ interface StepDef {
|
||||
|
||||
const steps: StepDef[] = [
|
||||
{
|
||||
id: 'welcome', label: 'Bienvenue', hint: 'Ce qui vous attend', component: SetupWelcomeStep,
|
||||
id: 'welcome', label: 'Bienvenue', hint: 'Présentation', component: SetupWelcomeStep,
|
||||
title: 'Bienvenue dans FanKarr',
|
||||
description: 'Quelques minutes pour préparer FanKarr : où ranger vos séries, quel client torrent utiliser et comment les retrouver dans votre lecteur.',
|
||||
description: 'Cet assistant règle les dossiers, le client torrent, le serveur multimédia et le catalogue.',
|
||||
},
|
||||
{
|
||||
id: 'paths', label: 'Dossiers', hint: 'Téléchargements et médiathèque', component: SetupPathsStep,
|
||||
@@ -153,28 +150,28 @@ const steps: StepDef[] = [
|
||||
},
|
||||
{
|
||||
id: 'client', label: 'Client torrent', hint: 'qBittorrent, Transmission…', component: SetupClientStep,
|
||||
title: 'Client de téléchargement',
|
||||
title: 'Client torrent',
|
||||
description: 'FanKarr envoie les torrents à votre client puis surveille leur progression pour les importer.',
|
||||
},
|
||||
{
|
||||
id: 'import', label: 'Import', hint: 'Automatisme et nommage', component: SetupImportStep,
|
||||
id: 'import', label: 'Import', hint: 'Import automatique et nommage', component: SetupImportStep,
|
||||
title: 'Options d\'import',
|
||||
description: 'Réglez ce qui se passe quand un téléchargement se termine.',
|
||||
},
|
||||
{
|
||||
id: 'media-server', label: 'Serveur multimédia', hint: 'Jellyfin, Plex', component: SetupMediaServerStep, optional: true,
|
||||
title: 'Jellyfin et Plex',
|
||||
description: 'Connectez votre serveur multimédia pour y retrouver vos séries. Vous pouvez passer cette étape.',
|
||||
description: 'Connectez votre serveur multimédia pour y retrouver vos séries.',
|
||||
},
|
||||
{
|
||||
id: 'catalog', label: 'Catalogue', hint: 'Séries Fankai', component: SetupCatalogStep,
|
||||
title: 'Catalogue Fankai',
|
||||
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met à jour tout seul ensuite.',
|
||||
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met ensuite à jour automatiquement.',
|
||||
},
|
||||
{
|
||||
id: 'recap', label: 'Récapitulatif', hint: 'Vérifier et terminer', component: SetupRecapStep,
|
||||
title: 'Tout est prêt ?',
|
||||
description: 'Vérifiez la configuration avant de commencer. Chaque point reste modifiable.',
|
||||
title: 'Récapitulatif',
|
||||
description: 'Vérifiez la configuration avant de commencer.',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Version ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Version">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
@@ -18,7 +17,7 @@
|
||||
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
|
||||
<template v-if="debug">Serveur démarré il y a {{ uptime }} · </template>mises à jour vérifiées toutes les 6 h
|
||||
</span>
|
||||
</div>
|
||||
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
@@ -27,8 +26,7 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Diag ─────────────────────────────────────────── -->
|
||||
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
|
||||
<SettingsSection title="Diagnostic" description="État du serveur, utile pour signaler un bug.">
|
||||
<template #actions>
|
||||
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
|
||||
</template>
|
||||
@@ -41,7 +39,6 @@
|
||||
<p v-else class="text-meta text-muted">Chargement…</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Dev ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Développement">
|
||||
<template #actions>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
@@ -49,11 +46,10 @@
|
||||
<SettingsToggle
|
||||
v-model="devMode"
|
||||
label="Mode développeur"
|
||||
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
|
||||
description="Affiche aussi ce diagnostic en bas de l'accueil."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Assistant et visite ────────────────────────────────── -->
|
||||
<SettingsSection title="Assistant et visite guidée">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
@@ -85,7 +81,7 @@ const saved = ref(false)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loaded = false
|
||||
|
||||
// ── Version ───────────────────────────────────────────────────
|
||||
// ── Version ──
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
const currentVersion = ref('')
|
||||
const latestVersion = ref('')
|
||||
@@ -120,7 +116,7 @@ async function checkForUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diag ────────────────────────────────────────────────
|
||||
// ── Diagnostic ──
|
||||
const debug = ref<any>(null)
|
||||
|
||||
async function fetchDebug() {
|
||||
@@ -139,8 +135,8 @@ const diagnostics = computed(() => debug.value ? [
|
||||
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
|
||||
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
|
||||
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
|
||||
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
|
||||
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
|
||||
{ label: `entrées en cache (gardées ${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
|
||||
{ label: "tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', accent: debug.value.worker.running },
|
||||
] : [])
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Données manquantes -->
|
||||
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
@@ -23,17 +21,16 @@
|
||||
<p class="card-title text-accent">Données manquantes</p>
|
||||
<p class="text-meta text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
Synchronisez le catalogue pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
|
||||
{{ updating ? 'Synchronisation…' : 'Synchroniser' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
|
||||
<SettingsSection title="Sources" description="Les séries et les torrents viennent du scraper GitHub. L'API Fankai complète les séries qui n'y figurent pas encore.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
@@ -41,36 +38,35 @@
|
||||
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
|
||||
{{ status.empty ? 'Aucune donnée chargée' : plural(status.count, 'série disponible', 'séries disponibles') }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
|
||||
<span class="text-meta text-muted shrink-0">Données gardées en cache 1 h</span>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">API Fankai</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
|
||||
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
|
||||
<template v-if="apiOnlyCount > 0">{{ plural(apiOnlyCount, 'série vient', 'séries viennent') }} uniquement de l'API : fiche et images, mais pas de torrents.</template>
|
||||
<template v-else>Fournit les titres à jour et les séries absentes du scraper.</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillance RSS -->
|
||||
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
|
||||
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés automatiquement au client. Vérification toutes les 6 heures.">
|
||||
<template #actions>
|
||||
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
|
||||
<Loader v-if="runningRss" :size="14" class="animate-spin" />
|
||||
<RefreshCw v-else :size="14" />
|
||||
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
|
||||
{{ runningRss ? 'Vérification…' : 'Vérifier maintenant' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="flex items-baseline gap-[9px]">
|
||||
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
|
||||
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
|
||||
<span class="text-meta text-muted">{{ watched.length > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
|
||||
</div>
|
||||
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
|
||||
<RouterLink
|
||||
@@ -79,17 +75,16 @@
|
||||
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
|
||||
>{{ s.serieName }}</RouterLink>
|
||||
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
|
||||
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
|
||||
+ {{ plural(watched.length - visibleWatched.length, 'autre') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
|
||||
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou en sélectionnant plusieurs séries dans la médiathèque.</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<SettingsSection
|
||||
v-if="syncOrphans.length > 0"
|
||||
:title="`Surveillances orphelines · ${syncOrphans.length}`"
|
||||
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
|
||||
:title="`Séries surveillées disparues du catalogue · ${syncOrphans.length}`"
|
||||
description="Ces séries n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
|
||||
>
|
||||
<div class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
|
||||
@@ -111,7 +106,7 @@ import { RouterLink } from 'vue-router'
|
||||
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
@@ -153,10 +148,10 @@ async function fetchSyncOrphans() {
|
||||
async function removeSyncOrphan(serieId: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
|
||||
if (!res.ok) { toast('Impossible de retirer la surveillance', 'error'); return }
|
||||
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
|
||||
watched.value = watched.value.filter(s => s.serieId !== serieId)
|
||||
toast('Surveillance retirée ✓', 'success')
|
||||
toast('Surveillance retirée', 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
}
|
||||
@@ -167,8 +162,8 @@ async function runRss() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
|
||||
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de vérifier les séries surveillées', 'error'); return }
|
||||
toast(data.sent > 0 ? `${plural(data.sent, 'épisode envoyé', 'épisodes envoyés')} au client` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -183,11 +178,11 @@ async function update() {
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
toast(`Catalogue synchronisé : ${plural(count, 'série')}`, 'success')
|
||||
fetchSyncOrphans()
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
toast(error ?? 'Impossible de synchroniser le catalogue', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Clients configurés -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Clients configurés</h3>
|
||||
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
|
||||
<p class="text-meta text-muted">FanKarr range ses torrents dans une catégorie dédiée (« fankai » par défaut) et ne touche pas aux autres.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
|
||||
@@ -57,11 +56,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Types pris en charge -->
|
||||
<section class="bg-card rounded-card overflow-hidden">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Types pris en charge</h3>
|
||||
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
|
||||
<h3 class="card-title">Clients compatibles</h3>
|
||||
<p class="text-meta text-muted">Fonctions disponibles selon le client. Vous pouvez en configurer plusieurs.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto border-t border-hover">
|
||||
<table class="w-full min-w-[720px] text-meta">
|
||||
@@ -73,7 +71,7 @@
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppression des fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -91,9 +89,9 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Immédiat' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Via FanKarr' : row.torrentUpload === 'url' ? 'Lien direct' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
|
||||
</tr>
|
||||
@@ -101,13 +99,12 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
|
||||
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
|
||||
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
|
||||
<span><span class="text-ok">Immédiat</span> : seuls les fichiers choisis sont téléchargés, dès la réception des métadonnées.</span>
|
||||
<span><span class="text-ok">Via FanKarr</span> : FanKarr récupère le fichier .torrent et le transmet au client (fonctionne avec les liens protégés).</span>
|
||||
<span><span class="text-secondary">Lien direct</span> : le client récupère lui-même le fichier .torrent.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modale ajout / modification -->
|
||||
<Teleport to="body">
|
||||
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
|
||||
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
|
||||
@@ -155,28 +152,32 @@ function matrixHealth(type: string) {
|
||||
const clientMatrix = [
|
||||
{
|
||||
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
|
||||
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
auth: "Nom d'utilisateur et mot de passe · Clé API (5.2+)", fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
|
||||
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
auth: 'Adresse XML-RPC', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'utorrent', label: 'uTorrent', version: '3.x',
|
||||
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
|
||||
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
|
||||
auth: "Nom d'utilisateur et mot de passe", fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
|
||||
},
|
||||
{
|
||||
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
|
||||
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'deluge', label: 'Deluge', version: 'Interface web',
|
||||
auth: 'Mot de passe', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
|
||||
},
|
||||
]
|
||||
|
||||
const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
|
||||
@@ -211,7 +212,7 @@ async function testClient(uuid: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
toast(ok ? 'Connexion réussie' : (message ?? 'Échec de la connexion'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -229,7 +230,7 @@ async function deleteClient(client: SavedTorrentClient) {
|
||||
delete healthStatus.value[uuid]
|
||||
toast('Client supprimé', 'success')
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
toast('Impossible de supprimer le client', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]" title="Repère les épisodes présents sur le disque mais pas encore suivis">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
<ScanSearch v-else :size="15" />
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Nommage ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Nommage">
|
||||
<template #description>
|
||||
FanKarr renomme les fichiers d’après le catalogue Fankai, en
|
||||
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
|
||||
FanKarr renomme les fichiers d'après le catalogue Fankai.
|
||||
<span class="text-secondary">{{ nfoSupport ? 'NFO activés : chaque fichier prend le nom de son NFO.' : 'NFO désactivés : noms du type Titre.S01E01.' }}</span>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
@@ -24,7 +23,8 @@
|
||||
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="loading">Analyse des fichiers importés…</template>
|
||||
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
|
||||
<template v-else-if="seriesNeedingRename > 1">{{ totalNeedsRename }} épisodes répartis sur {{ seriesNeedingRename }} séries ne portent pas le nom attendu.</template>
|
||||
<template v-else-if="totalNeedsRename > 0">{{ plural(totalNeedsRename, 'épisode ne porte', 'épisodes ne portent') }} pas le nom attendu.</template>
|
||||
<template v-else>Tous les fichiers portent le nom attendu.</template>
|
||||
</span>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
|
||||
{{ plural(renameResult.done, 'fichier renommé', 'fichiers renommés') }}<template v-if="renameResult.errors.length > 0"> · {{ plural(renameResult.errors.length, 'erreur') }}</template>
|
||||
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
|
||||
</p>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
|
||||
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
|
||||
<template v-else>{{ staleFolders.length > 1 ? `${staleFolders.length} dossiers ne portent pas le titre de leur série` : 'Un dossier ne porte pas le titre de sa série' }} : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
@@ -54,24 +54,23 @@
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
|
||||
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
|
||||
<span class="text-sm font-medium text-primary">Tout resynchroniser</span>
|
||||
<span class="text-meta text-muted">Recharge entièrement le catalogue, met à jour les identifiants d'épisodes et renomme les fichiers. Utile si des épisodes ne sont plus reconnus.</span>
|
||||
</div>
|
||||
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
|
||||
{{ forcingRefresh ? 'Resynchronisation…' : 'Lancer' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Métadonnées ────────────────────────────────────────── -->
|
||||
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
|
||||
<SettingsSection title="Métadonnées" description="Fichiers NFO et images, pour Infuse ou les lecteurs sans agent Fankai.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
|
||||
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
|
||||
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
|
||||
<template v-if="!nfoSupport">Compare les NFO locaux à ceux du dépôt Fankai. Activez d'abord les NFO dans Gestion des médias.</template>
|
||||
<template v-else-if="lastNfoUpdate">Compare les NFO locaux à ceux du dépôt Fankai (toutes les heures). Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
|
||||
<template v-else>Compare les NFO locaux à ceux du dépôt Fankai et télécharge ceux qui ont changé (toutes les heures).</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
@@ -90,22 +89,22 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
|
||||
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
|
||||
<SettingsSection title="Cohérence de la médiathèque" description="Compare les épisodes suivis par FanKarr au contenu du disque et au catalogue.">
|
||||
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">{{ trackedFiles > 1 ? 'fichiers suivis' : 'fichier suivi' }}</span></span>
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">{{ orphans.length > 1 ? 'épisodes disparus du catalogue' : 'épisode disparu du catalogue' }}</span></span>
|
||||
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">{{ missingFiles > 1 ? 'fichiers introuvables' : 'fichier introuvable' }}</span></span>
|
||||
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis la dernière analyse</span></span>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
|
||||
<span class="text-sm font-medium text-primary">Retirer les épisodes disparus du catalogue</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
|
||||
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
|
||||
<template v-if="orphans.length === 0">Aucun épisode suivi n'a disparu du catalogue.</template>
|
||||
<template v-else-if="orphans.length === 1">Retire du suivi l'épisode qui n'existe plus dans le catalogue. Aucun fichier n'est supprimé.</template>
|
||||
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n'existent plus dans le catalogue. Aucun fichier n'est supprimé.</template>
|
||||
</span>
|
||||
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
|
||||
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
|
||||
@@ -113,7 +112,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Nettoyer' }}
|
||||
{{ removingOrphans ? 'Retrait…' : 'Retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
@@ -121,7 +120,7 @@
|
||||
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? 'Chemin inconnu' }}</span>
|
||||
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
|
||||
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
|
||||
</div>
|
||||
@@ -131,7 +130,7 @@
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
|
||||
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
|
||||
<span class="text-meta text-muted">À utiliser si Fankai a renommé une série et que ses épisodes ne sont plus reconnus.</span>
|
||||
</div>
|
||||
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ migrating ? 'Migration…' : 'Migrer' }}
|
||||
@@ -139,12 +138,11 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Fichiers importés, série par série ──────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
|
||||
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
|
||||
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ plural(series.length, 'série') }}</span></h3>
|
||||
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode. Renommez ou retirez les épisodes un par un.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="totalNeedsRename > 0"
|
||||
@@ -162,7 +160,7 @@
|
||||
|
||||
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
|
||||
<p class="text-body text-primary">Aucune série importée</p>
|
||||
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
|
||||
<p class="text-meta text-muted">Importez des séries depuis la page Activité ou par l'import manuel d'une fiche série.</p>
|
||||
</div>
|
||||
|
||||
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
|
||||
@@ -176,13 +174,13 @@
|
||||
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
|
||||
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span class="text-meta text-muted">{{ plural(serie.total, 'épisode') }}</span>
|
||||
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
|
||||
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
|
||||
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ plural(missingIn(serie), 'introuvable') }}</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
|
||||
<div class="flex items-center gap-1.5 ml-auto">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,10 +208,10 @@
|
||||
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
|
||||
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
|
||||
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
|
||||
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
|
||||
{{ renamingEp[ep.episode_id] ? 'Renommage…' : 'Renommer' }}
|
||||
</button>
|
||||
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
|
||||
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
|
||||
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer le fichier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,12 +226,12 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
// ── État ──
|
||||
const loading = ref(true)
|
||||
const renamingAll = ref(false)
|
||||
const purgingNfo = ref(false)
|
||||
@@ -259,7 +257,7 @@ const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
// ── Calculs ──
|
||||
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
|
||||
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
|
||||
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
|
||||
@@ -277,17 +275,16 @@ const filteredSeries = computed(() =>
|
||||
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
|
||||
)
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organized-summary', { credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur chargement', 'error'); return }
|
||||
if (!res.ok) { toast('Impossible de charger les fichiers importés', 'error'); return }
|
||||
const data = await res.json()
|
||||
series.value = data.series ?? []
|
||||
nfoSupport.value = data.nfo_support ?? false
|
||||
orphans.value = data.orphans ?? []
|
||||
// Replier les séries sans rename par défaut
|
||||
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -313,8 +310,8 @@ async function scan() {
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
if (!res.ok) { toast(data.error ?? "Impossible d'analyser la médiathèque", 'error'); return }
|
||||
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -327,17 +324,17 @@ async function renameFolders() {
|
||||
const targets = staleFolders.value
|
||||
if (!targets.length) return
|
||||
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
|
||||
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
if (!confirm(`Renommer ${targets.length > 1 ? 'les dossiers' : 'le dossier'} en ${names} ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
|
||||
renamingFolders.value = true
|
||||
let moved = 0
|
||||
try {
|
||||
for (const f of targets) {
|
||||
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
|
||||
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'impossible de renommer le dossier'}`, 'error'); continue }
|
||||
moved += d.moved ?? 0
|
||||
}
|
||||
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
|
||||
if (moved > 0) toast(plural(moved, 'fichier déplacé', 'fichiers déplacés'), 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -351,7 +348,7 @@ async function checkNfo() {
|
||||
try {
|
||||
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
|
||||
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Impossible de lancer la vérification des NFO'), res.ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -364,8 +361,8 @@ async function migrateIds() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
|
||||
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de migrer les identifiants', 'error'); return }
|
||||
toast(data.updated > 0 ? plural(data.updated, 'identifiant migré', 'identifiants migrés') : 'Aucun identifiant à migrer', 'success')
|
||||
if (data.updated > 0) await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -391,7 +388,8 @@ async function renameAll(serieId?: number) {
|
||||
credentials: 'include',
|
||||
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
|
||||
})
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
|
||||
renameResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
if (data.done > 0) await load()
|
||||
} catch {
|
||||
@@ -411,7 +409,7 @@ async function renameSingle(ep: any, serie: any) {
|
||||
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
if (!res.ok) { toast(data.error ?? "Impossible de renommer l'épisode", 'error'); return }
|
||||
toast(`Renommé : ${data.new_name}`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
@@ -431,12 +429,13 @@ async function forceMetadataRefresh() {
|
||||
body : JSON.stringify({ force: true }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
|
||||
const parts = [`${data.count} séries rechargées`]
|
||||
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
|
||||
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
|
||||
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
|
||||
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de tout resynchroniser', 'error'); return }
|
||||
const parts = [plural(data.count, 'série rechargée', 'séries rechargées')]
|
||||
if (data.migrated > 0) parts.push(plural(data.migrated, 'identifiant migré', 'identifiants migrés'))
|
||||
if (data.deduped > 0) parts.push(plural(data.deduped, 'doublon retiré', 'doublons retirés'))
|
||||
parts.push(plural(data.renamed, 'fichier renommé', 'fichiers renommés'))
|
||||
if (data.errors > 0) parts.push(plural(data.errors, 'erreur'))
|
||||
toast(parts.join(' · '), data.errors > 0 ? 'error' : 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -446,13 +445,13 @@ async function forceMetadataRefresh() {
|
||||
}
|
||||
|
||||
async function purgeNfo() {
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
|
||||
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de la médiathèque ? Les vidéos ne sont pas touchées.')) return
|
||||
purgingNfo.value = true
|
||||
try {
|
||||
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de purger les NFO', 'error'); return }
|
||||
toast(`${plural(data.deleted, 'fichier supprimé', 'fichiers supprimés')}${data.errors.length > 0 ? ` (${plural(data.errors.length, 'erreur')})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
@@ -461,15 +460,16 @@ async function purgeNfo() {
|
||||
}
|
||||
|
||||
async function unimportSerie(serie: any, deleteFile: boolean) {
|
||||
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
|
||||
if (!confirm(deleteFile
|
||||
? `Retirer « ${serie.serie_title} » et supprimer ses fichiers du disque ? Cette action est irréversible.`
|
||||
: `Retirer « ${serie.serie_title} » de la médiathèque ? Les fichiers restent sur le disque.`)) return
|
||||
renamingAll.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
|
||||
method: 'DELETE', credentials: 'include',
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
|
||||
const data = await res.json()
|
||||
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer les fichiers de la série' : 'Impossible de retirer la série'), 'error'); return }
|
||||
toast(deleteFile ? 'Épisodes retirés et fichiers supprimés' : 'Épisodes retirés de la médiathèque', 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -481,15 +481,14 @@ async function unimportSerie(serie: any, deleteFile: boolean) {
|
||||
async function unimportEp(ep: any, deleteFile: boolean) {
|
||||
renamingEp.value[ep.episode_id] = true
|
||||
try {
|
||||
// On cherche le serie_id depuis la liste
|
||||
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
|
||||
if (!serie) return
|
||||
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
|
||||
method : 'DELETE',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
|
||||
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
|
||||
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -499,7 +498,7 @@ async function unimportEp(ep: any, deleteFile: boolean) {
|
||||
}
|
||||
|
||||
async function removeOrphans(episodeIds?: number[]) {
|
||||
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
|
||||
if (!episodeIds && !confirm(`Retirer du suivi ${plural(orphans.value.length, 'épisode disparu', 'épisodes disparus')} du catalogue ? Aucun fichier n'est supprimé.`)) return
|
||||
removingOrphans.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organized-summary/orphans', {
|
||||
@@ -509,8 +508,8 @@ async function removeOrphans(episodeIds?: number[]) {
|
||||
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
|
||||
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
|
||||
if (!res.ok) { toast(data.error ?? 'Impossible de retirer les épisodes disparus', 'error'); return }
|
||||
toast(`${plural(data.removed, 'épisode retiré', 'épisodes retirés')} du suivi`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
|
||||
@@ -2,41 +2,38 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
|
||||
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
|
||||
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
|
||||
<SettingsSection
|
||||
title="Synchronisation des utilisateurs"
|
||||
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
|
||||
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures."
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
|
||||
</button>
|
||||
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
|
||||
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span>
|
||||
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
|
||||
<span v-else-if="syncResult" class="text-meta text-secondary">
|
||||
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
|
||||
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
|
||||
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── API publique ───────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">API publique</h3>
|
||||
<p class="text-meta text-muted">
|
||||
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
|
||||
Adresse : <span class="text-secondary">{{ apiBase }}</span> · Authentification : jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -57,6 +54,7 @@ import { ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import { plural } from '@/utils/format'
|
||||
|
||||
const hasToken = ref(false)
|
||||
const syncing = ref(false)
|
||||
@@ -66,12 +64,12 @@ const syncError = ref<string | null>(null)
|
||||
const apiBase = `${window.location.origin}/api/v1`
|
||||
|
||||
const apiEndpoints = [
|
||||
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
|
||||
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
|
||||
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
|
||||
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
|
||||
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
|
||||
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
|
||||
{ method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' },
|
||||
{ method: 'GET', path: '/series/:id', desc: 'Obtenir la fiche complète (saisons et épisodes)' },
|
||||
{ method: 'POST', path: '/requests', desc: 'Créer ou compléter une demande au nom de l\'utilisateur' },
|
||||
{ method: 'GET', path: '/requests', desc: 'Lister ses demandes et leur statut' },
|
||||
{ method: 'POST', path: '/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
|
||||
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
|
||||
]
|
||||
|
||||
async function syncUsers() {
|
||||
@@ -81,7 +79,7 @@ async function syncUsers() {
|
||||
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (res.ok) syncResult.value = data
|
||||
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
|
||||
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin.'
|
||||
syncing.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,6 @@
|
||||
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
@@ -43,26 +42,23 @@
|
||||
class="segmented-item"
|
||||
:class="{ 'is-active': limit === n }"
|
||||
:aria-pressed="limit === n"
|
||||
>{{ n === 2000 ? 'Tout' : n }}</button>
|
||||
>{{ n.toLocaleString('fr-FR') }}</button>
|
||||
</div>
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Actualiser" aria-label="Actualiser">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
|
||||
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<section v-else class="bg-card rounded-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="min-w-[640px]">
|
||||
@@ -90,9 +86,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
|
||||
<p class="text-meta text-muted">{{ plural(entries.length, 'entrée affichée', 'entrées affichées') }} · fichier de {{ formatSize(fileSize) }}</p>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
|
||||
@@ -115,7 +110,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatSize } from '@/utils/format'
|
||||
import { formatSize, plural } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -130,13 +125,12 @@ const limit = ref(100)
|
||||
|
||||
const levels = [
|
||||
{ label: 'Tout', value: 'all' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Informations', value: 'info' },
|
||||
{ label: 'Avertissements', value: 'warn' },
|
||||
{ label: 'Erreurs', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
{ label: 'Débogage', value: 'debug' },
|
||||
]
|
||||
|
||||
// Compteurs et sources
|
||||
const counts = computed(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
|
||||
@@ -172,7 +166,6 @@ async function doClear() {
|
||||
await load()
|
||||
}
|
||||
|
||||
// Fichier texte
|
||||
function exportLogs() {
|
||||
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
|
||||
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
@@ -196,8 +189,8 @@ function fullDate(iso: string): string {
|
||||
|
||||
function levelInfo(level: string): { label: string; class: string } {
|
||||
return ({
|
||||
debug: { label: 'Debug', class: 'pill-muted' },
|
||||
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
|
||||
debug: { label: 'Débogage', class: 'pill-muted' },
|
||||
info : { label: 'Information', class: 'border-transparent px-0 text-muted' },
|
||||
warn : { label: 'Avertissement', class: 'pill-wait' },
|
||||
error: { label: 'Erreur', class: 'pill-err' },
|
||||
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
|
||||
|
||||
@@ -2,20 +2,18 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<Teleport defer to="#settings-actions">
|
||||
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
|
||||
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ plural(dirtyCount, 'modification non enregistrée', 'modifications non enregistrées') }}</span>
|
||||
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Dossiers -->
|
||||
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
|
||||
@@ -39,18 +37,16 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Alerte chemins non configurés -->
|
||||
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Chemins non configurés</p>
|
||||
<p class="text-meta text-secondary">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
|
||||
L'import automatique reste désactivé tant que ces deux dossiers ne sont pas choisis.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import -->
|
||||
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
|
||||
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
|
||||
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
|
||||
@@ -66,40 +62,39 @@
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
|
||||
<span class="text-meta text-muted">Recommandé : le hardlink laisse le fichier en partage dans le client sans occuper deux fois l'espace. Les deux dossiers doivent être sur le même disque.</span>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
description="Importe les téléchargements terminés (vérification toutes les 5 minutes)."
|
||||
/>
|
||||
<SettingsToggle
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO et métadonnées"
|
||||
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
|
||||
label="Fichiers NFO et images"
|
||||
description="À chaque import, ajoute les fichiers de description (.nfo) et les images. Utile pour Infuse ou les lecteurs sans agent Fankai."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
|
||||
description="Le torrent quitte le client une fois ses fichiers déplacés et n'est plus partagé."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si le fichier a disparu"
|
||||
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
|
||||
label="Retirer les épisodes dont le fichier a disparu"
|
||||
description="Lors de l'analyse, retire de la médiathèque les épisodes dont le fichier n'existe plus."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Dossiers « Season » plutôt que « Saison »"
|
||||
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
|
||||
label="Dossiers de saison en anglais"
|
||||
description="« Season 01 » au lieu de « Saison 1 »."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Serveurs média -->
|
||||
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
|
||||
<SettingsSection title="Serveurs multimédias" description="Les applications qui liront la médiathèque.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
@@ -120,7 +115,6 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Scan -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
@@ -128,13 +122,12 @@
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
<span v-if="lastScan" class="text-meta text-muted">
|
||||
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
|
||||
Dernière analyse {{ formatRelative(lastScan.at) }} · {{ plural(lastScan.found, 'fichier trouvé', 'fichiers trouvés') }}<template v-if="lastScan.added"> · {{ plural(lastScan.added, 'ajouté') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Choix de dossier -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@@ -142,14 +135,12 @@
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
|
||||
<!-- Assistant Plex -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
|
||||
@@ -178,7 +169,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
@@ -198,7 +189,7 @@ const savedSnapshot = ref('')
|
||||
|
||||
const pathFields = [
|
||||
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
|
||||
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
|
||||
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Dossier racine lu par Jellyfin, Plex ou Kodi.' },
|
||||
] as const
|
||||
|
||||
function onNfoToggle(val: boolean) {
|
||||
@@ -231,7 +222,6 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
|
||||
|
||||
function isRootPath(p: string): boolean { return !p || p === '/' }
|
||||
|
||||
// Réglages modifiés
|
||||
const dirtyCount = computed(() => {
|
||||
if (!savedSnapshot.value) return 0
|
||||
const saved = JSON.parse(savedSnapshot.value)
|
||||
@@ -259,7 +249,7 @@ async function save() {
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
else toast("Impossible d'enregistrer les réglages", 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -272,9 +262,9 @@ async function scan() {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
toast("Impossible d'analyser la médiathèque", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── Compte ─────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
|
||||
<SettingsSection title="Compte" description="Le nom d'utilisateur sert à la connexion et apparaît sur vos demandes.">
|
||||
<div class="flex items-center gap-[18px]">
|
||||
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
|
||||
{{ auth.username?.charAt(0).toUpperCase() }}
|
||||
@@ -21,7 +20,6 @@
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Mot de passe ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
@@ -48,14 +46,12 @@
|
||||
</SettingsSection>
|
||||
|
||||
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
|
||||
<!-- ── Apparence ───────────────────────────────────────── -->
|
||||
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
|
||||
<SettingsSection title="Apparence" description="Couleur d'accent de l'interface. Ce choix ne s'applique qu'à ce navigateur.">
|
||||
<ThemePicker />
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Jeton d'API ─────────────────────────────────────── -->
|
||||
<div data-tour="profile-token">
|
||||
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
|
||||
<SettingsSection title="Jeton d'API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
|
||||
{{ showToken ? myToken : maskedToken }}
|
||||
@@ -74,8 +70,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
|
||||
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer <jeton></code>.
|
||||
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l'ancien.
|
||||
Authentification par l'en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer <jeton></code>.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
@@ -101,7 +97,7 @@ function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Mot de passe ──────────────────────────────────────────────
|
||||
// ── Mot de passe ──
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
@@ -120,7 +116,7 @@ async function changePassword() {
|
||||
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
|
||||
pwdError.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return
|
||||
}
|
||||
|
||||
changingPwd.value = true
|
||||
@@ -138,12 +134,12 @@ async function changePassword() {
|
||||
confirmPassword.value = ''
|
||||
setTimeout(() => { pwdSuccess.value = false }, 3000)
|
||||
} else {
|
||||
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
|
||||
pwdError.value = data.error ?? 'Impossible de changer le mot de passe.'
|
||||
}
|
||||
changingPwd.value = false
|
||||
}
|
||||
|
||||
// ── Jeton d'API 2───────────────────────────────────────────────
|
||||
// ── Jeton d'API ──
|
||||
const myToken = ref('')
|
||||
const showToken = ref(false)
|
||||
const tokenCopied = ref(false)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row min-h-full">
|
||||
<!-- h-auto! annule le h-full du layout, sinon le menu sticky défile avec la page -->
|
||||
<div class="flex flex-col md:flex-row min-h-full h-auto!">
|
||||
|
||||
<nav
|
||||
v-if="groups.length > 1"
|
||||
ref="navRef"
|
||||
data-tour="settings-menu"
|
||||
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
|
||||
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-dvh md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
|
||||
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
|
||||
>
|
||||
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
|
||||
@@ -56,15 +57,15 @@ watch(() => route.path, async () => {
|
||||
}, { immediate: true })
|
||||
|
||||
const pages: Record<string, { title: string; description: string }> = {
|
||||
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
|
||||
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
|
||||
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
|
||||
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
|
||||
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
|
||||
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
|
||||
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
|
||||
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
|
||||
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
|
||||
'/settings/profile' : { title: 'Mon profil', description: "Votre compte, votre mot de passe et l'apparence de l'application." },
|
||||
'/settings/users' : { title: 'Utilisateurs et invitations', description: "Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l'activité ni les paramètres." },
|
||||
'/settings/media-management' : { title: 'Gestion des médias', description: "Dossiers, mode d'import et serveurs multimédias." },
|
||||
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
|
||||
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
|
||||
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
|
||||
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' },
|
||||
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
|
||||
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
|
||||
}
|
||||
|
||||
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Comptes ────────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
|
||||
@@ -18,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
|
||||
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
|
||||
<span>Utilisateur</span><span>Rôle</span><span>Dernière connexion</span><span>Jeton d'API</span><span />
|
||||
</div>
|
||||
|
||||
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
|
||||
@@ -41,7 +40,7 @@
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
|
||||
</span>
|
||||
<span class="text-[13px] text-secondary order-4 md:order-none">
|
||||
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
|
||||
<span class="md:hidden text-muted">Dernière connexion : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
|
||||
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
|
||||
@@ -72,10 +71,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Invitations ────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
|
||||
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ plural(activeInvites, 'lien actif', 'liens actifs') }}</span></h3>
|
||||
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
|
||||
</div>
|
||||
|
||||
@@ -116,10 +114,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement auto ────────────── -->
|
||||
<SettingsSection
|
||||
title="Téléchargement automatique"
|
||||
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
|
||||
title="Approbation automatique"
|
||||
description="Les demandes de ces utilisateurs sont approuvées et téléchargées sans attendre votre validation."
|
||||
>
|
||||
<template #actions>
|
||||
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
@@ -129,7 +126,7 @@
|
||||
:model-value="autoDownloadAll"
|
||||
@update:model-value="setAutoDownloadAll"
|
||||
label="Tous les utilisateurs"
|
||||
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
|
||||
description="Toutes les demandes sont approuvées et téléchargées automatiquement."
|
||||
/>
|
||||
|
||||
<template v-if="!autoDownloadAll">
|
||||
@@ -143,7 +140,7 @@
|
||||
<button
|
||||
type="button" role="switch"
|
||||
:aria-checked="autoDownloadUserIds.includes(u.id)"
|
||||
:aria-label="`Téléchargement automatique pour ${u.username}`"
|
||||
:aria-label="`Approbation automatique pour ${u.username}`"
|
||||
@click="toggleAutoDownloadUser(u.id)"
|
||||
class="switch"
|
||||
><span class="switch-knob" /></button>
|
||||
@@ -152,7 +149,6 @@
|
||||
</template>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
|
||||
@@ -162,13 +158,13 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-name" class="field-label">Identifiant</label>
|
||||
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
|
||||
<label for="user-name" class="field-label">Nom d'utilisateur</label>
|
||||
<input id="user-name" v-model="userModal.username" type="text" class="field" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
|
||||
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
|
||||
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
|
||||
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<span class="field-label">Rôle</span>
|
||||
@@ -184,14 +180,13 @@
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="userModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
|
||||
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
{{ userModal.loading ? (userModal.mode === 'create' ? 'Création…' : 'Enregistrement…') : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Modal créer invitation ─────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
|
||||
@@ -199,12 +194,12 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-note" class="field-label">Note (facultative)</label>
|
||||
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
|
||||
<label for="invite-note" class="field-label">Message pour la personne invitée (facultatif)</label>
|
||||
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-uses" class="field-label">Utilisations max</label>
|
||||
<label for="invite-uses" class="field-label">Nombre d'utilisations</label>
|
||||
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
@@ -212,7 +207,7 @@
|
||||
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
|
||||
<p class="text-xs text-muted">Laissez un champ vide pour ne pas fixer de limite.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
|
||||
@@ -220,7 +215,7 @@
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
|
||||
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
|
||||
{{ inviteModal.loading ? 'Création…' : 'Créer le lien' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,13 +262,13 @@ const copied = ref<string | null>(null)
|
||||
const pendingByUser = ref<Map<string, number>>(new Map())
|
||||
const userMenu = ref<string | null>(null)
|
||||
|
||||
// ── Téléchargement auto des demandes ──────────────────────────
|
||||
// ── Approbation automatique ──
|
||||
const autoDownloadAll = ref(false)
|
||||
const autoDownloadUserIds = ref<string[]>([])
|
||||
const autoDownloadSaved = ref(false)
|
||||
let autoDownloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Modals ────────────────────────────────────────────────────
|
||||
// ── Modales ──
|
||||
const userModal = ref<{
|
||||
mode : 'create' | 'edit'
|
||||
id? : string
|
||||
@@ -292,7 +287,7 @@ const inviteModal = ref<{
|
||||
loading : boolean
|
||||
} | null>(null)
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
// ── Chargement ──
|
||||
async function loadUsers() {
|
||||
const res = await fetch('/api/users', { credentials: 'include' })
|
||||
if (res.ok) users.value = await res.json()
|
||||
@@ -361,7 +356,7 @@ onMounted(() => {
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────
|
||||
// ── Utilisateurs ──
|
||||
function openCreateUser() {
|
||||
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
|
||||
}
|
||||
@@ -376,8 +371,8 @@ async function submitUserModal() {
|
||||
m.error = null
|
||||
m.loading = true
|
||||
|
||||
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
|
||||
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
|
||||
if (!m.username) { m.error = "Nom d'utilisateur requis."; m.loading = false; return }
|
||||
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis.'; m.loading = false; return }
|
||||
|
||||
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
|
||||
const method = m.mode === 'create' ? 'POST' : 'PATCH'
|
||||
@@ -385,15 +380,15 @@ async function submitUserModal() {
|
||||
if (m.password) body.password = m.password
|
||||
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (!res.ok) { m.error = data.error; m.loading = false; return }
|
||||
if (!res.ok) { m.error = data.error ?? 'Impossible d\'enregistrer le compte.'; m.loading = false; return }
|
||||
userModal.value = null
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function confirmDelete(u: User) {
|
||||
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
|
||||
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return
|
||||
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
loadUsers()
|
||||
}
|
||||
@@ -408,7 +403,7 @@ function userSubtitle(u: User) {
|
||||
}
|
||||
|
||||
function maskToken(token: string) {
|
||||
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
|
||||
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : 'Aucun jeton'
|
||||
}
|
||||
|
||||
async function copyToken(u: User) {
|
||||
@@ -419,7 +414,7 @@ async function copyToken(u: User) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Invites ───────────────────────────────────────────────────
|
||||
// ── Invitations ──
|
||||
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
|
||||
|
||||
function openCreateInvite() {
|
||||
@@ -436,9 +431,9 @@ async function submitInviteModal() {
|
||||
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
|
||||
|
||||
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
|
||||
const data = await res.json()
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (!res.ok) { m.error = data.error; m.loading = false; return }
|
||||
if (!res.ok) { m.error = data.error ?? 'Impossible de créer l\'invitation.'; m.loading = false; return }
|
||||
inviteModal.value = null
|
||||
loadInvites()
|
||||
}
|
||||
@@ -461,7 +456,7 @@ async function copyInvite(code: string) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
// ── Utilitaires ──
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user