feat(onboarding): assistant de configuration au premier lancement et visite guidée des écrans
This commit is contained in:
1 parent
6348cc40cb
commit
4b3c629ff9
43 files changed
+2498
-366
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user