refacto: réécriture des textes, messages et commentaires
This commit is contained in:
1 parent
6a3cb21adf
commit
586eccbbb3
118 files changed
+1621
-2159
No files matched your search
@@ -8,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