feat(onboarding): assistant de configuration au premier lancement et visite guidée des écrans

This commit is contained in:
ElPouki committed 2026-09-14 13:46:51 +02:00
1 parent 6348cc40cb
commit 4b3c629ff9
43 files changed
+2498 -366

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="flex flex-col gap-4">
<div v-if="!loaded" class="flex items-center justify-center py-10">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<section class="settings-card flex items-center gap-4">
<span
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
:class="status.empty ? 'bg-yellow-500/10 text-yellow-500' : 'bg-green-500/10 text-green-400'"
>
<Library :size="17" />
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">
{{ status.empty ? 'Catalogue non téléchargé' : `${status.count} séries disponibles` }}
</p>
<p class="text-xs text-muted mt-0.5">
{{ status.empty
? 'Sans catalogue, la médiathèque reste vide.'
: 'Le catalogue se met à jour automatiquement.' }}
</p>
</div>
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0">
{{ updating ? 'Téléchargement…' : status.empty ? 'Télécharger' : 'Mettre à jour' }}
</button>
</section>
<section class="settings-card flex items-center gap-4">
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
<ScanSearch :size="17" />
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
<p class="text-xs text-muted mt-0.5">
<template v-if="scanResult">{{ scanResult.found }} fichiers analysés · {{ scanResult.added }} référencés.</template>
<template v-else>Analysez la médiathèque pour référencer les fichiers présents, sans les déplacer.</template>
</p>
</div>
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
{{ scanning ? 'Analyse…' : 'Analyser' }}
</button>
</section>
<p v-if="warnEmpty" class="text-sm text-yellow-500" role="alert">
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Library, ScanSearch } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import type { SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const { add: toast } = useToast()
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
const warnEmpty = ref(false)
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) {
status.value = { exists: true, count: data.count, empty: data.count === 0 }
warnEmpty.value = false
toast(`${data.count} séries chargées ✓`, 'success')
} else {
toast(data.error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
async function scan() {
scanning.value = true
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 }
} else {
toast("Erreur lors de l'analyse", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
async function submit(): Promise<boolean> {
if (!status.value.empty || warnEmpty.value) return true
warnEmpty.value = true
return false
}
onMounted(async () => {
try {
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
} catch {}
loaded.value = true
})
defineExpose({ submit })
</script>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="flex flex-col gap-6">
<div v-if="!loaded" class="flex items-center justify-center py-10">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Clients existants -->
<div v-if="clients.length > 0" class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid" class="settings-card flex items-center gap-3">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="health[client.uuid] === true ? 'bg-green-500' : health[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
/>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium truncate">{{ client.name }}</p>
<p class="text-xs text-muted mt-0.5">
{{ labelOf(client.type) }}
<template v-if="health[client.uuid] === false"> · <span class="text-red-400">injoignable</span></template>
<template v-else-if="health[client.uuid] === true"> · <span class="text-green-400">connecté</span></template>
</p>
</div>
<button @click="edit(client)" class="btn-ghost text-xs">Modifier</button>
</div>
</div>
<!-- Formulaire -->
<div v-if="formOpen" class="settings-card flex flex-col gap-4">
<p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
<TorrentClientForm
:key="formKey"
:client="editing"
:definitions="definitions"
:show-cancel="clients.length > 0"
@saved="onSaved"
@cancel="formOpen = false"
/>
</div>
<button v-else @click="add" class="btn-secondary self-start flex items-center gap-1.5">
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
</button>
<!-- Rappel chemins -->
<div class="flex items-start gap-3 px-4 py-3 rounded-lg border border-border bg-card/50">
<Info :size="15" class="text-muted mt-0.5 shrink-0" />
<p class="text-xs text-muted leading-relaxed">
Le client doit enregistrer ses téléchargements dans un dossier que FanKarr voit
<template v-if="settings.completePath">(<code class="font-mono text-secondary">{{ settings.completePath }}</code>)</template>.
S'il tourne sur une autre machine ou dans un autre conteneur, renseignez
<span class="text-secondary">Chemin distant</span> et <span class="text-secondary">Chemin local</span>
dans les paramètres avancés du client.
</p>
</div>
<div v-if="warnSkip" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5" role="alert">
<TriangleAlert :size="15" class="text-yellow-500 mt-0.5 shrink-0" />
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Aucun client configuré</p>
<p class="text-xs text-muted">
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même,
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement.
</p>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, inject, onMounted } from 'vue'
import { Info, Plus, TriangleAlert } from 'lucide-vue-next'
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
import { setupContextKey, type SetupSettings } from './setup'
import type { SavedTorrentClient, TorrentClientDefinition } from '@/types/torrent-client'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const context = inject(setupContextKey)!
const loaded = ref(false)
const clients = ref<SavedTorrentClient[]>([])
const definitions = ref<TorrentClientDefinition[]>([])
const health = ref<Record<string, boolean | null>>({})
const formOpen = ref(false)
const formKey = ref(0)
const editing = ref<SavedTorrentClient | null>(null)
const warnSkip = ref(false)
function labelOf(type: string) {
return definitions.value.find(d => d.id === type)?.label ?? type
}
async function refreshHealth(uuid: string) {
health.value[uuid] = null
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
health.value[uuid] = res.ok ? (await res.json()).online : false
} catch {
health.value[uuid] = false
}
}
function add() {
editing.value = null
formKey.value++
formOpen.value = true
}
function edit(client: SavedTorrentClient) {
editing.value = client
formKey.value++
formOpen.value = true
}
function onSaved(client: SavedTorrentClient, isEdit: boolean) {
if (isEdit) {
const i = clients.value.findIndex(c => c.uuid === client.uuid)
if (i !== -1) clients.value[i] = client
} else {
clients.value.push(client)
}
formOpen.value = false
warnSkip.value = false
context.clientSkipped = false
refreshHealth(client.uuid)
}
async function submit(): Promise<boolean> {
if (clients.value.length > 0) {
context.clientSkipped = false
return true
}
if (!warnSkip.value) {
warnSkip.value = true
return false
}
context.clientSkipped = true
return true
}
onMounted(async () => {
const [clientsRes, defsRes] = await Promise.all([
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
])
if (clientsRes.ok) clients.value = await clientsRes.json()
if (defsRes.ok) definitions.value = await defsRes.json()
for (const c of clients.value) refreshHealth(c.uuid)
formOpen.value = clients.value.length === 0
loaded.value = true
})
defineExpose({ submit })
</script>
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col gap-6">
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé, puis vérifie toutes les 5 minutes. Sinon, l'import se lance à la main depuis Activité."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » au lieu de « Saison »"
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier disparaît"
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant."
/>
<SettingsToggle
v-if="settings.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client une fois le fichier déplacé."
/>
</div>
<div class="settings-card flex flex-col gap-3">
<SettingsToggle
v-model="form.nfoSupport"
label="Fichiers NFO et images"
description="Télécharge NFO et visuels à côté de chaque épisode lors de l'import."
/>
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-lg border border-border bg-shell/50 text-xs text-muted leading-relaxed">
<Info :size="13" class="shrink-0 mt-0.5" />
<p>
Inutile si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span> :
il gère déjà les métadonnées et les NFO peuvent créer des conflits.
À activer pour <span class="text-primary">Infuse</span> ou un lecteur qui lit les NFO locaux.
</p>
</div>
</div>
<p v-if="error" class="text-sm text-red-400" role="alert">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { Info } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { postSettings, type SetupSettings } from './setup'
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const form = reactive({
autoImport : props.settings.autoImport,
englishDirectory : props.settings.englishDirectory,
autoUnimportMissing: props.settings.autoUnimportMissing,
deleteTorrentOnMove: props.settings.deleteTorrentOnMove,
nfoSupport : props.settings.nfoSupport,
})
const error = ref<string | null>(null)
async function submit(): Promise<boolean> {
error.value = null
if (await postSettings(form)) return true
error.value = "Erreur lors de l'enregistrement."
return false
}
defineExpose({ submit })
</script>
@@ -0,0 +1,110 @@
<template>
<div class="flex flex-col gap-4">
<!-- Jellyfin -->
<section class="settings-card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
<Server :size="17" />
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium flex items-center gap-2">
Jellyfin
<span v-if="jellyfin?.test?.ok" class="text-[11px] text-green-400 font-normal">connecté</span>
</p>
<p class="text-xs text-muted mt-0.5 leading-relaxed">
Permet à vos utilisateurs Jellyfin de se connecter à FanKarr et de faire des demandes depuis le plugin FanKarr Search.
</p>
</div>
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary shrink-0 text-xs">
{{ jellyfinOpen ? 'Replier' : 'Configurer' }}
</button>
</div>
<template v-if="jellyfinOpen">
<JellyfinConnectionForm @change="jellyfin = $event" />
<div v-if="jellyfin?.test?.ok" class="flex flex-col gap-2">
<div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing" class="btn-secondary text-xs">
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="syncResult" class="text-xs text-green-400">
{{ syncResult.created }} créé{{ syncResult.created > 1 ? 's' : '' }} · {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
</span>
<span v-if="syncError" class="text-xs text-red-400">{{ syncError }}</span>
</div>
<p class="text-xs text-muted">Crée un compte FanKarr pour chaque utilisateur Jellyfin. La synchronisation se relance ensuite toutes les heures.</p>
</div>
</template>
</section>
<!-- Plex -->
<section class="settings-card flex flex-col gap-4">
<div class="flex items-start gap-3">
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
<Tv :size="17" />
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary font-medium flex items-center gap-2">
Plex
<span v-if="context.plexOpened" class="text-[11px] text-muted font-normal">assistant ouvert</span>
</p>
<p class="text-xs text-muted mt-0.5 leading-relaxed">
Crée une bibliothèque Plex sur votre médiathèque, reliée à l'agent de métadonnées Fankai.
Requiert Plex Media Server 1.43 ou plus récent pour la configuration automatique.
</p>
</div>
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary shrink-0 text-xs">
Lancer l'assistant Plex
</button>
</div>
<p v-if="!settings.mediaPath" class="text-xs text-yellow-500">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
</section>
<p class="text-xs text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
</div>
<PlexWizard v-if="plexOpen" :media-path="settings.mediaPath" @close="plexOpen = false" />
</template>
<script setup lang="ts">
import { ref, inject } from 'vue'
import { Server, Tv } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import PlexWizard from '@/components/settings/PlexWizard.vue'
import { setupContextKey, type SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const context = inject(setupContextKey)!
const jellyfinOpen = ref(false)
const jellyfin = ref<{ jellyfinUrl: string; hasToken: boolean; test: { ok: boolean } | null } | null>(null)
const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number } | null>(null)
const syncError = ref<string | null>(null)
const plexOpen = ref(false)
function openPlex() {
plexOpen.value = true
context.plexOpened = true
}
async function syncUsers() {
syncing.value = true
syncError.value = null
syncResult.value = null
try {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
} catch {
syncError.value = 'Impossible de contacter le serveur'
} finally {
syncing.value = false
}
}
</script>
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="flex flex-col gap-6">
<div class="settings-card flex flex-col gap-5">
<div v-for="field in fields" :key="field.key">
<div class="flex items-center gap-2 mb-1.5">
<label :for="`setup-${field.key}`" class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<span v-if="!field.required" class="text-[10px] text-muted border border-border px-1.5 py-0.5 rounded">recommandé</span>
</div>
<div class="flex gap-2">
<input
:id="`setup-${field.key}`"
v-model.trim="form[field.key]"
:placeholder="field.placeholder"
class="settings-input font-mono"
spellcheck="false"
/>
<button @click="openPicker(field.key)" class="btn-secondary shrink-0 flex items-center gap-1.5" :title="`Parcourir — ${field.label}`">
<FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
</button>
</div>
<p v-if="checking && form[field.key]" class="text-xs text-muted mt-1.5">Vérification…</p>
<p v-else-if="statusOf(field.key)?.error" class="text-xs text-red-400 mt-1.5 flex items-center gap-1.5">
<X :size="12" class="shrink-0" /> {{ statusOf(field.key)?.error }}
</p>
<p v-else-if="statusOf(field.key)?.writable" class="text-xs text-green-400 mt-1.5 flex items-center gap-1.5">
<Check :size="12" class="shrink-0" /> Dossier accessible en écriture
</p>
<p v-else class="text-xs text-muted mt-1.5">{{ field.help }}</p>
</div>
<p v-if="result?.relation === 'same'" class="text-xs text-red-400 flex items-start gap-1.5">
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque doit être distincte du dossier de téléchargements.
</p>
<p v-else-if="result?.relation === 'nested'" class="text-xs text-yellow-500 flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" />
Un dossier est à l'intérieur de l'autre : ça fonctionne, mais le scan de la médiathèque verra aussi les téléchargements.
</p>
</div>
<!-- Mode d'import -->
<div class="settings-card flex flex-col gap-3">
<label class="settings-label">Mode d'import</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
v-for="mode in modes"
:key="mode.id"
@click="form.organizeMode = mode.id"
:aria-pressed="form.organizeMode === mode.id"
class="p-3 rounded-lg border text-left transition-colors"
:class="form.organizeMode === mode.id ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
>
<p class="text-sm font-medium" :class="form.organizeMode === mode.id ? 'text-accent' : 'text-primary'">
{{ mode.label }}
<span v-if="mode.id === 'hardlink'" class="text-[10px] font-normal text-muted ml-1">recommandé</span>
</p>
<p class="text-xs text-muted mt-1 leading-relaxed">{{ mode.text }}</p>
</button>
</div>
<!-- Résultat du test hardlink -->
<div v-if="result && form.organizeMode === 'hardlink'" class="text-xs">
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-green-400 flex items-center gap-1.5">
<Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
</p>
<div v-else-if="result.hardlink.tested" class="flex flex-col gap-2 px-3 py-2.5 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<p class="text-yellow-500 flex items-start gap-1.5">
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
</p>
<p class="text-muted">Sans hardlink, FanKarr copiera les fichiers (espace disque doublé).</p>
<button @click="form.organizeMode = 'copy'" class="btn-secondary text-xs self-start">Passer en mode Copier</button>
</div>
<p v-else class="text-muted">{{ result.hardlink.message }}</p>
</div>
</div>
<p v-if="error" class="text-sm text-red-400" role="alert">{{ error }}</p>
</div>
<FolderPicker
v-if="picker.open"
:initial-path="picker.initialPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { Check, FolderOpen, TriangleAlert, X } from 'lucide-vue-next'
import FolderPicker from '@/components/FolderPicker.vue'
import { checkPaths, postSettings, type PathsCheckResult, type SetupSettings } from './setup'
type PathKey = 'completePath' | 'mediaPath'
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const fields: { key: PathKey; label: string; required: boolean; placeholder: string; help: string }[] = [
{
key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete',
help: 'Là où votre client torrent dépose les fichiers terminés. Sert aussi à tester les hardlinks.',
},
{
key: 'mediaPath', label: 'Médiathèque Fankai', required: true,
placeholder: props.isDocker ? '/data/medias/kai' : 'D:/Medias/Fankai',
help: 'Dossier racine où FanKarr range les séries, à ajouter ensuite dans Jellyfin ou Plex.',
},
]
const modes = [
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Aucune copie : le fichier reste en partage dans le client torrent.' },
{ id: 'copy' as const, label: 'Copier', text: 'Duplique le fichier. Fonctionne partout, prend deux fois la place.' },
{ id: 'move' as const, label: 'Déplacer', text: 'Retire le fichier du client : le partage s\'arrête.' },
]
const form = reactive({
completePath: props.settings.completePath,
mediaPath : props.settings.mediaPath,
organizeMode: props.settings.organizeMode,
})
const result = ref<PathsCheckResult | null>(null)
const checking = ref(false)
const error = ref<string | null>(null)
const picker = ref<{ open: boolean; field: PathKey; initialPath: string }>({ open: false, field: 'mediaPath', initialPath: '/' })
let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
function statusOf(key: PathKey) {
if (!form[key] || !result.value) return null
return key === 'mediaPath' ? result.value.mediaPath : result.value.completePath
}
async function runCheck() {
const id = ++requestId
if (!form.mediaPath && !form.completePath) { result.value = null; return result.value }
checking.value = true
const res = await checkPaths(form.mediaPath, form.completePath)
if (id === requestId) {
result.value = res
checking.value = false
}
return res
}
watch(() => [form.mediaPath, form.completePath], () => {
error.value = null
clearTimeout(timer)
timer = setTimeout(runCheck, 400)
})
function openPicker(field: PathKey) {
picker.value = { open: true, field, initialPath: form[field] || props.defaultPath }
}
function onPickerSelect(path: string) {
form[picker.value.field] = path
picker.value.open = false
}
async function submit(): Promise<boolean> {
error.value = null
if (!form.mediaPath) { error.value = 'Choisissez le dossier de la médiathèque pour continuer.'; return false }
clearTimeout(timer)
const res = await runCheck()
if (!res) { error.value = 'Impossible de vérifier les dossiers.'; return false }
if (!res.ok) { error.value = 'Corrigez les dossiers signalés avant de continuer.'; return false }
if (!(await postSettings({ ...form, mediaPath: res.mediaPath.resolved, completePath: res.completePath?.resolved ?? '' }))) {
error.value = "Erreur lors de l'enregistrement."
return false
}
return true
}
onMounted(() => { if (form.mediaPath || form.completePath) runCheck() })
defineExpose({ submit })
</script>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="flex flex-col gap-6">
<ul class="settings-card p-0 divide-y divide-border">
<li v-for="row in rows" :key="row.label" class="flex items-center gap-3 px-4 py-3">
<span class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="stateClass[row.state]">
<span v-if="row.state === 'loading'" class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
<Check v-else-if="row.state === 'ok'" :size="13" />
<TriangleAlert v-else-if="row.state === 'warn'" :size="12" />
<X v-else-if="row.state === 'error'" :size="13" />
<Minus v-else :size="13" />
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-primary">{{ row.label }}</p>
<p class="text-xs text-muted truncate" :title="row.value">{{ row.value }}</p>
</div>
<button @click="context.goto(row.step)" class="btn-ghost text-xs shrink-0">Modifier</button>
</li>
</ul>
<div class="flex flex-col sm:flex-row gap-2">
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary flex items-center justify-center gap-1.5">
<Compass :size="15" />
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
</button>
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
</button>
</div>
<p v-if="!context.relaunch" class="text-xs text-muted -mt-3">
La visite guidée présente chaque écran en une minute. Vous pourrez la relancer depuis le bouton « ? » de la barre latérale.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, inject, onMounted } from 'vue'
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
interface Row { label: string; value: string; state: RowState; step: SetupStepId }
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const context = inject(setupContextKey)!
const finishing = ref(false)
const stateClass: Record<RowState, string> = {
loading: 'bg-hover',
ok : 'bg-green-500/15 text-green-400',
warn : 'bg-yellow-500/15 text-yellow-500',
error : 'bg-red-500/15 text-red-400',
neutral: 'bg-hover text-muted',
}
const modeLabel = { hardlink: 'Hardlink', copy: 'Copie', move: 'Déplacement' }
const row = (label: string, step: SetupStepId, value = '…', state: RowState = 'loading'): Row => ({ label, step, value, state })
const rows = reactive({
paths : row('Dossiers', 'paths'),
mode : row("Mode d'import", 'paths'),
client : row('Client torrent', 'client'),
import : row('Import automatique', 'import',
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis Activité',
props.settings.autoImport ? 'ok' : 'neutral'),
nfo : row('NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
jellyfin: row('Jellyfin', 'media-server'),
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant Plex lancé' : 'Non configuré', 'neutral'),
catalog : row('Catalogue Fankai', 'catalog'),
})
function set(target: Row, value: string, state: RowState) {
target.value = value
target.state = state
}
async function getJson(url: string, init?: RequestInit) {
const res = await fetch(url, { credentials: 'include', ...init })
if (!res.ok) throw new Error(String(res.status))
return res.json()
}
async function loadPaths() {
const s = props.settings
const res = s.mediaPath ? await checkPaths(s.mediaPath, s.completePath) : null
if (!res) {
set(rows.paths, 'Médiathèque non configurée', 'error')
} else {
set(rows.paths,
`${s.mediaPath}${s.completePath ? ` ← ${s.completePath}` : ' · téléchargements non renseignés'}`,
!res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
}
const h = res?.hardlink
if (s.organizeMode !== 'hardlink') set(rows.mode, modeLabel[s.organizeMode], 'ok')
else if (h?.tested && h.ok) set(rows.mode, 'Hardlink · testé avec succès', 'ok')
else if (h?.tested) set(rows.mode, `Hardlink · ${h.message}`, 'warn')
else set(rows.mode, 'Hardlink · non testé', 'neutral')
}
async function loadClients() {
try {
const clients: { uuid: string; name: string }[] = await getJson('/api/torrent-clients')
if (clients.length === 0) {
set(rows.client, 'Aucun client : les téléchargements sont impossibles', 'warn')
return
}
const online = await Promise.all(clients.map(c =>
getJson(`/api/torrent-clients/${c.uuid}/healthcheck`).then(d => !!d.online).catch(() => false)
))
const up = online.filter(Boolean).length
set(rows.client,
`${clients.map(c => c.name).join(', ')} · ${up}/${clients.length} joignable${clients.length > 1 ? 's' : ''}`,
up === clients.length ? 'ok' : 'warn')
} catch {
set(rows.client, 'Impossible de vérifier', 'warn')
}
}
async function loadJellyfin() {
try {
const { jellyfinUrl, hasToken } = await getJson('/api/jellyfin/settings')
if (!jellyfinUrl || !hasToken) {
set(rows.jellyfin, 'Non configuré', 'neutral')
return
}
const test = await getJson('/api/jellyfin/test', { method: 'POST' })
if (test.ok) set(rows.jellyfin, `${jellyfinUrl} · Jellyfin ${test.version ?? ''}`.trim(), 'ok')
else set(rows.jellyfin, `${jellyfinUrl} · ${test.error ?? 'connexion impossible'}`, 'error')
} catch {
set(rows.jellyfin, 'Impossible de vérifier', 'warn')
}
}
async function loadCatalog() {
try {
const { count, empty } = await getJson('/api/torrents/status')
if (empty) set(rows.catalog, 'Catalogue vide', 'warn')
else set(rows.catalog, `${count} séries disponibles`, 'ok')
} catch {
set(rows.catalog, 'Impossible de vérifier', 'warn')
}
}
async function done(withTour: boolean) {
finishing.value = true
try { await context.finish(withTour) }
finally { finishing.value = false }
}
onMounted(() => {
loadPaths()
loadClients()
loadJellyfin()
loadCatalog()
})
</script>
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="flex flex-col gap-6">
<!-- Fonctionnement -->
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<li v-for="(item, i) in flow" :key="item.title" class="settings-card flex gap-3">
<span class="w-8 h-8 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
<component :is="item.icon" :size="16" />
</span>
<div class="min-w-0">
<p class="text-sm text-primary font-medium">{{ i + 1 }}. {{ item.title }}</p>
<p class="text-xs text-muted mt-0.5 leading-relaxed">{{ item.text }}</p>
</div>
</li>
</ol>
<!-- Ce qu'on va configurer -->
<div class="settings-card flex flex-col gap-3">
<p class="settings-label">Au programme</p>
<ul class="flex flex-col gap-2">
<li v-for="item in checklist" :key="item.label" class="flex items-start gap-2.5 text-sm">
<Check :size="14" class="text-accent mt-0.5 shrink-0" />
<span class="text-secondary">
{{ item.label }}
<span v-if="item.optional" class="text-muted text-xs">(optionnel)</span>
</span>
</li>
</ul>
<p class="text-xs text-muted">Comptez environ 5 minutes.</p>
</div>
<!-- Docker -->
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-accent/30 bg-accent-muted">
<HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
<div class="flex flex-col gap-1">
<p class="text-sm text-primary font-medium">FanKarr tourne dans Docker</p>
<p class="text-xs text-muted leading-relaxed">
Les chemins à renseigner sont ceux <span class="text-primary">vus depuis le conteneur</span>.
Pour profiter des hardlinks, montez un seul volume parent qui contient à la fois les téléchargements
et la médiathèque (ex. <code class="font-mono text-secondary">/data/downloads</code> et
<code class="font-mono text-secondary">/data/medias</code>).
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Check, Download, FolderOpen, HardDrive, Library, Tv } from 'lucide-vue-next'
import type { SetupSettings } from './setup'
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
const flow = [
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' },
{ icon: Download, title: 'Téléchargement', text: 'Un clic envoie le torrent à votre client (qBittorrent, Transmission…).' },
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' },
{ icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
]
const checklist = [
{ label: 'Dossier de téléchargements et médiathèque' },
{ label: 'Client torrent' },
{ label: 'Import automatique et nommage' },
{ label: 'Jellyfin ou Plex', optional: true },
{ label: 'Téléchargement du catalogue' },
]
</script>
+65
View File
@@ -0,0 +1,65 @@
import type { InjectionKey } from 'vue'
export type SetupStepId = 'welcome' | 'paths' | 'client' | 'import' | 'media-server' | 'catalog' | 'recap'
export interface SetupSettings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'copy' | 'move'
nfoSupport : boolean
autoImport : boolean
deleteTorrentOnMove: boolean
autoUnimportMissing: boolean
englishDirectory : boolean
}
export interface PathCheck {
input : string
resolved : string
exists : boolean
isDirectory: boolean
writable : boolean
error? : string
}
export interface PathsCheckResult {
ok : boolean
mediaPath : PathCheck
completePath: PathCheck | null
relation : 'distinct' | 'same' | 'nested' | null
hardlink : { tested: boolean; ok: boolean; code?: string; message: string }
}
export interface SetupContext {
relaunch : boolean
clientSkipped: boolean
plexOpened : boolean
goto : (id: SetupStepId) => void
finish : (withTour: boolean) => Promise<void>
}
export const setupContextKey: InjectionKey<SetupContext> = Symbol('setup-context')
export async function postSettings(patch: Partial<SetupSettings>): Promise<boolean> {
try {
const res = await fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(patch),
})
return res.ok
} catch {
return false
}
}
export async function checkPaths(mediaPath: string, completePath: string): Promise<PathsCheckResult | null> {
try {
const res = await fetch('/api/system/check-paths', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ mediaPath, completePath }),
})
return res.ok ? await res.json() : null
} catch {
return null
}
}