feat(ui): refonte de l'interface
This commit is contained in:
1 parent
3ede3e808c
commit
09eb1fa3aa
79 files changed
+5323
-3514
No files matched your search
@@ -1,40 +1,81 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Avancé</h2>
|
||||
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="checkForUpdates" :disabled="checking" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': checking }" />
|
||||
{{ checking ? 'Vérification…' : 'Vérifier les mises à jour' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<!-- ── Version ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Version">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary flex-wrap">
|
||||
FanKarr {{ currentVersion || '…' }}
|
||||
<span v-if="latestVersion && updateAvailable" class="pill pill-wait h-5 px-[9px] text-[11px]">{{ latestVersion }} disponible</span>
|
||||
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
|
||||
</span>
|
||||
</div>
|
||||
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
Notes de version <ExternalLink :size="13" />
|
||||
</a>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Diag ─────────────────────────────────────────── -->
|
||||
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
|
||||
<template #actions>
|
||||
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
|
||||
</template>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-5 gap-x-6 gap-y-4">
|
||||
<div v-for="d in diagnostics" :key="d.label" class="flex flex-col gap-0.5">
|
||||
<span class="font-display text-2xl font-bold" :class="d.accent ? 'text-accent' : 'text-primary'">{{ d.value }}</span>
|
||||
<span class="text-meta text-muted">{{ d.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-meta text-muted">Chargement…</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Dev ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Développement">
|
||||
<template #actions>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
</template>
|
||||
<SettingsToggle
|
||||
v-model="devMode"
|
||||
label="Mode développeur"
|
||||
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
|
||||
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
|
||||
|
||||
<div class="settings-card flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-primary">Assistant de configuration</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Reprend pas à pas les dossiers, le client torrent, Jellyfin/Plex et le catalogue. La configuration actuelle est conservée.
|
||||
</p>
|
||||
<!-- ── Assistant et visite ────────────────────────────────── -->
|
||||
<SettingsSection title="Assistant et visite guidée">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Assistant de configuration</span>
|
||||
<span class="text-meta text-muted">Reprend pas à pas les dossiers, le client torrent, Jellyfin ou Plex et le catalogue. La configuration actuelle est conservée.</span>
|
||||
</div>
|
||||
<div class="flex gap-2.5 shrink-0 flex-wrap">
|
||||
<button @click="tour.start()" class="btn-ghost btn-sm pointer-fine:h-[34px]">Revoir la visite</button>
|
||||
<RouterLink to="/setup" class="btn-secondary btn-sm pointer-fine:h-[34px]">Relancer l'assistant</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button @click="tour.start()" class="btn-ghost">Revoir la visite</button>
|
||||
<RouterLink to="/setup" class="btn-secondary">Relancer l'assistant</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Check, ExternalLink, RefreshCw } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
|
||||
const tour = useTourStore()
|
||||
@@ -44,7 +85,67 @@ const saved = ref(false)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loaded = false
|
||||
|
||||
// ── Version ───────────────────────────────────────────────────
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
const currentVersion = ref('')
|
||||
const latestVersion = ref('')
|
||||
const releaseUrl = ref(`https://github.com/${GITHUB_REPO}/releases/latest`)
|
||||
const checking = ref(false)
|
||||
|
||||
const updateAvailable = computed(() => {
|
||||
if (!latestVersion.value || !currentVersion.value || currentVersion.value === 'dev') return false
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
const l = parse(latestVersion.value), c = parse(currentVersion.value)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
if ((l[i] ?? 0) !== (c[i] ?? 0)) return (l[i] ?? 0) > (c[i] ?? 0)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
checking.value = true
|
||||
try {
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) currentVersion.value = (await vRes.json()).version
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (gRes.ok) {
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
releaseUrl.value = release.html_url ?? releaseUrl.value
|
||||
}
|
||||
} catch {} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diag ────────────────────────────────────────────────
|
||||
const debug = ref<any>(null)
|
||||
|
||||
async function fetchDebug() {
|
||||
const res = await fetch('/api/debug/stats', { credentials: 'include' })
|
||||
if (res.ok) debug.value = await res.json()
|
||||
}
|
||||
|
||||
// « 4 h 37 min »
|
||||
const uptime = computed(() => {
|
||||
const s = debug.value?.uptimeSeconds ?? 0
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d} j ${h} h` : h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
})
|
||||
|
||||
const diagnostics = computed(() => debug.value ? [
|
||||
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
|
||||
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
|
||||
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
|
||||
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
|
||||
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
|
||||
] : [])
|
||||
|
||||
onMounted(async () => {
|
||||
checkForUpdates()
|
||||
fetchDebug()
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
@@ -65,4 +166,4 @@ watch(devMode, async (val) => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saved.value = false }, 2000)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,99 +1,148 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
|
||||
</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="update" :disabled="updating" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': updating }" />
|
||||
{{ updating ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div
|
||||
v-if="status.empty"
|
||||
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
|
||||
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statut -->
|
||||
<div class="settings-card">
|
||||
<p class="text-xs text-muted mb-1">Statut</p>
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom).
|
||||
Réactivez la surveillance sur la nouvelle fiche si besoin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
|
||||
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
|
||||
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
|
||||
<button
|
||||
@click="removeSyncOrphan(s.serieId)"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
<!-- Données manquantes -->
|
||||
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Données manquantes</p>
|
||||
<p class="text-meta text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
Scraper GitHub
|
||||
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">API Fankai</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
|
||||
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillance RSS -->
|
||||
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
|
||||
<template #actions>
|
||||
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
|
||||
<Loader v-if="runningRss" :size="14" class="animate-spin" />
|
||||
<RefreshCw v-else :size="14" />
|
||||
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="flex items-baseline gap-[9px]">
|
||||
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
|
||||
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
|
||||
<RouterLink
|
||||
v-for="s in visibleWatched" :key="s.serieId"
|
||||
:to="`/series/${s.serieId}`"
|
||||
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
|
||||
>{{ s.serieName }}</RouterLink>
|
||||
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
|
||||
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<SettingsSection
|
||||
v-if="syncOrphans.length > 0"
|
||||
:title="`Surveillances orphelines · ${syncOrphans.length}`"
|
||||
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
|
||||
>
|
||||
<div class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
|
||||
<span class="flex-1 min-w-0 text-body text-primary truncate">{{ s.serieName }}</span>
|
||||
<span class="text-meta text-muted shrink-0 tabular-nums">#{{ s.serieId }}</span>
|
||||
<button @click="removeSyncOrphan(s.serieId)" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
const seriesStore = useSeriesStore()
|
||||
|
||||
const updating = ref(false)
|
||||
const loaded = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
const status = ref<{ exists: boolean; count: number; empty: boolean; syncedAt?: string | null }>({ exists: false, count: 0, empty: true })
|
||||
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
|
||||
const watched = ref<{ serieId: number; serieName: string }[]>([])
|
||||
const runningRss = ref(false)
|
||||
const showAllWatched = ref(false)
|
||||
const visibleWatched = computed(() => showAllWatched.value ? watched.value : watched.value.slice(0, 8))
|
||||
const apiOnlyCount = computed(() => Math.max(0, seriesStore.series.length - status.value.count))
|
||||
|
||||
onMounted(async () => {
|
||||
fetchSyncOrphans()
|
||||
fetchWatched()
|
||||
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
async function fetchWatched() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync', { credentials: 'include' })
|
||||
if (res.ok) watched.value = (await res.json()).sort((a: any, b: any) => a.serieName.localeCompare(b.serieName, 'fr'))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchSyncOrphans() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
|
||||
@@ -106,19 +155,34 @@ async function removeSyncOrphan(serieId: number) {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
|
||||
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
|
||||
watched.value = watched.value.filter(s => s.serieId !== serieId)
|
||||
toast('Surveillance retirée ✓', 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function runRss() {
|
||||
runningRss.value = true
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
|
||||
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
runningRss.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
status.value = { exists: true, count, empty: count === 0 }
|
||||
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
fetchSyncOrphans()
|
||||
} else {
|
||||
@@ -131,4 +195,4 @@ async function update() {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,125 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="openAddModal" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Plus :size="15" :stroke-width="2.25" /> Ajouter un client
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Clients configurés -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Clients configurés</h3>
|
||||
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
|
||||
</div>
|
||||
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste vide -->
|
||||
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun client configuré.
|
||||
</div>
|
||||
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
|
||||
<p class="text-body text-muted">Aucun client configuré.</p>
|
||||
<button @click="openAddModal" class="btn-secondary btn-sm">Ajouter un client</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste clients -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.uuid"
|
||||
class="settings-card flex items-center justify-between"
|
||||
class="flex items-center gap-4 px-5 py-3.5 border-t border-hover flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
|
||||
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="text-sm font-medium text-primary">{{ client.name }}</span>
|
||||
<span
|
||||
class="pill h-5 px-[9px] text-[11px]"
|
||||
:class="healthStatus[client.uuid] === true ? 'pill-ok' : healthStatus[client.uuid] === false ? 'pill-err' : 'pill-muted'"
|
||||
>
|
||||
{{ healthStatus[client.uuid] === true ? 'Connecté' : healthStatus[client.uuid] === false ? 'Injoignable' : 'Vérification…' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted truncate">
|
||||
{{ clientLabel(client.type) }}<template v-if="client.config?.url"> · {{ client.config.url }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="testClient(client.uuid)" :disabled="testingClient[client.uuid]" class="btn-secondary btn-sm pointer-fine:h-[34px]">
|
||||
{{ testingClient[client.uuid] ? 'Test…' : 'Tester' }}
|
||||
</button>
|
||||
<div class="relative" @click.stop>
|
||||
<button
|
||||
@click="clientMenu = clientMenu === client.uuid ? null : client.uuid"
|
||||
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-primary hover:bg-hover transition-colors"
|
||||
:aria-label="`Actions pour ${client.name}`" aria-haspopup="menu" :aria-expanded="clientMenu === client.uuid"
|
||||
>
|
||||
<Ellipsis :size="15" />
|
||||
</button>
|
||||
<div v-if="clientMenu === client.uuid" class="menu absolute right-0 top-full mt-1.5 w-44 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item" @click="clientMenu = null; editClient(client)"><Pencil :size="14" /> Modifier</button>
|
||||
<button role="menuitem" class="menu-item text-err hover:text-err" @click="clientMenu = null; deleteClient(client)"><Trash2 :size="14" /> Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
||||
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
|
||||
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
|
||||
<div class="overflow-x-auto rounded-xl border border-border">
|
||||
<table class="w-full text-xs">
|
||||
<!-- Types pris en charge -->
|
||||
<section class="bg-card rounded-card overflow-hidden">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Types pris en charge</h3>
|
||||
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto border-t border-hover">
|
||||
<table class="w-full min-w-[720px] text-meta">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
|
||||
<tr class="border-b border-hover">
|
||||
<th class="text-left px-5 h-[34px] tag-label tracking-[0.12em] font-bold">Client</th>
|
||||
<th class="text-left px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Authentification</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Choix du fichier</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<!-- Client + version -->
|
||||
<td class="px-4 py-2.5">
|
||||
<tbody>
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="border-b border-hover last:border-b-0">
|
||||
<td class="px-5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="matrixHealth(row.id) === true ? 'bg-ok' : matrixHealth(row.id) === false ? 'bg-err' : 'bg-transparent'"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
|
||||
<p class="text-body text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-xs text-muted">{{ row.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Auth -->
|
||||
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<!-- Sélection fichier -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
|
||||
{{ row.fileSelect ? 'Async ✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Progression par fichier -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.fileProgress ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Upload .torrent (FanKarr proxy) -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
|
||||
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Catégories -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.categories ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Suppression fichiers -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.deleteFiles ? '✓' : '✗' }}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
|
||||
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
|
||||
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
|
||||
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
|
||||
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
|
||||
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
<!-- Modale ajout / modification -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="modal.open"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-primary">{{ modal.client ? 'Modifier un client' : 'Ajouter un client' }}</h3>
|
||||
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
||||
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
|
||||
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 id="client-modal-title" class="font-display text-xl font-bold text-primary">{{ modal.client ? 'Modifier le client' : 'Ajouter un client' }}</h3>
|
||||
<button @click="closeModal" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<TorrentClientForm
|
||||
@@ -128,7 +122,6 @@
|
||||
:definitions="availableClients"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -137,7 +130,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Ellipsis, Pencil, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
|
||||
@@ -147,6 +141,16 @@ const { add: toast } = useToast()
|
||||
const clients = ref<SavedTorrentClient[]>([])
|
||||
const availableClients = ref<TorrentClientDefinition[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
const testingClient = ref<Record<string, boolean>>({})
|
||||
const clientMenu = ref<string | null>(null)
|
||||
|
||||
const closeClientMenu = () => { clientMenu.value = null }
|
||||
onUnmounted(() => document.removeEventListener('click', closeClientMenu))
|
||||
|
||||
function matrixHealth(type: string) {
|
||||
const client = clients.value.find(c => c.type === type)
|
||||
return client ? healthStatus.value[client.uuid] : undefined
|
||||
}
|
||||
|
||||
const clientMatrix = [
|
||||
{
|
||||
@@ -189,6 +193,7 @@ onMounted(async () => {
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (availRes.ok) availableClients.value = await availRes.json()
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
document.addEventListener('click', closeClientMenu)
|
||||
})
|
||||
|
||||
async function refreshHealth(uuid: string) {
|
||||
@@ -202,13 +207,22 @@ async function refreshHealth(uuid: string) {
|
||||
}
|
||||
|
||||
async function testClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
testingClient.value[uuid] = true
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
testingClient.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(uuid: string) {
|
||||
async function deleteClient(client: SavedTorrentClient) {
|
||||
if (!confirm(`Supprimer le client « ${client.name} » ?`)) return
|
||||
const uuid = client.uuid
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
clients.value = clients.value.filter(c => c.uuid !== uuid)
|
||||
|
||||
@@ -1,212 +1,235 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Visualisez et renommez vos fichiers importés selon le mode actuel
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
|
||||
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
<ScanSearch v-else :size="15" />
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Nommage ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Nommage">
|
||||
<template #description>
|
||||
FanKarr renomme les fichiers d’après le catalogue Fankai, en
|
||||
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="loading">Analyse des fichiers importés…</template>
|
||||
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
|
||||
<template v-else>Tous les fichiers portent le nom attendu.</template>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="renameAll()" :disabled="renamingAll || totalNeedsRename === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ renamingAll ? 'Renommage…' : totalNeedsRename > 0 ? `Renommer les ${totalNeedsRename}` : 'Renommer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="load" :disabled="loading" class="btn-secondary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
:class="loading ? 'animate-spin' : ''">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
<polyline points="16 5 21 5 21 10"/>
|
||||
</svg>
|
||||
Actualiser
|
||||
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
|
||||
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
|
||||
</p>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
|
||||
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ renamingFolders ? 'Renommage…' : staleFolders.length > 1 ? `Renommer les ${staleFolders.length} dossiers` : 'Renommer le dossier' }}
|
||||
</button>
|
||||
<button
|
||||
@click="forceMetadataRefresh"
|
||||
:disabled="forcingRefresh"
|
||||
class="btn-secondary"
|
||||
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
|
||||
>
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
|
||||
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
|
||||
</div>
|
||||
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
class="btn-secondary text-red-400 hover:text-red-300"
|
||||
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
|
||||
>
|
||||
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Métadonnées ────────────────────────────────────────── -->
|
||||
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
|
||||
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
|
||||
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ checkingNfo ? 'Lancement…' : 'Vérifier' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-err">Purger les NFO</span>
|
||||
<span class="text-meta text-muted">Supprime tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de séries. Les vidéos ne sont pas touchées.</span>
|
||||
</div>
|
||||
<button @click="purgeNfo" :disabled="purgingNfo" class="btn-danger btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ purgingNfo ? 'Suppression…' : 'Purger' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
|
||||
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
|
||||
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
|
||||
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
|
||||
</span>
|
||||
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
|
||||
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
|
||||
<ChevronDown :size="13" class="transition-transform" :class="{ 'rotate-180': orphansOpen }" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Nettoyer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
|
||||
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
|
||||
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
|
||||
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
|
||||
</div>
|
||||
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ migrating ? 'Migration…' : 'Migrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Fichiers importés, série par série ──────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
|
||||
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="totalNeedsRename > 0"
|
||||
@click="renameAll()"
|
||||
:disabled="renamingAll"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
|
||||
</button>
|
||||
@click="showOnlyNeedsRename = !showOnlyNeedsRename"
|
||||
class="chip"
|
||||
:class="{ 'is-active': showOnlyNeedsRename }"
|
||||
:aria-pressed="showOnlyNeedsRename"
|
||||
>À renommer<span class="chip-count">{{ seriesNeedingRename }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-12 text-muted text-body border-t border-hover">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucune série importée</p>
|
||||
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
|
||||
</div>
|
||||
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
|
||||
<p class="text-body text-primary">Aucune série importée</p>
|
||||
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultat rename all -->
|
||||
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
|
||||
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
|
||||
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
|
||||
</p>
|
||||
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste séries -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="serie in filteredSeries"
|
||||
:key="serie.serie_id"
|
||||
class="settings-card flex flex-col gap-0"
|
||||
>
|
||||
<!-- Header série -->
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
|
||||
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span v-if="serie.needs_rename > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{{ serie.needs_rename }} à renommer
|
||||
</span>
|
||||
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
✓ À jour
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
|
||||
Tout renommer
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
|
||||
Désimporter
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
|
||||
Supprimer fichiers
|
||||
</button>
|
||||
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
|
||||
<div class="flex items-center gap-3 px-5 py-3 flex-wrap">
|
||||
<button
|
||||
@click="toggleSerie(serie.serie_id)"
|
||||
class="w-7 h-7 -ml-1 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
|
||||
:aria-expanded="!collapsed.has(serie.serie_id)"
|
||||
:aria-label="collapsed.has(serie.serie_id) ? 'Déplier' : 'Replier'"
|
||||
>
|
||||
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
|
||||
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
|
||||
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
|
||||
<div class="flex items-center gap-1.5 ml-auto">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste épisodes -->
|
||||
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
|
||||
<div v-if="!collapsed.has(serie.serie_id)" class="pb-2">
|
||||
<div
|
||||
v-for="ep in serie.episodes"
|
||||
:key="ep.episode_id"
|
||||
class="flex items-center gap-3 py-2.5"
|
||||
class="flex items-center gap-3 px-5 py-2 border-t border-hover/60 flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<!-- Numéro -->
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
|
||||
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
|
||||
</span>
|
||||
|
||||
<!-- Noms -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
|
||||
<p class="text-body text-primary truncate">{{ ep.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 min-w-0">
|
||||
<span class="text-xs text-muted truncate" :title="ep.current_name">{{ ep.current_name }}</span>
|
||||
<template v-if="ep.needs_rename">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
|
||||
<ArrowRight :size="11" class="shrink-0 text-accent" />
|
||||
<span class="text-xs text-accent truncate" :title="ep.expected_name">{{ ep.expected_name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
|
||||
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
|
||||
<button
|
||||
v-else
|
||||
@click="renameSingle(ep, serie)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
|
||||
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
|
||||
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
|
||||
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, false)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
Désimporter
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, true)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entrées orphelines -->
|
||||
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Tout retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
|
||||
<button
|
||||
@click="removeOrphans([o.episode_id])"
|
||||
:disabled="removingOrphans"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -223,11 +246,32 @@ const renameResult = ref<{ done: number; errors: any[] } | null>(null)
|
||||
const showOnlyNeedsRename = ref(false)
|
||||
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
|
||||
const removingOrphans = ref(false)
|
||||
const orphansOpen = ref(false)
|
||||
const staleFolders = ref<{ serie_id: number; serie_title: string; expected: string; current: string[] }[]>([])
|
||||
const renamingFolders = ref(false)
|
||||
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
|
||||
const scanning = ref(false)
|
||||
const checkingNfo = ref(false)
|
||||
const lastNfoUpdate = ref<{ serieTitle: string; updatedAt: string } | null>(null)
|
||||
const migrating = ref(false)
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
|
||||
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
|
||||
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
|
||||
const missingIn = (serie: any) => serie.episodes.filter((e: any) => !e.file_exists).length
|
||||
const missingFiles = computed(() => series.value.reduce((acc, s) => acc + missingIn(s), 0))
|
||||
|
||||
// « 3 h », « 12 min », « 2 j »
|
||||
const sinceScan = computed(() => {
|
||||
if (!lastScan.value) return ''
|
||||
const m = Math.max(0, Math.floor((Date.now() - new Date(lastScan.value.at).getTime()) / 60_000))
|
||||
return m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} j`
|
||||
})
|
||||
|
||||
const filteredSeries = computed(() =>
|
||||
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
|
||||
@@ -248,6 +292,86 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const [folders, scanRes, nfo] = await Promise.all([
|
||||
fetch('/api/organized-folders', { credentials: 'include' }),
|
||||
fetch('/api/scan', { credentials: 'include' }),
|
||||
fetch('/api/nfo-updates/recent', { credentials: 'include' }),
|
||||
])
|
||||
if (folders.ok) staleFolders.value = await folders.json()
|
||||
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
|
||||
if (nfo.ok) lastNfoUpdate.value = (await nfo.json()).find((n: any) => n.status === 'updated') ?? null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolders() {
|
||||
const targets = staleFolders.value
|
||||
if (!targets.length) return
|
||||
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
|
||||
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
renamingFolders.value = true
|
||||
let moved = 0
|
||||
try {
|
||||
for (const f of targets) {
|
||||
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
|
||||
moved += d.moved ?? 0
|
||||
}
|
||||
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingFolders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNfo() {
|
||||
checkingNfo.value = true
|
||||
try {
|
||||
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
checkingNfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateIds() {
|
||||
migrating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
|
||||
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
|
||||
if (data.updated > 0) await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
migrating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSerie(id: number) {
|
||||
|
||||
@@ -1,73 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Serveur Jellyfin</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Configurez la connexion à votre serveur Jellyfin pour synchroniser les utilisateurs
|
||||
et permettre l'authentification depuis le plugin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
|
||||
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Synchronisation des utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Importe les utilisateurs Jellyfin en tant que comptes FanKarr.
|
||||
Les comptes existants sont ignorés. Les utilisateurs pourront ensuite se connecter
|
||||
via le plugin Jellyfin sans configuration supplémentaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div v-if="syncResult" class="flex flex-col gap-1">
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p v-if="syncResult.users.length > 0" class="text-xs text-muted">
|
||||
Nouveaux : {{ syncResult.users.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary w-fit disabled:opacity-50">
|
||||
<SettingsSection
|
||||
title="Synchronisation des utilisateurs"
|
||||
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
</button>
|
||||
<p v-if="syncError" class="text-xs text-red-400">{{ syncError }}</p>
|
||||
<p v-if="!hasToken" class="text-xs text-muted">Configurez d'abord le token admin.</p>
|
||||
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
|
||||
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
|
||||
<span v-else-if="syncResult" class="text-meta text-secondary">
|
||||
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Référence API ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-base font-semibold text-primary">Référence API v1</h2>
|
||||
<div class="settings-card overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Méthode</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Endpoint</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
<tr v-for="ep in apiEndpoints" :key="ep.path">
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-mono font-semibold" :class="methodClass(ep.method)">{{ ep.method }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<code class="text-muted">{{ ep.path }}</code>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted">{{ ep.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- ── API publique ───────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">API publique</h3>
|
||||
<p class="text-meta text-muted">
|
||||
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="ep in apiEndpoints" :key="ep.method + ep.path"
|
||||
class="flex items-center gap-x-3.5 gap-y-1 px-5 py-2.5 border-t border-hover flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<span class="pill h-[22px] px-2 text-[10.5px] w-14 justify-center shrink-0" :class="ep.method === 'GET' ? 'pill-neutral' : 'pill-wait'">{{ ep.method }}</span>
|
||||
<code class="text-body text-primary sm:w-60 shrink-0 font-sans">{{ ep.path }}</code>
|
||||
<span class="text-meta text-muted">{{ ep.desc }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -76,24 +54,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const hasToken = ref(false)
|
||||
const syncing = ref(false)
|
||||
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
|
||||
const syncError = ref<string | null>(null)
|
||||
|
||||
const apiEndpoints = [
|
||||
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
|
||||
{ method: 'GET', path: '/api/v1/series/search?q=', desc: 'Rechercher dans le catalogue FanKai' },
|
||||
{ method: 'POST', path: '/api/v1/requests', desc: 'Créer ou mettre à jour une demande' },
|
||||
{ method: 'GET', path: '/api/v1/requests', desc: 'Récupérer ses propres demandes' },
|
||||
]
|
||||
const apiBase = `${window.location.origin}/api/v1`
|
||||
|
||||
function methodClass(m: string) {
|
||||
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
|
||||
}
|
||||
const apiEndpoints = [
|
||||
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
|
||||
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
|
||||
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
|
||||
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
|
||||
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
|
||||
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
|
||||
]
|
||||
|
||||
async function syncUsers() {
|
||||
syncing.value = true
|
||||
|
||||
+135
-113
@@ -1,109 +1,108 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
|
||||
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="exportLogs" :disabled="entries.length === 0" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Download :size="15" /> Exporter
|
||||
</button>
|
||||
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
v-for="l in levels" :key="l.value"
|
||||
@click="filterLevel = l.value; load()"
|
||||
class="px-3 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="filterLevel === l.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
class="chip"
|
||||
:class="{
|
||||
'is-active': filterLevel === l.value,
|
||||
'border-err/30 text-err hover:text-err': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
|
||||
}"
|
||||
:aria-pressed="filterLevel === l.value"
|
||||
>
|
||||
{{ l.label }}
|
||||
{{ l.label }}<span
|
||||
class="chip-count"
|
||||
:class="{
|
||||
'text-err! font-bold': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
|
||||
'text-accent! font-bold': l.value === 'warn' && (counts.warn ?? 0) > 0 && filterLevel !== 'warn',
|
||||
}"
|
||||
>{{ l.value === 'all' ? totalCount : (counts[l.value] ?? 0) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option value="organize">organize</option>
|
||||
<option value="api">api</option>
|
||||
<option value="auth">auth</option>
|
||||
</select>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<span class="text-xs text-muted mr-1">Afficher</span>
|
||||
<button
|
||||
v-for="n in [100, 500, 2000]" :key="n"
|
||||
@click="limit = n; load()"
|
||||
class="px-2 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="limit === n
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ n === 2000 ? 'Tout' : n }}
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<select v-model="filterSource" @change="load()" class="field h-[34px] pointer-coarse:h-10 py-0 rounded-full w-auto text-[13px]" aria-label="Source">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option v-for="s in sources" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<div class="segmented" role="group" aria-label="Nombre d'entrées">
|
||||
<button
|
||||
v-for="n in [100, 500, 2000]" :key="n"
|
||||
@click="limit = n; load()"
|
||||
class="segmented-item"
|
||||
:class="{ 'is-active': limit === n }"
|
||||
:aria-pressed="limit === n"
|
||||
>{{ n === 2000 ? 'Tout' : n }}</button>
|
||||
</div>
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
|
||||
Aucun log
|
||||
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
|
||||
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="settings-card p-0 overflow-hidden">
|
||||
<section v-else class="bg-card rounded-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(entry, i) in entries" :key="i"
|
||||
class="border-b border-border/50 hover:bg-hover transition-colors"
|
||||
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
|
||||
>
|
||||
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
|
||||
{{ entry.level.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
|
||||
<td class="px-3 py-2 text-primary break-all">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm effacement -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
|
||||
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
|
||||
<p class="text-xs text-muted">Cette action est irréversible.</p>
|
||||
<div class="min-w-[640px]">
|
||||
<div class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
|
||||
<span>Heure</span><span>Niveau</span><span>Source</span><span>Message</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
|
||||
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
|
||||
<div
|
||||
v-for="(entry, i) in entries" :key="i"
|
||||
class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-start gap-y-1 px-5 py-2 border-b border-hover last:border-b-0 hover:bg-hover/30 transition-colors"
|
||||
>
|
||||
<span class="text-meta text-muted tabular-nums pt-px" :title="fullDate(entry.at)">{{ formatTime(entry.at) }}</span>
|
||||
<span>
|
||||
<span class="pill h-5 px-2 text-[10.5px] gap-1" :class="levelInfo(entry.level).class">
|
||||
<TriangleAlert v-if="entry.level === 'error'" :size="11" :stroke-width="2.25" />
|
||||
{{ levelInfo(entry.level).label }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted truncate pt-px">{{ entry.source }}</span>
|
||||
<span class="text-body break-words" :class="entry.level === 'error' ? 'text-err' : 'text-primary'">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-meta text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="logs-clear-title" class="card-title">Vider les journaux ?</h3>
|
||||
<p class="text-meta text-muted">Cette action est irréversible.</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="showConfirm = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="doClear" class="btn-danger">Vider</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,12 +112,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatSize } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const entries = ref<any[]>([])
|
||||
const allEntries = ref<any[]>([])
|
||||
const fileSize = ref(0)
|
||||
const loading = ref(true)
|
||||
const showConfirm = ref(false)
|
||||
@@ -127,27 +129,37 @@ const filterSource = ref('')
|
||||
const limit = ref(100)
|
||||
|
||||
const levels = [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
{ label: 'Tout', value: 'all' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Avertissements', value: 'warn' },
|
||||
{ label: 'Erreurs', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
]
|
||||
|
||||
// Compteurs et sources
|
||||
const counts = computed(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
|
||||
return c
|
||||
})
|
||||
const totalCount = computed(() => allEntries.value.length)
|
||||
const sources = computed(() => [...new Set(allEntries.value.map(e => e.source).filter(Boolean))].sort())
|
||||
|
||||
async function fetchLogs(level: string, source: string, max: number) {
|
||||
const params = new URLSearchParams({ limit: String(max), level, ...(source ? { source } : {}) })
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
return res.ok ? await res.json() : null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit.value),
|
||||
level: filterLevel.value,
|
||||
...(filterSource.value ? { source: filterSource.value } : {}),
|
||||
})
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
entries.value = data.entries
|
||||
fileSize.value = data.size
|
||||
}
|
||||
const [filtered, all] = await Promise.all([
|
||||
fetchLogs(filterLevel.value, filterSource.value, limit.value),
|
||||
fetchLogs('all', '', 2000),
|
||||
])
|
||||
if (filtered) { entries.value = filtered.entries; fileSize.value = filtered.size }
|
||||
if (all) allEntries.value = all.entries
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -156,30 +168,40 @@ async function load() {
|
||||
async function doClear() {
|
||||
showConfirm.value = false
|
||||
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
|
||||
toast('Logs effacés', 'success')
|
||||
toast('Journaux vidés', 'success')
|
||||
await load()
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
// Fichier texte
|
||||
function exportLogs() {
|
||||
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
|
||||
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `fankarr-journaux-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
const time = d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} ${time.slice(0, 5)}`
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
function fullDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('fr-FR')
|
||||
}
|
||||
|
||||
function levelClass(level: string): string {
|
||||
function levelInfo(level: string): { label: string; class: string } {
|
||||
return ({
|
||||
debug: 'bg-border text-muted',
|
||||
info : 'bg-blue-500/10 text-blue-400',
|
||||
warn : 'bg-yellow-500/10 text-yellow-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
} as Record<string, string>)[level] ?? 'bg-border text-muted'
|
||||
debug: { label: 'Debug', class: 'pill-muted' },
|
||||
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
|
||||
warn : { label: 'Avertissement', class: 'pill-wait' },
|
||||
error: { label: 'Erreur', class: 'pill-err' },
|
||||
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,190 +1,185 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Media Management</h2>
|
||||
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
|
||||
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Chemins -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Dossier téléchargements</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
<!-- Dossiers -->
|
||||
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
|
||||
<span class="field-label flex items-center gap-2">
|
||||
{{ field.label }}
|
||||
<span v-if="isDocker" class="pill pill-neutral h-5 px-2 text-[10.5px]">Docker</span>
|
||||
</span>
|
||||
<button
|
||||
@click="openPicker(field.key)"
|
||||
class="field flex items-center gap-2.5 text-left h-[42px] py-0"
|
||||
:class="isRootPath(form[field.key]) ? 'border-accent/50' : ''"
|
||||
>
|
||||
<span class="flex-1 truncate" :class="form[field.key] ? 'text-primary' : 'text-muted'">{{ form[field.key] || '/' }}</span>
|
||||
<span class="text-meta text-secondary shrink-0">Parcourir</span>
|
||||
</button>
|
||||
<p v-if="isRootPath(form[field.key])" class="text-xs text-accent flex items-center gap-1.5">
|
||||
<TriangleAlert :size="13" /> Chemin non configuré : cliquez pour choisir un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">{{ field.hint }}</p>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Médiathèque Fankai</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Alerte chemins non configurés -->
|
||||
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||
<p class="text-xs text-muted">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||
Configurez-les ci-dessus avant de sauvegarder.
|
||||
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Chemins non configurés</p>
|
||||
<p class="text-meta text-secondary">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
|
||||
:key="mode"
|
||||
@click="form.organizeMode = mode"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
|
||||
:class="form.organizeMode === mode
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
<!-- Import -->
|
||||
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
|
||||
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
|
||||
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
|
||||
<button
|
||||
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
|
||||
:key="mode"
|
||||
type="button"
|
||||
@click="form.organizeMode = mode"
|
||||
class="segmented-item"
|
||||
:class="{ 'is-active': form.organizeMode === mode }"
|
||||
:aria-pressed="form.organizeMode === mode"
|
||||
>
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div class="h-px bg-hover" />
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
<SettingsToggle
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)."
|
||||
label="NFO et métadonnées"
|
||||
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
|
||||
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si fichier manquant"
|
||||
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
|
||||
label="Désimporter si le fichier a disparu"
|
||||
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
|
||||
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
|
||||
label="Dossiers « Season » plutôt que « Saison »"
|
||||
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Plex -->
|
||||
<div class="settings-card flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="settings-label">Plex</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
|
||||
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
|
||||
</p>
|
||||
<!-- Serveurs média -->
|
||||
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
Jellyfin
|
||||
<span class="pill h-5 px-[9px] text-[11px]" :class="jellyfinConfigured ? 'pill-ok' : 'pill-muted'">{{ jellyfinConfigured ? 'Configuré' : 'Non configuré' }}</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">Synchronisation des comptes et connexion depuis le plugin FanKarr Search.</span>
|
||||
</div>
|
||||
<RouterLink to="/settings/jellyfin" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Réglages Jellyfin</RouterLink>
|
||||
</div>
|
||||
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Plex</span>
|
||||
<span class="text-meta text-muted">Crée une bibliothèque Plex reliée à l'agent de métadonnées Fankai. Nécessite Plex Media Server 1.43 ou plus récent pour configurer l'agent automatiquement.</span>
|
||||
</div>
|
||||
<button @click="plexOpen = true" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Configurer Plex</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '...' : 'Sauvegarder' }}
|
||||
<!-- Scan -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
<ScanSearch v-else :size="15" />
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary">
|
||||
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
|
||||
</button>
|
||||
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
<span v-if="lastScan" class="text-meta text-muted">
|
||||
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
<!-- Choix de dossier -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
|
||||
<!-- Plex Wizard -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
<!-- Assistant Plex -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="nfoConfirmOpen"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="nfoConfirmOpen = false"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
|
||||
et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
|
||||
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 id="nfo-title" class="card-title">Activer les NFO et métadonnées ?</h3>
|
||||
<p class="text-meta text-secondary leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
les métadonnées sont déjà gérées par l'agent : activer les NFO est inutile et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-meta text-secondary leading-relaxed">
|
||||
Activez cette option seulement si vous utilisez <span class="text-primary">Infuse</span> ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="nfoConfirmOpen = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmNfo" class="btn-primary">Activer quand même</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
@@ -197,7 +192,14 @@ const isDocker = ref(false)
|
||||
const loaded = ref(false)
|
||||
const plexOpen = ref(false)
|
||||
const nfoConfirmOpen = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
|
||||
const jellyfinConfigured = ref(false)
|
||||
const savedSnapshot = ref('')
|
||||
|
||||
const pathFields = [
|
||||
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
|
||||
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
|
||||
] as const
|
||||
|
||||
function onNfoToggle(val: boolean) {
|
||||
if (val && !form.value.nfoSupport) {
|
||||
@@ -229,6 +231,13 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
|
||||
|
||||
function isRootPath(p: string): boolean { return !p || p === '/' }
|
||||
|
||||
// Réglages modifiés
|
||||
const dirtyCount = computed(() => {
|
||||
if (!savedSnapshot.value) return 0
|
||||
const saved = JSON.parse(savedSnapshot.value)
|
||||
return (Object.keys(form.value) as (keyof typeof form.value)[]).filter(k => saved[k] !== form.value[k]).length
|
||||
})
|
||||
|
||||
const hasUnconfiguredPaths = computed(() =>
|
||||
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
|
||||
)
|
||||
@@ -249,7 +258,7 @@ async function save() {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
@@ -258,12 +267,11 @@ async function save() {
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
@@ -276,12 +284,19 @@ async function scan() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
const [settingsRes, systemRes, infoRes, scanRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
fetch('/api/scan', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (settingsRes.ok) {
|
||||
const s = await settingsRes.json()
|
||||
for (const k of Object.keys(form.value) as (keyof typeof form.value)[]) if (k in s) (form.value as any)[k] = s[k]
|
||||
jellyfinConfigured.value = !!s.jellyfinUrl
|
||||
savedSnapshot.value = JSON.stringify(form.value)
|
||||
}
|
||||
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
loaded.value = true
|
||||
|
||||
@@ -1,103 +1,105 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── Informations du compte ────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Mon compte</h2>
|
||||
<p class="text-sm text-muted mt-1">Informations associées à votre compte FanKarr.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="settings-label mb-1">Nom d'utilisateur</label>
|
||||
<p class="text-sm text-primary font-mono bg-shell px-3 py-2 rounded-lg border border-border w-fit">
|
||||
{{ auth.username }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1">Rôle</label>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="auth.isAdmin ? 'bg-violet-500/20 text-violet-300' : 'bg-blue-500/20 text-blue-300'">
|
||||
{{ auth.isAdmin ? 'Administrateur' : 'Utilisateur' }}
|
||||
<!-- ── Compte ─────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
|
||||
<div class="flex items-center gap-[18px]">
|
||||
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
|
||||
{{ auth.username?.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[5px]">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="text-[17px] font-bold text-primary">{{ auth.username }}</span>
|
||||
<span class="pill" :class="auth.isAdmin ? 'pill-wait' : 'pill-neutral'">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}</span>
|
||||
</div>
|
||||
<span v-if="me" class="text-meta text-muted">
|
||||
<template v-if="me.createdAt">Compte créé le {{ formatDate(me.createdAt) }}</template>
|
||||
<template v-if="me.createdAt && me.lastLoginAt"> · </template>
|
||||
<template v-if="me.lastLoginAt">dernière connexion {{ formatRelative(me.lastLoginAt) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Changer de mot de passe ───────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Changer le mot de passe</h2>
|
||||
<p class="text-sm text-muted mt-1">Votre nouveau mot de passe doit faire au moins 6 caractères.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe actuel</label>
|
||||
<input v-model="currentPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
<!-- ── Mot de passe ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-current" class="field-label">Mot de passe actuel</label>
|
||||
<input id="pwd-current" v-model="currentPassword" type="password" class="field" placeholder="••••••••" autocomplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Nouveau mot de passe</label>
|
||||
<input v-model="newPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-new" class="field-label">Nouveau mot de passe</label>
|
||||
<input id="pwd-new" v-model="newPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Confirmer le nouveau mot de passe</label>
|
||||
<input v-model="confirmPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="changePassword" :disabled="changingPwd" class="btn-primary">
|
||||
{{ changingPwd ? 'Modification…' : 'Modifier le mot de passe' }}
|
||||
</button>
|
||||
<span v-if="pwdSuccess" class="text-xs text-green-400">Mot de passe modifié.</span>
|
||||
<span v-if="pwdError" class="text-xs text-red-400">{{ pwdError }}</span>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-confirm" class="field-label">Confirmer le nouveau mot de passe</label>
|
||||
<input id="pwd-confirm" v-model="confirmPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" @keyup.enter="changePassword" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Token API personnel ──────────────────────────────── -->
|
||||
<section data-tour="profile-token" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Utilisez ce token pour accéder à l'API FanKarr depuis des applications tierces
|
||||
(ex : plugin Jellyfin).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
|
||||
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
|
||||
</code>
|
||||
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ showToken ? 'Masquer' : 'Afficher' }}
|
||||
</button>
|
||||
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ tokenCopied ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
|
||||
Regénérer le token
|
||||
<div class="flex items-center gap-3.5 flex-wrap">
|
||||
<button @click="changePassword" :disabled="changingPwd" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ changingPwd ? 'Modification…' : 'Changer le mot de passe' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted">
|
||||
Authentification via header :
|
||||
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">Authorization: Bearer <token></code>
|
||||
</p>
|
||||
<span v-if="pwdSuccess" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Mot de passe modifié.</span>
|
||||
<span v-else-if="pwdError" class="text-meta text-err">{{ pwdError }}</span>
|
||||
<span v-else class="text-meta text-muted">Les autres sessions ouvertes resteront connectées.</span>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
|
||||
<!-- ── Apparence ───────────────────────────────────────── -->
|
||||
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
|
||||
<ThemePicker />
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Jeton d'API ─────────────────────────────────────── -->
|
||||
<div data-tour="profile-token">
|
||||
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
|
||||
{{ showToken ? myToken : maskedToken }}
|
||||
</code>
|
||||
<button @click="showToken = !showToken" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :aria-label="showToken ? 'Masquer le jeton' : 'Afficher le jeton'" :title="showToken ? 'Masquer' : 'Afficher'">
|
||||
<EyeOff v-if="showToken" :size="15" />
|
||||
<Eye v-else :size="15" />
|
||||
</button>
|
||||
<button @click="copyToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Check v-if="tokenCopied" :size="15" class="text-ok" />
|
||||
<Copy v-else :size="15" />
|
||||
{{ tokenCopied ? 'Copié' : 'Copier' }}
|
||||
</button>
|
||||
<button @click="regenerateToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" /> Régénérer
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
|
||||
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer <jeton></code>.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Check, Copy, Eye, EyeOff, RefreshCw } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import ThemePicker from '@/components/settings/ThemePicker.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const me = ref<{ createdAt?: string; lastLoginAt?: string | null } | null>(null)
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Mot de passe ──────────────────────────────────────────────
|
||||
const currentPassword = ref('')
|
||||
@@ -141,20 +143,25 @@ async function changePassword() {
|
||||
changingPwd.value = false
|
||||
}
|
||||
|
||||
// ── Token API ─────────────────────────────────────────────────
|
||||
// ── Jeton d'API 2───────────────────────────────────────────────
|
||||
const myToken = ref('')
|
||||
const showToken = ref(false)
|
||||
const tokenCopied = ref(false)
|
||||
|
||||
async function loadMyToken() {
|
||||
const maskedToken = computed(() => myToken.value
|
||||
? `${myToken.value.slice(0, 4)}${'•'.repeat(24)}${myToken.value.slice(-4)}`
|
||||
: '•'.repeat(32))
|
||||
|
||||
async function loadMe() {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
myToken.value = d.apiToken ?? ''
|
||||
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMyToken)
|
||||
onMounted(loadMe)
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
@@ -165,7 +172,7 @@ async function copyToken() {
|
||||
}
|
||||
|
||||
async function regenerateToken() {
|
||||
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
|
||||
if (!confirm('Régénérer le jeton ? L\'ancien sera immédiatement invalidé.')) return
|
||||
const res = await fetch('/api/auth/regenerate-token', {
|
||||
method: 'POST', credentials: 'include',
|
||||
})
|
||||
|
||||
@@ -1,27 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
|
||||
<RouterView />
|
||||
<div class="flex flex-col md:flex-row min-h-full">
|
||||
|
||||
<nav
|
||||
v-if="groups.length > 1"
|
||||
ref="navRef"
|
||||
data-tour="settings-menu"
|
||||
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
|
||||
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
|
||||
>
|
||||
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
|
||||
<div v-for="group in groups" :key="group.label" class="flex md:flex-col gap-1 md:gap-0.5 shrink-0">
|
||||
<span class="hidden md:block tag-label px-2.5 pb-2">{{ group.label }}</span>
|
||||
<RouterLink
|
||||
v-for="link in group.links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="flex items-center h-9 pointer-coarse:h-10 px-2.5 rounded-lg text-body whitespace-nowrap transition-colors"
|
||||
:class="route.path === link.to ? 'bg-hover text-primary font-semibold' : 'text-secondary hover:text-primary hover:bg-hover/60'"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1 min-w-0 px-4 md:px-10 pt-[26px] pb-16">
|
||||
<div class="max-w-[960px] flex flex-col gap-5">
|
||||
<header class="flex items-end justify-between gap-5 flex-wrap">
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<h2 class="font-display text-[26px] font-bold text-primary leading-tight">{{ page.title }}</h2>
|
||||
<p v-if="page.description" class="text-body text-muted">{{ page.description }}</p>
|
||||
</div>
|
||||
<div id="settings-actions" class="flex items-center gap-2.5 empty:hidden" />
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
'settings-profile' : 'Mon profil',
|
||||
'settings-download' : 'Clients de téléchargement',
|
||||
'settings-media' : 'Gestion des médias',
|
||||
'settings-catalogue' : 'Catalogue Fankai',
|
||||
'settings-logs' : 'Journaux',
|
||||
'settings-users' : 'Utilisateurs & Invitations',
|
||||
'settings-jellyfin' : 'Jellyfin & API',
|
||||
watch(() => route.path, async () => {
|
||||
await nextTick()
|
||||
const nav = navRef.value
|
||||
if (!nav || nav.scrollWidth <= nav.clientWidth) return
|
||||
nav.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({ inline: 'center', block: 'nearest' })
|
||||
}, { immediate: true })
|
||||
|
||||
const pages: Record<string, { title: string; description: string }> = {
|
||||
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
|
||||
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
|
||||
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
|
||||
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
|
||||
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
|
||||
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
|
||||
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
|
||||
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
|
||||
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
|
||||
</script>
|
||||
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
|
||||
|
||||
const groups = computed(() => {
|
||||
const link = (to: string) => ({ to, label: pages[to]!.title })
|
||||
if (!auth.isAdmin) return [{ label: 'Compte', links: [link('/settings/profile')] }]
|
||||
return [
|
||||
{ label: 'Compte', links: [link('/settings/profile'), link('/settings/users')] },
|
||||
{ label: 'Médias', links: [link('/settings/media-management'), link('/settings/import-management'), link('/settings/catalogue')] },
|
||||
{ label: 'Services', links: [link('/settings/download-client'), link('/settings/jellyfin')] },
|
||||
{ label: 'Système', links: [link('/settings/logs'), link('/settings/advanced')] },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
+245
-186
@@ -1,224 +1,226 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── Utilisateurs ─────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez les comptes ayant accès à FanKarr.</p>
|
||||
</div>
|
||||
<button @click="openCreateUser" class="btn-secondary">+ Créer</button>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="openCreateInvite" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Plus :size="15" :stroke-width="2.25" /> Créer une invitation
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div v-if="users.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun utilisateur.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="u in users" :key="u.id"
|
||||
class="settings-card flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>
|
||||
{{ u.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Utilisateur' }}
|
||||
· créé le {{ formatDate(u.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="openEditUser(u)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button
|
||||
@click="confirmDelete(u)"
|
||||
:disabled="u.id === currentUserId"
|
||||
class="btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>Supprimer</button>
|
||||
</div>
|
||||
<!-- ── Comptes ────────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-meta text-muted hidden sm:inline">Le jeton sert à l'API publique et aux applications tierces.</span>
|
||||
<button @click="openCreateUser" class="btn-secondary btn-sm"><UserPlus :size="14" /> Créer un compte</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Invitations ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Invitations</h2>
|
||||
<p class="text-sm text-muted mt-1">Générez des liens pour permettre à de nouveaux utilisateurs de s'inscrire.</p>
|
||||
</div>
|
||||
<button @click="openCreateInvite" class="btn-secondary">+ Générer</button>
|
||||
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
|
||||
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
|
||||
</div>
|
||||
|
||||
<div v-if="invites.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun lien d'invitation actif.
|
||||
</div>
|
||||
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="inv in invites" :key="inv.code"
|
||||
class="settings-card flex items-center justify-between gap-4"
|
||||
:class="{ 'opacity-50': isExpired(inv) || isExhausted(inv) }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span v-if="isExpired(inv) || isExhausted(inv)" class="text-xs text-muted">[Invalide]</span>
|
||||
<span v-if="inv.note" class="text-sm text-primary font-medium truncate">{{ inv.note }}</span>
|
||||
<span v-else class="text-sm text-muted italic">Sans note</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisations
|
||||
<template v-if="inv.expiresAt"> · expire {{ formatDate(inv.expiresAt) }}</template>
|
||||
<template v-else> · sans expiration</template>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<code class="text-xs text-muted bg-shell px-2 py-0.5 rounded truncate max-w-xs">
|
||||
{{ inviteUrl(inv.code) }}
|
||||
</code>
|
||||
<button @click="copyInvite(inv.code)" class="btn-ghost text-xs shrink-0">
|
||||
{{ copied === inv.code ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="u in users" :key="u.id"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center gap-x-3 gap-y-2 px-5 py-3 md:py-0 md:min-h-[58px] border-b border-hover last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-8 h-8 rounded-full bg-hover flex items-center justify-center font-display text-[15px] font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">
|
||||
{{ u.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-px min-w-0">
|
||||
<span class="text-sm font-semibold text-primary truncate">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted truncate">{{ userSubtitle(u) }}</span>
|
||||
</div>
|
||||
<button @click="deleteInvite(inv.code)" class="btn-ghost text-xs text-red-400 hover:text-red-300 shrink-0">
|
||||
Révoquer
|
||||
</div>
|
||||
|
||||
<span class="pill w-fit order-3 md:order-none" :class="u.role === 'admin' ? 'pill-wait' : 'pill-neutral'">
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
|
||||
</span>
|
||||
<span class="text-[13px] text-secondary order-4 md:order-none">
|
||||
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
|
||||
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
|
||||
<button @click="copyToken(u)" class="text-meta text-secondary hover:text-primary transition-colors shrink-0">
|
||||
{{ copied === `token-${u.id}` ? 'Copié' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement automatique des demandes ──────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Téléchargement automatique</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<!-- Tous les utilisateurs -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadAll"
|
||||
@change="toggleAutoDownloadAll"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">Tous les utilisateurs</p>
|
||||
<p class="text-xs text-muted">Chaque approbation déclenche un téléchargement, quel que soit le demandeur.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div v-if="!autoDownloadAll && users.length > 0" class="border-t border-border/50" />
|
||||
|
||||
<!-- Par utilisateur -->
|
||||
<template v-if="!autoDownloadAll">
|
||||
<label
|
||||
v-for="u in users" :key="u.id"
|
||||
class="flex items-center gap-3 cursor-pointer"
|
||||
<div class="relative justify-self-end order-2 md:order-none" @click.stop>
|
||||
<button
|
||||
@click="userMenu = userMenu === u.id ? null : u.id"
|
||||
class="w-[30px] h-[30px] rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
|
||||
:aria-label="`Actions pour ${u.username}`" aria-haspopup="menu" :aria-expanded="userMenu === u.id"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadUserIds.includes(u.id)"
|
||||
@change="toggleAutoDownloadUser(u.id)"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>{{ u.username.charAt(0).toUpperCase() }}</span>
|
||||
<span class="text-sm text-primary">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="text-xs text-muted">Aucun utilisateur.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="autoDownloadSaved" class="text-xs text-green-400">Paramètre sauvegardé.</p>
|
||||
<Ellipsis :size="16" />
|
||||
</button>
|
||||
<div v-if="userMenu === u.id" class="menu absolute right-0 top-full mt-1.5 w-48 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item" @click="userMenu = null; openEditUser(u)"><Pencil :size="14" /> Modifier</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
class="menu-item text-err hover:text-err"
|
||||
:disabled="u.id === currentUserId"
|
||||
:title="u.id === currentUserId ? 'Vous ne pouvez pas supprimer votre propre compte' : undefined"
|
||||
@click="userMenu = null; confirmDelete(u)"
|
||||
><Trash2 :size="14" /> Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ─────────────────── -->
|
||||
<!-- ── Invitations ────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
|
||||
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
|
||||
</div>
|
||||
|
||||
<p v-if="invites.length === 0" class="text-body text-muted px-5 py-5">Aucun lien d'invitation. Créez-en un pour inviter quelqu'un.</p>
|
||||
|
||||
<div
|
||||
v-for="inv in invites" :key="inv.code"
|
||||
class="flex items-center gap-4 flex-wrap sm:flex-nowrap px-5 py-3.5 border-b border-hover last:border-b-0"
|
||||
>
|
||||
<span class="w-8 h-8 rounded-full border border-dashed flex items-center justify-center shrink-0 text-muted" :class="isInvalid(inv) ? 'border-border-light opacity-60' : 'border-border'">
|
||||
<Link2 :size="15" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-body truncate" :class="isInvalid(inv) ? 'text-muted' : 'text-primary'">
|
||||
<template v-if="inv.note">{{ inv.note }} · </template>{{ inviteUrl(inv.code).replace(/^https?:\/\//, '') }}
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="isExhausted(inv)">utilisations épuisées ({{ inv.uses }}/{{ inv.maxUses }})</template>
|
||||
<template v-else-if="isExpired(inv)">expirée {{ formatRelative(inv.expiresAt!) }}</template>
|
||||
<template v-else>
|
||||
créée {{ formatRelative(inv.createdAt) }} · {{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisation{{ (inv.maxUses ?? 2) > 1 ? 's' : '' }}
|
||||
· {{ inv.expiresAt ? `expire ${formatFuture(inv.expiresAt)}` : 'sans expiration' }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<button v-if="!isInvalid(inv)" @click="copyInvite(inv.code)" class="btn-secondary btn-sm">
|
||||
<Check v-if="copied === inv.code" :size="14" class="text-ok" />
|
||||
<Copy v-else :size="14" />
|
||||
{{ copied === inv.code ? 'Lien copié' : 'Copier le lien' }}
|
||||
</button>
|
||||
<button
|
||||
@click="deleteInvite(inv.code)"
|
||||
class="w-8 h-8 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
|
||||
title="Révoquer le lien" aria-label="Révoquer le lien"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement auto ────────────── -->
|
||||
<SettingsSection
|
||||
title="Téléchargement automatique"
|
||||
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
|
||||
>
|
||||
<template #actions>
|
||||
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
</template>
|
||||
|
||||
<SettingsToggle
|
||||
:model-value="autoDownloadAll"
|
||||
@update:model-value="setAutoDownloadAll"
|
||||
label="Tous les utilisateurs"
|
||||
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
|
||||
/>
|
||||
|
||||
<template v-if="!autoDownloadAll">
|
||||
<div class="h-px bg-hover" />
|
||||
<div v-for="u in users" :key="u.id" class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-6 h-6 rounded-full bg-hover flex items-center justify-center font-display text-xs font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">{{ u.username.charAt(0).toUpperCase() }}</span>
|
||||
<span class="text-body text-primary truncate">{{ u.username }}</span>
|
||||
<span class="text-meta text-muted">{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button" role="switch"
|
||||
:aria-checked="autoDownloadUserIds.includes(u.id)"
|
||||
:aria-label="`Téléchargement automatique pour ${u.username}`"
|
||||
@click="toggleAutoDownloadUser(u.id)"
|
||||
class="switch"
|
||||
><span class="switch-knob" /></button>
|
||||
</div>
|
||||
<p v-if="users.length === 0" class="text-meta text-muted">Aucun utilisateur.</p>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="userModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="userModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">
|
||||
{{ userModal.mode === 'create' ? 'Créer un utilisateur' : 'Modifier l\'utilisateur' }}
|
||||
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
|
||||
<h3 id="user-modal-title" class="card-title">
|
||||
{{ userModal.mode === 'create' ? 'Créer un compte' : 'Modifier le compte' }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="userModal.username" type="text" class="settings-input" placeholder="username" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-name" class="field-label">Identifiant</label>
|
||||
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">
|
||||
{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}
|
||||
</label>
|
||||
<input v-model="userModal.password" type="password" class="settings-input"
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
|
||||
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
|
||||
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Rôle</label>
|
||||
<select v-model="userModal.role" class="settings-input">
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<span class="field-label">Rôle</span>
|
||||
<div class="segmented w-fit">
|
||||
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'user' }" @click="userModal.role = 'user'">Invité</button>
|
||||
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'admin' }" @click="userModal.role = 'admin'">Administrateur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="userModal.error" class="text-xs text-red-400">{{ userModal.error }}</p>
|
||||
<p v-if="userModal.error" class="text-meta text-err">{{ userModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="userModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
|
||||
{{ userModal.loading ? '...' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Modal créer invitation ────────────────────────────── -->
|
||||
<!-- ── Modal créer invitation ─────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="inviteModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="inviteModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">Générer une invitation</h3>
|
||||
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
|
||||
<h3 id="invite-modal-title" class="card-title">Créer une invitation</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Note (optionnelle)</label>
|
||||
<input v-model="inviteModal.note" type="text" class="settings-input" placeholder="Pour qui est ce lien ?" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-note" class="field-label">Note (facultative)</label>
|
||||
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Utilisations max</label>
|
||||
<input v-model.number="inviteModal.maxUses" type="number" min="1" class="settings-input" placeholder="1" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour illimité</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Expire dans (heures)</label>
|
||||
<input v-model.number="inviteModal.expiresInHours" type="number" min="1" class="settings-input" placeholder="24" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour pas d'expiration</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-uses" class="field-label">Utilisations max</label>
|
||||
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-hours" class="field-label">Expire dans (heures)</label>
|
||||
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="inviteModal.error" class="text-xs text-red-400">{{ inviteModal.error }}</p>
|
||||
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
|
||||
{{ inviteModal.loading ? '...' : 'Générer' }}
|
||||
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,16 +231,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Check, Copy, Ellipsis, Link2, Pencil, Plus, Trash2, UserPlus } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
|
||||
interface User {
|
||||
id : string
|
||||
username : string
|
||||
role : 'admin' | 'user'
|
||||
apiToken : string
|
||||
createdAt: string
|
||||
id : string
|
||||
username : string
|
||||
role : 'admin' | 'user'
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
lastLoginAt?: string | null
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
@@ -252,11 +259,13 @@ interface Invite {
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const currentUserId = auth.username // utilisé pour désactiver la suppression de soi-même
|
||||
const currentUserId = computed(() => auth.userId) // pas de suppression de son propre compte
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const invites = ref<Invite[]>([])
|
||||
const copied = ref<string | null>(null)
|
||||
const pendingByUser = ref<Map<string, number>>(new Map())
|
||||
const userMenu = ref<string | null>(null)
|
||||
|
||||
// ── Téléchargement auto des demandes ──────────────────────────
|
||||
const autoDownloadAll = ref(false)
|
||||
@@ -294,6 +303,16 @@ async function loadInvites() {
|
||||
if (res.ok) invites.value = await res.json()
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const counts = new Map<string, number>()
|
||||
for (const r of await res.json())
|
||||
if (r.status === 'pending')
|
||||
for (const q of r.requesters) counts.set(q.userId, (counts.get(q.userId) ?? 0) + 1)
|
||||
pendingByUser.value = counts
|
||||
}
|
||||
|
||||
async function loadAutoDownloadSetting() {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
@@ -321,8 +340,8 @@ async function saveAutoDownloadSetting() {
|
||||
autoDownloadTimer = setTimeout(() => { autoDownloadSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
function toggleAutoDownloadAll(e: Event) {
|
||||
autoDownloadAll.value = (e.target as HTMLInputElement).checked
|
||||
function setAutoDownloadAll(value: boolean) {
|
||||
autoDownloadAll.value = value
|
||||
if (autoDownloadAll.value) autoDownloadUserIds.value = []
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
@@ -334,7 +353,13 @@ function toggleAutoDownloadUser(userId: string) {
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); loadInvites(); loadAutoDownloadSetting() })
|
||||
const closeUserMenu = () => { userMenu.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers(); loadInvites(); loadAutoDownloadSetting(); loadPendingRequests()
|
||||
document.addEventListener('click', closeUserMenu)
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────
|
||||
function openCreateUser() {
|
||||
@@ -373,7 +398,30 @@ async function confirmDelete(u: User) {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function userSubtitle(u: User) {
|
||||
const pending = pendingByUser.value.get(u.id) ?? 0
|
||||
const parts: string[] = []
|
||||
if (u.id === currentUserId.value) parts.push('vous')
|
||||
if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`)
|
||||
if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function maskToken(token: string) {
|
||||
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
|
||||
}
|
||||
|
||||
async function copyToken(u: User) {
|
||||
try {
|
||||
await copyToClipboard(u.apiToken)
|
||||
copied.value = `token-${u.id}`
|
||||
setTimeout(() => { copied.value = null }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Invites ───────────────────────────────────────────────────
|
||||
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
|
||||
|
||||
function openCreateInvite() {
|
||||
inviteModal.value = { note: '', maxUses: 1, expiresInHours: 24, error: null, loading: false }
|
||||
}
|
||||
@@ -416,7 +464,14 @@ async function copyInvite(code: string) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
// « dans 5 j », « dans 3 h »
|
||||
function formatFuture(iso: string) {
|
||||
const diff = new Date(iso).getTime() - Date.now()
|
||||
const h = Math.round(diff / 3_600_000)
|
||||
return h < 24 ? `dans ${Math.max(1, h)} h` : `dans ${Math.round(h / 24)} j`
|
||||
}
|
||||
|
||||
function isExpired(inv: Invite) {
|
||||
@@ -426,4 +481,8 @@ function isExpired(inv: Invite) {
|
||||
function isExhausted(inv: Invite) {
|
||||
return inv.maxUses !== null && inv.uses >= inv.maxUses
|
||||
}
|
||||
|
||||
function isInvalid(inv: Invite) {
|
||||
return isExpired(inv) || isExhausted(inv)
|
||||
}
|
||||
</script>
|
||||
Reference in new issue
Block a user