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

+21 -2
View File
@@ -8,9 +8,11 @@ import { DATA_DIR } from './config.js'
import { logger } from './logger.js' import { logger } from './logger.js'
import { import {
findByUsername, findById, findByApiToken, findByUsername, findById, findByApiToken,
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen,
type User, type User,
} from './users.js' } from './users.js'
import { readSettings } from './settings.js'
import { startOnboarding } from './onboarding.js'
// ── Typage Express étendu ───────────────────────────────────── // ── Typage Express étendu ─────────────────────────────────────
declare global { declare global {
@@ -50,7 +52,11 @@ export function authStatus(req: Request, res: Response): void {
const payload = jwt.verify(token, JWT_SECRET) as any const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id) const user = findById(payload.id)
if (!user) { res.json({ setup, loggedIn: false }); return } if (!user) { res.json({ setup, loggedIn: false }); return }
res.json({ setup, loggedIn: true, role: user.role, username: user.username, userId: user.id }) res.json({
setup, loggedIn: true, role: user.role, username: user.username, userId: user.id,
onboardingDone: !!readSettings().onboardingCompletedAt,
tourSeen : !!user.tourSeenAt,
})
} catch { } catch {
res.json({ setup, loggedIn: false }) res.json({ setup, loggedIn: false })
} }
@@ -67,9 +73,13 @@ export function authSetup(req: Request, res: Response): void {
if (!username || !password) { if (!username || !password) {
res.status(400).json({ error: 'Username et password requis' }); return res.status(400).json({ error: 'Username et password requis' }); return
} }
if (password.length < 6) {
res.status(400).json({ error: 'Le mot de passe doit faire au moins 6 caractères' }); return
}
try { try {
const user = createUser(username, password, 'admin') const user = createUser(username, password, 'admin')
startOnboarding()
logger.info('auth', `Premier compte admin créé pour "${username}"`) logger.info('auth', `Premier compte admin créé pour "${username}"`)
setCookieAndRespond(res, user) setCookieAndRespond(res, user)
} catch (err) { } catch (err) {
@@ -143,6 +153,15 @@ export function authRegenerateToken(req: Request, res: Response): void {
} }
} }
// POST /api/auth/tour-seen
export function authTourSeen(req: Request, res: Response): void {
try {
res.json({ tourSeenAt: markTourSeen(req.user!.id) })
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
}
}
// POST /api/auth/register (via lien d'invitation) // POST /api/auth/register (via lien d'invitation)
export function authRegister(req: Request, res: Response): void { export function authRegister(req: Request, res: Response): void {
const { username, password, inviteCode } = req.body const { username, password, inviteCode } = req.body
+4 -1
View File
@@ -3,8 +3,9 @@ import cookieParser from 'cookie-parser'
import http from 'http' import http from 'http'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, requireAuth, requireAdmin } from './auth.js' import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js'
import { migrateIfNeeded } from './users.js' import { migrateIfNeeded } from './users.js'
import { migrateOnboarding } from './onboarding.js'
import { readSettings } from './settings.js' import { readSettings } from './settings.js'
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js' import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js'
@@ -71,6 +72,7 @@ if (fs.existsSync(PUBLIC_PATH)) {
// ── Migration mono-user → multi-user ────────────────────────── // ── Migration mono-user → multi-user ──────────────────────────
migrateIfNeeded() migrateIfNeeded()
migrateOnboarding()
// ── Auth ─────────────────────────────────────────────────────── // ── Auth ───────────────────────────────────────────────────────
app.get ('/api/auth/status', authStatus) app.get ('/api/auth/status', authStatus)
@@ -80,6 +82,7 @@ app.post('/api/auth/logout', authLogout)
app.get ('/api/auth/me', requireAuth, authMe) app.get ('/api/auth/me', requireAuth, authMe)
app.post('/api/auth/change-password', requireAuth, authChangePassword) app.post('/api/auth/change-password', requireAuth, authChangePassword)
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken) app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
// ── API publique v1 (auth par token) ────────────────────────── // ── API publique v1 (auth par token) ──────────────────────────
app.use('/api', publicApiRouter) app.use('/api', publicApiRouter)
+55
View File
@@ -0,0 +1,55 @@
import { readSettings, writeSettings } from './settings.js'
import { markAllToursSeen } from './users.js'
import { listClients } from './torrent-clients/index.js'
import { logger } from './logger.js'
export const ONBOARDING_STEPS = ['welcome', 'paths', 'client', 'import', 'media-server', 'catalog', 'recap'] as const
export type OnboardingStep = typeof ONBOARDING_STEPS[number]
export interface OnboardingState {
step : OnboardingStep | null
completedAt: string | null
}
export function isOnboardingStep(value: unknown): value is OnboardingStep {
return typeof value === 'string' && (ONBOARDING_STEPS as readonly string[]).includes(value)
}
export function readOnboarding(): OnboardingState {
const { onboardingStep, onboardingCompletedAt } = readSettings()
return { step: isOnboardingStep(onboardingStep) ? onboardingStep : null, completedAt: onboardingCompletedAt }
}
// Installation antérieure à l'assistant : déjà configurée → rien n'est imposé
export function migrateOnboarding(): boolean {
const settings = readSettings()
if (settings.onboardingCompletedAt || settings.onboardingStep) return false
if (!settings.mediaPath && listClients().length === 0) return false
const now = new Date().toISOString()
writeSettings({ onboardingCompletedAt: now })
markAllToursSeen(now)
logger.info('onboarding', 'Installation existante détectée — assistant marqué comme terminé')
return true
}
export function startOnboarding(): void {
if (readSettings().onboardingCompletedAt) return
writeSettings({ onboardingStep: 'welcome' })
}
export function advanceOnboarding(step?: OnboardingStep, complete = false): OnboardingState {
const current = readOnboarding()
const patch: { onboardingStep?: string; onboardingCompletedAt?: string } = {}
if (step && (!current.step || ONBOARDING_STEPS.indexOf(step) > ONBOARDING_STEPS.indexOf(current.step))) {
patch.onboardingStep = step
}
if (complete) {
patch.onboardingStep = 'recap'
patch.onboardingCompletedAt = new Date().toISOString()
logger.info('onboarding', 'Assistant de configuration terminé')
}
if (Object.keys(patch).length > 0) writeSettings(patch)
return readOnboarding()
}
+6 -2
View File
@@ -268,7 +268,7 @@ export async function scanMediaPath(
} }
// Désimport automatique des fichiers manquants sur le disque // Désimport automatique des fichiers manquants sur le disque
const { autoUnimportMissing } = readSettings() as any const { autoUnimportMissing } = readSettings()
let autoRemoved = 0 let autoRemoved = 0
if (autoUnimportMissing && !mediaEmpty) { if (autoUnimportMissing && !mediaEmpty) {
for (const [hash, episodes] of Object.entries(organized)) { for (const [hash, episodes] of Object.entries(organized)) {
@@ -300,11 +300,15 @@ export async function autoOrganizeAll(
seriesData : any[], seriesData : any[],
onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> { ): Promise<void> {
const { autoImport } = readSettings() const { autoImport, mediaPath } = readSettings()
if (!autoImport) { if (!autoImport) {
logger.debug('organize', 'Import automatique désactivé — skip') logger.debug('organize', 'Import automatique désactivé — skip')
return return
} }
if (!mediaPath?.trim()) {
logger.debug('organize', 'Médiathèque non configurée — skip')
return
}
if (_queued > 0) { if (_queued > 0) {
logger.debug('organize', 'Import déjà en cours — skip') logger.debug('organize', 'Import déjà en cours — skip')
+13
View File
@@ -2,9 +2,22 @@ import { Router } from 'express'
import { requireAuth } from '../auth.js' import { requireAuth } from '../auth.js'
import { readSettings, writeSettings } from '../settings.js' import { readSettings, writeSettings } from '../settings.js'
import { logger } from '../logger.js' import { logger } from '../logger.js'
import { readOnboarding, advanceOnboarding, isOnboardingStep } from '../onboarding.js'
const router = Router() const router = Router()
router.get('/settings/onboarding', requireAuth, (_req, res) => {
res.json(readOnboarding())
})
router.post('/settings/onboarding', requireAuth, (req, res) => {
const { step, complete } = req.body ?? {}
if (step !== undefined && !isOnboardingStep(step)) {
res.status(400).json({ error: 'Étape inconnue' }); return
}
res.json(advanceOnboarding(step, complete === true))
})
router.get('/settings', requireAuth, (_req, res) => { router.get('/settings', requireAuth, (_req, res) => {
res.json(readSettings()) res.json(readSettings())
}) })
+9 -1
View File
@@ -5,7 +5,7 @@ import { requireAuth } from '../auth.js'
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js' import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js' import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js' import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js' import { systemInfo, checkPaths } from '../system.js'
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js' import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
import { readAvailable, cacheClear } from '../lib/github-cache.js' import { readAvailable, cacheClear } from '../lib/github-cache.js'
import { loadCatalog } from '../lib/serie-helpers.js' import { loadCatalog } from '../lib/serie-helpers.js'
@@ -36,6 +36,14 @@ router.get('/system', requireAuth, (_req, res) => {
res.json({ isDocker: fs.existsSync('/.dockerenv') }) res.json({ isDocker: fs.existsSync('/.dockerenv') })
}) })
router.post('/system/check-paths', requireAuth, (req, res) => {
const { mediaPath, completePath } = req.body ?? {}
if (typeof mediaPath !== 'string' || (completePath !== undefined && typeof completePath !== 'string')) {
res.status(400).json({ error: 'mediaPath requis' }); return
}
res.json(checkPaths(mediaPath, completePath))
})
router.get('/version', (_req, res) => { router.get('/version', (_req, res) => {
try { try {
const versionPath = path.join(BASE_DIR, 'version.txt') const versionPath = path.join(BASE_DIR, 'version.txt')
+6
View File
@@ -17,6 +17,9 @@ export interface Settings {
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096) jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
jellyfinAdminToken : string // Token admin Jellyfin pour la sync jellyfinAdminToken : string // Token admin Jellyfin pour la sync
englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1") englishDirectory : boolean // Si true, les dossiers de séries sont en anglais (ex: "Season 1" au lieu de "Saison 1")
autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null
} }
const defaults: Settings = { const defaults: Settings = {
@@ -32,6 +35,9 @@ const defaults: Settings = {
jellyfinUrl : '', jellyfinUrl : '',
jellyfinAdminToken : '', jellyfinAdminToken : '',
englishDirectory : false, englishDirectory : false,
autoUnimportMissing: false,
onboardingStep : null,
onboardingCompletedAt: null,
} }
export function readSettings(): Settings { export function readSettings(): Settings {
+139 -1
View File
@@ -1,5 +1,7 @@
import fs from 'fs' import fs from 'fs'
import os from 'os' import os from 'os'
import path from 'path'
import { randomBytes } from 'crypto'
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
// GET /api/system/info // GET /api/system/info
@@ -12,4 +14,140 @@ export function systemInfo(_req: Request, res: Response): void {
isTermux, isTermux,
defaultPath: isTermux ? '/sdcard/' : '/', defaultPath: isTermux ? '/sdcard/' : '/',
}) })
} }
// ── Vérification des dossiers (assistant de configuration) ────
export interface PathCheck {
input : string
resolved : string
exists : boolean
isDirectory: boolean
writable : boolean
error? : string
}
export interface HardlinkCheck {
tested : boolean
ok : boolean
code? : string
message: string
}
export type PathRelation = 'distinct' | 'same' | 'nested'
function tempName(prefix: string): string {
return `.fankarr-${prefix}-${process.pid}-${randomBytes(4).toString('hex')}`
}
function removeQuietly(file: string): void {
try { fs.rmSync(file, { force: true, maxRetries: 3, retryDelay: 100 }) } catch {}
}
export function checkDirectory(input: string, { allowRoot = true } = {}): PathCheck {
const raw = (input ?? '').trim()
const result: PathCheck = { input: raw, resolved: '', exists: false, isDirectory: false, writable: false }
if (!raw) return { ...result, error: 'Chemin vide' }
if (/^[A-Za-z]:$/.test(raw)) return { ...result, error: 'Chemin incomplet : ajoutez une barre après la lettre du lecteur' }
if (!path.isAbsolute(raw)) return { ...result, error: 'Le chemin doit être absolu' }
result.resolved = path.resolve(raw)
if (!allowRoot && path.parse(result.resolved).root === result.resolved) {
return { ...result, error: 'La racine du disque ne peut pas servir de médiathèque' }
}
let stat: fs.Stats
try { stat = fs.statSync(result.resolved) }
catch { return { ...result, error: 'Dossier introuvable' } }
result.exists = true
if (!stat.isDirectory()) return { ...result, error: "Ce chemin n'est pas un dossier" }
result.isDirectory = true
// Test d'écriture réel
const probe = path.join(result.resolved, tempName('write'))
try {
fs.writeFileSync(probe, '')
result.writable = true
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
result.error = `Écriture impossible${code ? ` (${code})` : ''}`
} finally {
removeQuietly(probe)
}
return result
}
export function pathRelation(a: string, b: string): PathRelation {
const norm = (p: string) => {
const r = path.resolve(p).replace(/[\\/]+$/, '')
return process.platform === 'win32' ? r.toLowerCase() : r
}
const na = norm(a), nb = norm(b)
if (na === nb) return 'same'
if (na.startsWith(nb + path.sep) || nb.startsWith(na + path.sep)) return 'nested'
return 'distinct'
}
function hardlinkMessage(code: string | undefined, err: unknown): string {
switch (code) {
case 'EXDEV':
return 'Volumes différents : hardlink impossible. Passez en mode Copier ou, sous Docker, montez un seul volume parent contenant téléchargements et médiathèque.'
case 'EPERM':
case 'ENOTSUP':
case 'ENOSYS':
return 'Ce système de fichiers ne gère pas les hardlinks (FAT/exFAT, partage réseau…).'
case 'EACCES':
return 'Permissions insuffisantes pour créer un hardlink.'
default:
return `Hardlink impossible : ${err instanceof Error ? err.message : String(err)}`
}
}
export function testHardlink(srcDir: string, destDir: string): HardlinkCheck {
const name = tempName('linktest')
const src = path.join(srcDir, name)
const dest = path.join(destDir, `${name}.link`)
try {
fs.writeFileSync(src, '')
fs.linkSync(src, dest)
return { tested: true, ok: true, message: 'Hardlink possible entre les deux dossiers' }
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
return { tested: true, ok: false, code, message: hardlinkMessage(code, err) }
} finally {
removeQuietly(dest)
removeQuietly(src)
}
}
export function checkPaths(mediaPath: string, completePath?: string) {
const media = checkDirectory(mediaPath, { allowRoot: false })
const hasComplete = !!completePath?.trim()
const complete = hasComplete ? checkDirectory(completePath!) : null
const valid = (c: PathCheck | null) => !!c && c.writable && !c.error
const relation = hasComplete && media.resolved && complete?.resolved
? pathRelation(media.resolved, complete.resolved)
: null
let hardlink: HardlinkCheck
if (!hasComplete) {
hardlink = { tested: false, ok: false, message: 'Renseignez le dossier de téléchargements pour tester le hardlink' }
} else if (!valid(media) || !valid(complete)) {
hardlink = { tested: false, ok: false, message: 'Test impossible tant que les dossiers ne sont pas valides' }
} else if (relation === 'same') {
hardlink = { tested: false, ok: false, message: 'Les deux dossiers sont identiques' }
} else {
hardlink = testHardlink(complete!.resolved, media.resolved)
}
return {
ok: valid(media) && (!hasComplete || valid(complete)) && relation !== 'same',
mediaPath : media,
completePath: complete,
relation,
hardlink,
}
}
+20
View File
@@ -19,6 +19,7 @@ export interface User {
role : UserRole role : UserRole
apiToken : string apiToken : string
createdAt : string createdAt : string
tourSeenAt? : string | null
} }
const USERS_PATH = path.join(DATA_DIR, 'users.json') const USERS_PATH = path.join(DATA_DIR, 'users.json')
@@ -92,6 +93,7 @@ export function createUser(username: string, password: string, role: UserRole =
role, role,
apiToken : crypto.randomBytes(32).toString('hex'), apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(), createdAt : new Date().toISOString(),
tourSeenAt : null,
} }
writeUsers([...users, user]) writeUsers([...users, user])
logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`) logger.info('users', `Utilisateur "${username}" créé (rôle: ${role})`)
@@ -142,6 +144,24 @@ export function changePassword(id: string, currentPassword: string, newPassword:
logger.info('users', `Mot de passe changé pour "${user.username}"`) logger.info('users', `Mot de passe changé pour "${user.username}"`)
} }
export function markTourSeen(id: string): string {
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
user.tourSeenAt = user.tourSeenAt ?? new Date().toISOString()
writeUsers(users)
return user.tourSeenAt
}
export function markAllToursSeen(at: string): number {
const users = readUsers()
const pending = users.filter(u => !u.tourSeenAt)
if (pending.length === 0) return 0
for (const u of pending) u.tourSeenAt = at
writeUsers(users)
return pending.length
}
export function regenerateApiToken(id: string): string { export function regenerateApiToken(id: string): string {
const users = readUsers() const users = readUsers()
const user = users.find(u => u.id === id) const user = users.find(u => u.id === id)
+6
View File
@@ -0,0 +1,6 @@
<template>
<svg viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"> <div class="fixed bottom-4 right-4 z-[70] flex flex-col gap-2">
<TransitionGroup name="toast"> <TransitionGroup name="toast">
<div <div
v-for="t in toasts" v-for="t in toasts"
+47 -7
View File
@@ -3,10 +3,7 @@
<!-- Logo + logout --> <!-- Logo + logout -->
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]"> <div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
<svg class="w-7 h-7 shrink-0" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg"> <FankarrLogo class="w-7 h-7 shrink-0" />
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors"> <RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
FanKarr FanKarr
</RouterLink> </RouterLink>
@@ -48,9 +45,34 @@
</svg> </svg>
</a> </a>
<!-- Version actuelle --> <!-- Version actuelle + aide -->
<div class="px-3 pb-1 pt-0.5"> <div class="px-3 pb-1 pt-0.5 flex items-center justify-between gap-2">
<p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p> <p class="text-[10px] text-muted/50 font-mono">{{ currentVersion }}</p>
<div ref="helpRef" class="relative">
<button
data-tour="help"
@click="helpOpen = !helpOpen"
aria-haspopup="menu"
:aria-expanded="helpOpen"
title="Aide"
class="text-muted hover:text-primary transition-colors p-1 rounded-md hover:bg-hover"
>
<CircleQuestionMark :size="15" />
</button>
<div
v-if="helpOpen"
role="menu"
class="absolute bottom-full right-0 mb-1 w-52 bg-card border border-border rounded-lg shadow-xl p-1 z-20 flex flex-col"
@keydown.esc="helpOpen = false"
>
<button role="menuitem" @click="startTour" class="flex items-center gap-2 px-2.5 py-2 rounded-md text-xs text-secondary hover:bg-hover hover:text-primary text-left">
<Compass :size="14" class="shrink-0" /> Visite guidée
</button>
<button v-if="auth.isAdmin" role="menuitem" @click="openSetup" class="flex items-center gap-2 px-2.5 py-2 rounded-md text-xs text-secondary hover:bg-hover hover:text-primary text-left">
<Wand :size="14" class="shrink-0" /> Assistant de configuration
</button>
</div>
</div>
</div> </div>
<!-- Thème --> <!-- Thème -->
@@ -62,16 +84,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { LogOut } from 'lucide-vue-next' import { onClickOutside } from '@vueuse/core'
import { CircleQuestionMark, Compass, LogOut, Wand } from 'lucide-vue-next'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
import SidebarNavItem from './SidebarNavItem.vue' import SidebarNavItem from './SidebarNavItem.vue'
import ThemeSwitcher from './ThemeSwitcher.vue' import ThemeSwitcher from './ThemeSwitcher.vue'
import FankarrLogo from '@/components/FankarrLogo.vue'
import type { NavItem } from '@/types/nav' import type { NavItem } from '@/types/nav'
defineProps<{ items: NavItem[] }>() defineProps<{ items: NavItem[] }>()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const tour = useTourStore()
const helpOpen = ref(false)
const helpRef = ref<HTMLElement | null>(null)
onClickOutside(helpRef, () => { helpOpen.value = false })
function startTour() {
helpOpen.value = false
tour.start()
}
function openSetup() {
helpOpen.value = false
router.push('/setup')
}
const currentVersion = ref( const currentVersion = ref(
import.meta.env.DEV ? 'dev' : '' import.meta.env.DEV ? 'dev' : ''
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div> <div :data-tour="item.tour">
<component <component
:is="item.to ? RouterLink : 'button'" :is="item.to ? RouterLink : 'button'"
:to="item.to ?? undefined" :to="item.to ?? undefined"
+3 -2
View File
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- Toolbar --> <!-- Toolbar -->
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2"> <div data-tour="series-toolbar" class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
<!-- Ligne 1 : recherche + filtres --> <!-- Ligne 1 : recherche + filtres -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -79,6 +79,7 @@
<!-- Sélection multiple --> <!-- Sélection multiple -->
<button <button
v-if="selectable" v-if="selectable"
data-tour="series-select"
@click="emit('update:selecting', !selecting)" @click="emit('update:selecting', !selecting)"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
:class="selecting ? 'border-accent text-accent' : ''" :class="selecting ? 'border-accent text-accent' : ''"
@@ -122,7 +123,7 @@
</div> </div>
<!-- Légende barres --> <!-- Légende barres -->
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border"> <div data-tour="series-legend" class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5"> <div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" /> <span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
<span class="text-[11px] text-muted">{{ l.label }}</span> <span class="text-[11px] text-muted">{{ l.label }}</span>
@@ -0,0 +1,97 @@
<template>
<div class="settings-card flex flex-col gap-4">
<div>
<label class="settings-label mb-1.5">URL du serveur</label>
<input v-model="jellyfinUrl" type="url" class="settings-input" placeholder="http://jellyfin:8096" />
<p class="text-xs text-muted mt-1">URL accessible depuis FanKarr (réseau local ou Docker)</p>
</div>
<div>
<label class="settings-label mb-1.5">Token admin Jellyfin</label>
<input v-model="jellyfinAdminToken" type="password" class="settings-input"
:placeholder="hasToken ? '••••••••••••••••••••• (configuré)' : 'Tableau de bord → Clés API'" />
<p class="text-xs text-muted mt-1">
Dans Jellyfin : Tableau de bord → Clés API → Créer une clé
</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
<button @click="testConnection" :disabled="testing" class="btn-secondary">
{{ testing ? 'Test…' : 'Tester la connexion' }}
</button>
<span v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-green-400' : 'text-red-400'">
{{ testResult.ok ? `Connecté — Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-xs text-green-400">Enregistré.</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface JellyfinTestResult { ok: boolean; version?: string; error?: string }
const emit = defineEmits<{
change: [state: { jellyfinUrl: string; hasToken: boolean; test: JellyfinTestResult | null }]
}>()
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const testing = ref(false)
const testResult = ref<JellyfinTestResult | null>(null)
function notify() {
emit('change', { jellyfinUrl: jellyfinUrl.value, hasToken: hasToken.value, test: testResult.value })
}
onMounted(async () => {
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
jellyfinUrl.value = d.jellyfinUrl ?? ''
hasToken.value = d.hasToken ?? false
notify()
}
})
async function save() {
saving.value = true
saved.value = false
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (res.ok) {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = ''
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
notify()
// Le test du serveur n'utilise que les valeurs enregistrées
if (jellyfinUrl.value && hasToken.value) await testConnection()
}
saving.value = false
}
async function testConnection() {
testing.value = true
testResult.value = null
try {
const res = await fetch('/api/jellyfin/test', { method: 'POST', credentials: 'include' })
testResult.value = await res.json()
} catch {
testResult.value = { ok: false, error: 'Impossible de contacter le serveur' }
}
testing.value = false
notify()
}
</script>
@@ -0,0 +1,216 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<div>
<label class="settings-label">Nom</label>
<input v-model="name" placeholder="Mon client torrent" class="settings-input" />
</div>
<div>
<label class="settings-label">Type</label>
<select v-model="type" @change="onTypeChange" class="settings-input">
<option value="">Choisir un type...</option>
<option v-for="def in definitions" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="config[field.key] = $event"
/>
<template v-else>
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@click="advancedOpen = !advancedOpen"
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
>
<ChevronDown
:size="13"
class="transition-transform duration-200 shrink-0"
:class="advancedOpen ? 'rotate-180' : ''"
/>
Paramètres avancés
</button>
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
<div v-for="field in advancedFields" :key="field.key">
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="config[field.key] = $event"
/>
<template v-else>
<label class="settings-label flex items-center gap-1.5">
{{ field.label }}
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
tabindex="-1"
>
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div>
</div>
</div>
</template>
</div>
<p v-if="!tested && type" class="text-xs text-muted">
Testez la connexion avant d'enregistrer.
</p>
<div class="flex gap-2 pt-1">
<button @click="save" :disabled="saving || !tested" class="btn-primary">
{{ saving ? '...' : 'Enregistrer' }}
</button>
<button @click="test" :disabled="testing || !type" class="btn-secondary">
{{ testing ? '...' : tested ? 'Testé ✓' : 'Tester' }}
</button>
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast'
import type { TorrentClientConfig, TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
const props = defineProps<{
definitions : TorrentClientDefinition[]
client? : SavedTorrentClient | null
showCancel? : boolean
}>()
const emit = defineEmits<{
saved : [client: SavedTorrentClient, isEdit: boolean]
cancel: []
}>()
const { add: toast } = useToast()
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
}
const name = ref(props.client?.name ?? '')
const type = ref(props.client?.type ?? '')
const config = ref<TorrentClientConfig>(props.client ? JSON.parse(JSON.stringify(props.client.config)) : {})
const tested = ref(!!props.client)
const testing = ref(false)
const saving = ref(false)
const advancedOpen = ref(false)
function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? ''
}
function toBool(v: unknown): boolean {
return v === true || v === 'true'
}
const currentDefinition = computed(() =>
props.definitions.find(d => d.id === type.value) ?? null
)
const basicFields = computed(() =>
currentDefinition.value?.fields.filter(f => !ADVANCED_KEYS.includes(f.key)) ?? []
)
const advancedFields = computed(() =>
currentDefinition.value?.fields.filter(f => ADVANCED_KEYS.includes(f.key)) ?? []
)
function onTypeChange() {
config.value = {}
advancedOpen.value = false
for (const field of currentDefinition.value?.fields ?? []) {
if (field.default === undefined) continue
config.value[field.key] = field.type === 'boolean' ? field.default : String(field.default)
}
}
watch([name, config], () => { tested.value = false }, { deep: true })
async function test() {
testing.value = true
tested.value = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: type.value, config: config.value, uuid: props.client?.uuid ?? null }),
})
const { ok, message } = await res.json()
tested.value = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
testing.value = false
}
}
async function save() {
if (!tested.value) return
saving.value = true
try {
const isEdit = !!props.client
const res = await fetch(isEdit ? `/api/torrent-clients/${props.client!.uuid}` : '/api/torrent-clients', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({
name : name.value || currentDefinition.value?.label || type.value,
type : type.value,
config: config.value,
}),
})
if (res.ok) {
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
emit('saved', await res.json(), isEdit)
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
saving.value = false
}
}
</script>
+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
}
}
+228
View File
@@ -0,0 +1,228 @@
<template>
<Teleport to="body">
<div
v-if="step"
ref="rootRef"
class="fixed inset-0 z-[60]"
role="dialog"
aria-modal="true"
aria-labelledby="tour-title"
aria-describedby="tour-body"
>
<div class="absolute inset-0" />
<div
v-if="spot"
class="absolute rounded-lg pointer-events-none transition-all duration-200 motion-reduce:transition-none"
:style="{
top: `${spot.top}px`, left: `${spot.left}px`, width: `${spot.width}px`, height: `${spot.height}px`,
boxShadow: '0 0 0 2px var(--accent), 0 0 0 9999px rgb(0 0 0 / 0.65)',
}"
/>
<div v-else class="absolute inset-0 bg-black/60 pointer-events-none" />
<div
ref="bubbleRef"
class="absolute bg-card border border-border rounded-xl p-4 w-80 max-w-[calc(100vw-24px)] shadow-xl flex flex-col gap-3 transition-[top,left] duration-200 motion-reduce:transition-none"
:style="{ top: `${bubble.top}px`, left: `${bubble.left}px` }"
>
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<p class="text-[11px] text-accent font-medium">{{ tour.index + 1 }} / {{ tour.steps.length }}</p>
<h2 id="tour-title" class="text-sm font-semibold text-primary mt-0.5">{{ step.title }}</h2>
</div>
<button @click="tour.finish()" aria-label="Fermer la visite" class="text-muted hover:text-primary transition-colors p-1 -m-1 rounded-md">
<X :size="15" />
</button>
</div>
<p id="tour-body" class="text-xs text-secondary leading-relaxed" aria-live="polite">{{ step.body }}</p>
<div class="flex items-center gap-2 pt-1">
<button @click="tour.finish()" class="btn-ghost text-xs">Passer</button>
<div class="flex-1" />
<button v-if="tour.index > 0" @click="tour.prev()" class="btn-secondary text-xs py-1.5">Précédent</button>
<button ref="primaryRef" @click="tour.next()" class="btn-primary text-xs py-1.5">
{{ tour.isLast ? 'Terminer' : 'Suivant' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import { useTourStore } from '@/stores/tour'
import { clampRect, computeBubblePosition, type Rect } from '@/utils/tour-position'
import type { TourStep } from '@/tour/steps'
const PAD = 6
const tour = useTourStore()
const route = useRoute()
const router = useRouter()
const isDesktop = useMediaQuery('(min-width: 768px)')
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
const rootRef = ref<HTMLElement | null>(null)
const bubbleRef = ref<HTMLElement | null>(null)
const primaryRef = ref<HTMLButtonElement | null>(null)
const spot = ref<Rect | null>(null)
const bubble = ref({ top: window.innerHeight / 2 - 80, left: Math.max(12, window.innerWidth / 2 - 160) })
const step = computed(() => tour.current)
let token = 0
let expectedPath = ''
let lostAt = 0
let raf = 0
let previousFocus : HTMLElement | null = null
let observed : HTMLElement | null = null
const resizeObserver = new ResizeObserver(() => scheduleUpdate())
const mutationObserver = new MutationObserver(records => {
if (records.some(r => !rootRef.value?.contains(r.target))) scheduleUpdate()
})
function findTarget(s: TourStep): HTMLElement | null {
if (!isDesktop.value || !s.target) return null
for (const name of s.target) {
for (const el of document.querySelectorAll<HTMLElement>(`[data-tour="${name}"]`)) {
if (el.getClientRects().length > 0) return el
}
}
return null
}
function waitForTarget(s: TourStep, timeout = 4000): Promise<HTMLElement | null> {
return new Promise(resolve => {
const found = findTarget(s)
if (found || !s.target || !isDesktop.value) return resolve(found)
const obs = new MutationObserver(() => {
const el = findTarget(s)
if (el) { cleanup(); resolve(el) }
})
const timer = setTimeout(() => { cleanup(); resolve(null) }, timeout)
function cleanup() { obs.disconnect(); clearTimeout(timer) }
obs.observe(document.body, { childList: true, subtree: true })
})
}
function scheduleUpdate() {
if (raf) return
raf = requestAnimationFrame(() => { raf = 0; update() })
}
function update() {
const s = step.value
if (!s) return
const viewport = { width: window.innerWidth, height: window.innerHeight }
const el = findTarget(s)
if (el !== observed) {
if (observed) resizeObserver.unobserve(observed)
if (el) resizeObserver.observe(el)
observed = el
}
let rect: Rect | null = null
if (el) {
const r = el.getBoundingClientRect()
rect = clampRect({ top: r.top - PAD, left: r.left - PAD, width: r.width + PAD * 2, height: r.height + PAD * 2 }, viewport)
lostAt = 0
} else if (spot.value) {
// Cible démontée le temps d'un rechargement (grille des séries) : on garde le cadre un instant
if (!lostAt) { lostAt = performance.now(); setTimeout(scheduleUpdate, 320) }
if (performance.now() - lostAt < 300) rect = spot.value
}
spot.value = rect
const size = bubbleRef.value
? { width: bubbleRef.value.offsetWidth, height: bubbleRef.value.offsetHeight }
: { width: 320, height: 160 }
const pos = computeBubblePosition(rect, size, viewport, s.placement)
bubble.value = { top: pos.top, left: pos.left }
}
async function showStep() {
const s = step.value
if (!s) return
const current = ++token
spot.value = null
lostAt = 0
if (s.route && route.path !== s.route) {
expectedPath = s.route
try { await router.push(s.route) } catch {}
if (current !== token) return
}
expectedPath = route.path
await nextTick()
update()
const el = await waitForTarget(s)
if (current !== token) return
if (el) {
const r = el.getBoundingClientRect()
if (r.top < 0 || r.bottom > window.innerHeight) {
el.scrollIntoView({ block: 'center', behavior: reducedMotion.value ? 'auto' : 'smooth' })
}
}
await nextTick()
update()
primaryRef.value?.focus({ preventScroll: true })
}
function onKeydown(e: KeyboardEvent) {
if (!step.value) return
if (e.key === 'Escape') { e.preventDefault(); tour.finish(); return }
if (e.key === 'ArrowRight') { e.preventDefault(); tour.next(); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); tour.prev(); return }
if (e.key === 'Tab' && bubbleRef.value) {
const focusables = [...bubbleRef.value.querySelectorAll<HTMLElement>('button')]
if (focusables.length === 0) return
const i = focusables.indexOf(document.activeElement as HTMLElement)
const nextIndex = e.shiftKey ? (i <= 0 ? focusables.length - 1 : i - 1) : (i + 1) % focusables.length
e.preventDefault()
focusables[nextIndex]?.focus()
}
}
useEventListener(window, 'keydown', onKeydown)
useEventListener(window, 'resize', scheduleUpdate)
useEventListener(document, 'scroll', scheduleUpdate, { capture: true, passive: true })
watch(() => tour.active, (active) => {
if (active) {
previousFocus = document.activeElement as HTMLElement | null
mutationObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] })
} else {
token++
mutationObserver.disconnect()
resizeObserver.disconnect()
observed = null
spot.value = null
previousFocus?.focus?.({ preventScroll: true })
previousFocus = null
}
}, { immediate: true })
watch(() => [tour.active, tour.index], () => { if (tour.active) showStep() }, { immediate: true })
// Navigation manuelle (bouton précédent du navigateur…) : on arrête la visite
watch(() => route.path, (path) => {
if (tour.active && path !== expectedPath) tour.finish()
})
onBeforeUnmount(() => {
mutationObserver.disconnect()
resizeObserver.disconnect()
if (raf) cancelAnimationFrame(raf)
})
</script>
+23 -1
View File
@@ -31,20 +31,37 @@
</RouterView> </RouterView>
</main> </main>
</div> </div>
<TourOverlay />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { Menu } from 'lucide-vue-next' import { Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/nav/SidebarNav.vue' import SidebarNav from '@/components/nav/SidebarNav.vue'
import TourOverlay from '@/components/tour/TourOverlay.vue'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
import type { NavItem, NavChild } from '@/types/nav' import type { NavItem, NavChild } from '@/types/nav'
const mobileOpen = ref(false) const mobileOpen = ref(false)
const dlStore = useDownloadsStore() const dlStore = useDownloadsStore()
const auth = useAuthStore() const auth = useAuthStore()
const tour = useTourStore()
// Première visite : une seule fois par session, après l'assistant pour un admin
let tourAutoStarted = false
watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
if (tourAutoStarted || tour.active) return
if (auth.loggedIn && !auth.tourSeen && (!auth.isAdmin || auth.onboardingDone)) {
tourAutoStarted = true
tour.start()
}
}, { immediate: true })
watch(() => tour.active, (active) => { if (active) mobileOpen.value = false })
const navItems = computed<NavItem[]>(() => { const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [ const items: NavItem[] = [
@@ -52,11 +69,13 @@ const navItems = computed<NavItem[]>(() => {
label: 'Dashboard', label: 'Dashboard',
icon : 'LayoutDashboard', icon : 'LayoutDashboard',
to : '/dashboard', to : '/dashboard',
tour : 'nav-dashboard',
}, },
{ {
label : 'Médiathèque', label : 'Médiathèque',
icon : 'Tv', icon : 'Tv',
to : '/series', to : '/series',
tour : 'nav-series',
children: [ children: [
{ label: 'Séries', to: '/series' }, { label: 'Séries', to: '/series' },
], ],
@@ -65,6 +84,7 @@ const navItems = computed<NavItem[]>(() => {
label: 'Demandes', label: 'Demandes',
icon : 'ClipboardList', icon : 'ClipboardList',
to : '/requests', to : '/requests',
tour : 'nav-requests',
}, },
] ]
@@ -73,6 +93,7 @@ const navItems = computed<NavItem[]>(() => {
label: 'Activité', label: 'Activité',
icon : 'Activity', icon : 'Activity',
to : '/activity', to : '/activity',
tour : 'nav-activity',
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined, badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
}) })
} }
@@ -99,6 +120,7 @@ const navItems = computed<NavItem[]>(() => {
label : 'Paramètres', label : 'Paramètres',
icon : 'Settings', icon : 'Settings',
to : '/settings', to : '/settings',
tour : 'nav-settings',
children: settingsChildren, children: settingsChildren,
}) })
+11
View File
@@ -23,6 +23,12 @@ const router = createRouter({
component: () => import('@/views/InviteView.vue'), component: () => import('@/views/InviteView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{
path: '/setup',
name: 'setup',
component: () => import('@/views/SetupView.vue'),
meta: { adminOnly: true },
},
// ─── App principale avec layout sidebar ────────────────── // ─── App principale avec layout sidebar ──────────────────
{ {
@@ -127,6 +133,7 @@ const router = createRouter({
scrollBehavior(to, from, savedPosition) { scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition if (savedPosition) return savedPosition
if (to.name === 'setup') return { top: 0 }
// Retour depuis le détail vers la liste — ne pas scroller en haut // Retour depuis le détail vers la liste — ne pas scroller en haut
if (to.name === 'series' && from.name === 'serie-detail') return false if (to.name === 'series' && from.name === 'serie-detail') return false
return { el: '#main-scroll', top: 0 } return { el: '#main-scroll', top: 0 }
@@ -147,6 +154,10 @@ router.beforeEach(async (to) => {
// Redirige les non-admins hors des pages admin // Redirige les non-admins hors des pages admin
if (to.meta.adminOnly && !auth.isAdmin) return '/series' if (to.meta.adminOnly && !auth.isAdmin) return '/series'
// Premier lancement : l'assistant de configuration est obligatoire
if (auth.loggedIn && auth.isAdmin && !auth.onboardingDone
&& !to.meta.public && to.name !== 'setup') return '/setup'
}) })
export default router export default router
+31 -5
View File
@@ -9,6 +9,9 @@ export const useAuthStore = defineStore('auth', () => {
const username = ref<string | null>(null) const username = ref<string | null>(null)
const userId = ref<string | null>(null) const userId = ref<string | null>(null)
const onboardingDone = ref(true)
const tourSeen = ref(true)
const isAdmin = computed(() => role.value === 'admin') const isAdmin = computed(() => role.value === 'admin')
async function checkStatus() { async function checkStatus() {
@@ -20,6 +23,8 @@ export const useAuthStore = defineStore('auth', () => {
role.value = data.role ?? null role.value = data.role ?? null
username.value = data.username ?? null username.value = data.username ?? null
userId.value = data.userId ?? null userId.value = data.userId ?? null
onboardingDone.value = data.onboardingDone ?? true
tourSeen.value = data.tourSeen ?? true
} catch { } catch {
loggedIn.value = false loggedIn.value = false
} finally { } finally {
@@ -35,10 +40,7 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
loggedIn.value = true await checkStatus()
role.value = data.user?.role ?? null
username.value = data.user?.username ?? null
userId.value = data.user?.id ?? null
return null return null
} }
return data.error return data.error
@@ -57,6 +59,8 @@ export const useAuthStore = defineStore('auth', () => {
role.value = data.user?.role ?? 'admin' role.value = data.user?.role ?? 'admin'
username.value = data.user?.username ?? u username.value = data.user?.username ?? u
userId.value = data.user?.id ?? null userId.value = data.user?.id ?? null
onboardingDone.value = false
tourSeen.value = false
return null return null
} }
return data.error return data.error
@@ -68,7 +72,29 @@ export const useAuthStore = defineStore('auth', () => {
role.value = null role.value = null
username.value = null username.value = null
userId.value = null userId.value = null
onboardingDone.value = true
tourSeen.value = true
} }
return { loggedIn, setup, loading, role, username, userId, isAdmin, checkStatus, login, setupAccount, logout } async function markTourSeen() {
tourSeen.value = true
try { await fetch('/api/auth/tour-seen', { method: 'POST', credentials: 'include' }) } catch {}
}
async function completeOnboarding(): Promise<boolean> {
const res = await fetch('/api/settings/onboarding', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ complete: true }),
})
if (res.ok) onboardingDone.value = true
return res.ok
}
return {
loggedIn, setup, loading, role, username, userId, isAdmin,
onboardingDone, tourSeen,
checkStatus, login, setupAccount, logout, markTourSeen, completeOnboarding,
}
}) })
+36
View File
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { ref, shallowRef, computed } from 'vue'
import { buildTourSteps, type TourStep } from '@/tour/steps'
import { useAuthStore } from '@/stores/auth'
export const useTourStore = defineStore('tour', () => {
const active = ref(false)
const index = ref(0)
const steps = shallowRef<TourStep[]>([])
const current = computed(() => active.value ? steps.value[index.value] ?? null : null)
const isLast = computed(() => index.value >= steps.value.length - 1)
function start() {
steps.value = buildTourSteps(useAuthStore().isAdmin)
index.value = 0
active.value = true
}
function next() {
if (isLast.value) finish()
else index.value++
}
function prev() {
if (index.value > 0) index.value--
}
function finish() {
active.value = false
const auth = useAuthStore()
if (!auth.tourSeen) auth.markTourSeen()
}
return { active, index, steps, current, isLast, start, next, prev, finish }
})
+113
View File
@@ -0,0 +1,113 @@
export type TourPlacement = 'top' | 'right' | 'bottom' | 'left'
export interface TourStep {
id : string
route? : string
target? : string[]
title : string
body : string
placement?: TourPlacement
}
const welcome: TourStep = {
id: 'welcome',
title: 'Bienvenue dans FanKarr',
body: 'Une visite rapide des écrans principaux. Utilisez les flèches ← → pour naviguer et Échap pour quitter.',
}
const help = (isAdmin: boolean): TourStep => ({
id: 'help', target: ['help'], placement: 'right',
title: 'Besoin d\'aide ?',
body: isAdmin
? 'Ce bouton relance la visite ou l\'assistant de configuration à tout moment (menu ☰ sur mobile).'
: 'Ce bouton relance la visite à tout moment (menu ☰ sur mobile).',
})
const adminSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'bottom',
title: 'Tableau de bord',
body: 'Taille du catalogue, séries importées, téléchargements en cours et erreurs, en un coup d\'œil.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'top',
title: 'Demandes en attente',
body: 'Les demandes de vos utilisateurs arrivent ici, qu\'elles viennent de FanKarr ou du plugin Jellyfin.',
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Tout le catalogue Fankai. Cliquez sur une affiche pour ouvrir la fiche : saisons, épisodes, téléchargement et surveillance.',
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Cherchez une série, filtrez par disponibilité ou état d\'import, triez et changez la taille des affiches.',
},
{
id: 'series-legend', route: '/series', target: ['series-legend'], placement: 'bottom',
title: 'Barres d\'état',
body: 'La barre sous chaque affiche indique où en est la série : importée, en cours, partielle ou pas encore là.',
},
{
id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
title: 'Actions groupées',
body: 'Sélectionnez plusieurs séries pour les renommer ou les surveiller. Une série surveillée télécharge seule ses nouveaux épisodes.',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
title: 'Demandes',
body: 'Approuvez ou refusez les demandes. L\'approbation lance le téléchargement si des torrents existent.',
},
{
id: 'activity', route: '/activity', target: ['activity-header'], placement: 'bottom',
title: 'Activité',
body: 'Suivi en direct des torrents FanKarr, import manuel et relance quand un fichier n\'a pas été reconnu.',
},
{
id: 'settings', route: '/settings/download-client', target: ['nav-settings'], placement: 'right',
title: 'Paramètres',
body: 'Clients torrent, dossiers et mode d\'import, gestion des séries, catalogue, journaux, utilisateurs et Jellyfin.',
},
help(true),
]
const userSteps: TourStep[] = [
welcome,
{
id: 'dashboard', route: '/dashboard', target: ['dashboard-stats'], placement: 'bottom',
title: 'Tableau de bord',
body: 'Le catalogue Fankai et les séries déjà disponibles au visionnage.',
},
{
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'top',
title: 'Mes demandes',
body: 'L\'état de vos dernières demandes.',
},
{
id: 'series', route: '/series', target: ['series-poster', 'series-grid'], placement: 'right',
title: 'Médiathèque',
body: 'Ouvrez une fiche puis « Demander la série », ou choisissez une saison précise.',
},
{
id: 'series-toolbar', route: '/series', target: ['series-toolbar'], placement: 'bottom',
title: 'Recherche et filtres',
body: 'Retrouvez rapidement une série et filtrez celles qui sont déjà disponibles.',
},
{
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
title: 'Demandes',
body: 'Suivez vos demandes : en attente, approuvée, disponible ou refusée.',
},
{
id: 'profile-token', route: '/settings/profile', target: ['profile-token'], placement: 'top',
title: 'Token API',
body: 'Ce token relie le plugin Jellyfin FanKarr Search à votre compte pour faire vos demandes depuis Jellyfin.',
},
help(false),
]
export function buildTourSteps(isAdmin: boolean): TourStep[] {
return isAdmin ? adminSteps : userSteps
}
+2 -1
View File
@@ -9,5 +9,6 @@ export interface NavItem {
to? : string to? : string
children? : NavChild[] children? : NavChild[]
separator?: true separator?: true
badge? : string | number // ← badge optionnel badge? : string | number
tour? : string
} }
+23
View File
@@ -0,0 +1,23 @@
export type TorrentClientConfig = Record<string, string | number | boolean>
export interface TorrentClientField {
key : string
label : string
type : 'text' | 'password' | 'number' | 'url' | 'boolean'
placeholder?: string
required : boolean
default? : string | number | boolean
}
export interface TorrentClientDefinition {
id : string
label : string
fields: TorrentClientField[]
}
export interface SavedTorrentClient {
uuid : string
name : string
type : string
config: TorrentClientConfig
}
+72
View File
@@ -0,0 +1,72 @@
import type { TourPlacement } from '@/tour/steps'
export interface Rect { top: number; left: number; width: number; height: number }
export interface Size { width: number; height: number }
export interface BubblePosition { top: number; left: number; placement: TourPlacement | 'inside' | 'center' }
const OPPOSITE: Record<TourPlacement, TourPlacement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max))
export function clampRect(rect: Rect, viewport: Size): Rect | null {
const top = Math.max(rect.top, 0)
const left = Math.max(rect.left, 0)
const bottom = Math.min(rect.top + rect.height, viewport.height)
const right = Math.min(rect.left + rect.width, viewport.width)
if (bottom <= top || right <= left) return null
return { top, left, width: right - left, height: bottom - top }
}
export function computeBubblePosition(
target : Rect | null,
bubble : Size,
viewport : Size,
placement: TourPlacement = 'bottom',
gap = 12,
margin = 12,
): BubblePosition {
const centered: BubblePosition = {
top : Math.max(margin, (viewport.height - bubble.height) / 2),
left: Math.max(margin, (viewport.width - bubble.width) / 2),
placement: 'center',
}
const t = target && clampRect(target, viewport)
if (!t) return centered
const maxLeft = viewport.width - bubble.width - margin
const maxTop = viewport.height - bubble.height - margin
const hCenter = clamp(t.left + t.width / 2 - bubble.width / 2, margin, maxLeft)
const vCenter = clamp(t.top + t.height / 2 - bubble.height / 2, margin, maxTop)
const candidates: Record<TourPlacement, () => BubblePosition | null> = {
bottom: () => {
const top = t.top + t.height + gap
return top <= maxTop ? { top, left: hCenter, placement: 'bottom' } : null
},
top: () => {
const top = t.top - gap - bubble.height
return top >= margin ? { top, left: hCenter, placement: 'top' } : null
},
right: () => {
const left = t.left + t.width + gap
return left <= maxLeft ? { top: vCenter, left, placement: 'right' } : null
},
left: () => {
const left = t.left - gap - bubble.width
return left >= margin ? { top: vCenter, left, placement: 'left' } : null
},
}
const order = [placement, OPPOSITE[placement], ...(['bottom', 'top', 'right', 'left'] as const)]
for (const p of order) {
const pos = candidates[p]()
if (pos) return pos
}
// Cible trop grande : bulle posée dans sa partie visible, en bas
return {
top : clamp(t.top + t.height - bubble.height - margin, margin, maxTop),
left: hCenter,
placement: 'inside',
}
}
+3 -4
View File
@@ -4,10 +4,7 @@
<!-- Logo --> <!-- Logo -->
<div class="flex items-center justify-center gap-2.5 mb-8"> <div class="flex items-center justify-center gap-2.5 mb-8">
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg"> <FankarrLogo class="w-8 h-8" />
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
</svg>
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span> <span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
</div> </div>
@@ -52,6 +49,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import FankarrLogo from '@/components/FankarrLogo.vue'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
@@ -67,6 +65,7 @@ const isSetup = computed(() => !auth.setup)
async function submit() { async function submit() {
error.value = null error.value = null
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return } if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
if (isSetup.value && password.value.length < 6) { error.value = 'Le mot de passe doit faire au moins 6 caractères'; return }
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return } if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
submitting.value = true submitting.value = true
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="px-8 py-8 max-w-4xl"> <div class="px-8 py-8 max-w-4xl">
<!-- Stats --> <!-- Stats -->
<div class="grid gap-3 mb-6" :class="auth.isAdmin ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-2'"> <div data-tour="dashboard-stats" class="grid gap-3 mb-6" :class="auth.isAdmin ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-2'">
<div class="settings-card flex flex-col gap-1"> <div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Catalogue</span> <span class="text-xs text-muted">Catalogue</span>
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span> <span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
@@ -81,7 +81,7 @@
</div> </div>
<!-- Widget demandes --> <!-- Widget demandes -->
<div class="settings-card flex flex-col gap-3 mb-6"> <div data-tour="dashboard-requests" class="settings-card flex flex-col gap-3 mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-secondary"> <h2 class="text-sm font-medium text-secondary">
{{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }} {{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }}
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="px-8 py-8 max-w-4xl"> <div class="px-8 py-8 max-w-4xl">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div data-tour="activity-header" class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h1 class="text-lg font-semibold text-primary">Activité</h1> <h1 class="text-lg font-semibold text-primary">Activité</h1>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="px-4 md:px-8 py-8 max-w-4xl"> <div class="px-4 md:px-8 py-8 max-w-4xl">
<div class="flex items-center justify-between mb-6"> <div data-tour="requests-header" class="flex items-center justify-between mb-6">
<div> <div>
<h1 class="text-lg font-semibold text-primary">Demandes</h1> <h1 class="text-lg font-semibold text-primary">Demandes</h1>
<p class="text-sm text-muted mt-0.5"> <p class="text-sm text-muted mt-0.5">
+3 -2
View File
@@ -18,7 +18,7 @@
/> />
<!-- Grille --> <!-- Grille -->
<div class="px-4 md:px-6 py-6"> <div data-tour="series-grid" class="px-4 md:px-6 py-6">
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-3 h-64 text-muted"> <div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" /> <div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
@@ -37,8 +37,9 @@
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }"> <div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
<component <component
:is="selecting ? 'div' : RouterLink" :is="selecting ? 'div' : RouterLink"
v-for="serie in filtered" v-for="(serie, i) in filtered"
:key="serie.id" :key="serie.id"
:data-tour="i === 0 ? 'series-poster' : undefined"
v-bind="selecting ? {} : { to: `/series/${serie.id}` }" v-bind="selecting ? {} : { to: `/series/${serie.id}` }"
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5" class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
:class="{ :class="{
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="min-h-screen bg-shell flex flex-col md:flex-row">
<!-- Rail des étapes -->
<aside class="md:w-72 md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-border flex flex-col shrink-0">
<div class="flex items-center gap-2.5 px-5 py-4 border-b border-border min-h-[56px]">
<FankarrLogo class="w-7 h-7 shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-[15px] font-semibold text-primary tracking-wide leading-tight">FanKarr</p>
<p class="text-[11px] text-muted">Assistant de configuration</p>
</div>
<button
v-if="context.relaunch"
@click="close"
title="Fermer l'assistant"
class="text-muted hover:text-primary transition-colors p-1 rounded-md hover:bg-hover"
>
<X :size="16" />
</button>
<button
v-else
@click="logout"
title="Se déconnecter"
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
>
<LogOut :size="15" />
</button>
</div>
<!-- Progression mobile -->
<div class="md:hidden px-5 py-3">
<p class="text-xs text-muted">Étape {{ index + 1 }} sur {{ steps.length }} · <span class="text-primary">{{ current.label }}</span></p>
<div class="h-1 bg-border rounded-full mt-2 overflow-hidden">
<div class="h-full bg-accent rounded-full transition-all duration-300" :style="{ width: `${((index + 1) / steps.length) * 100}%` }" />
</div>
</div>
<nav aria-label="Étapes" class="hidden md:block flex-1 overflow-y-auto px-3 py-5">
<ol class="flex flex-col gap-1">
<li v-for="(step, i) in steps" :key="step.id">
<button
:disabled="!canVisit(i)"
:aria-current="i === index ? 'step' : undefined"
@click="visit(i)"
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors"
:class="i === index ? 'bg-active' : canVisit(i) ? 'hover:bg-hover' : 'cursor-default'"
>
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium shrink-0 border"
:class="stepBadgeClass(step.id, i)"
>
<TriangleAlert v-if="step.id === 'client' && context.clientSkipped && i !== index" :size="12" />
<Check v-else-if="i < reached && i !== index" :size="12" />
<template v-else>{{ i + 1 }}</template>
</span>
<span class="flex flex-col min-w-0">
<span class="text-sm truncate" :class="i === index ? 'text-primary font-medium' : canVisit(i) ? 'text-secondary' : 'text-muted'">
{{ step.label }}
</span>
<span class="text-[11px] text-muted truncate">{{ step.hint }}</span>
</span>
</button>
</li>
</ol>
</nav>
<p class="hidden md:block px-5 py-4 text-[11px] text-muted border-t border-border leading-relaxed">
Tout reste modifiable ensuite dans les Paramètres.
</p>
</aside>
<!-- Contenu -->
<main class="flex-1 min-w-0">
<div v-if="!loaded" class="flex items-center justify-center py-32">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<div v-else class="max-w-2xl mx-auto px-5 md:px-8 py-8 md:py-12 flex flex-col gap-8">
<header class="flex flex-col gap-1.5">
<p class="text-xs text-accent font-medium">
Étape {{ index + 1 }} sur {{ steps.length }}<template v-if="current.optional"> · optionnelle</template>
</p>
<h1 ref="titleRef" tabindex="-1" class="text-xl font-semibold text-primary outline-none">{{ current.title }}</h1>
<p class="text-sm text-muted leading-relaxed">{{ current.description }}</p>
</header>
<component
:is="current.component"
ref="stepRef"
:key="current.id"
:settings="settings"
:is-docker="isDocker"
:default-path="defaultPath"
/>
<footer v-if="current.id !== 'recap'" class="flex items-center gap-2 pt-6 border-t border-border">
<button v-if="index > 0" @click="visit(index - 1)" :disabled="busy" class="btn-secondary flex items-center gap-1.5">
<ArrowLeft :size="14" /> Précédent
</button>
<div class="flex-1" />
<button v-if="current.optional" @click="next(false)" :disabled="busy" class="btn-ghost">
Passer
</button>
<button @click="next(true)" :disabled="busy" class="btn-primary flex items-center gap-1.5">
{{ busy ? '...' : index === 0 ? 'Commencer' : 'Suivant' }}
<ArrowRight v-if="!busy" :size="14" />
</button>
</footer>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, provide, nextTick, onMounted, type Component } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowLeft, ArrowRight, Check, LogOut, TriangleAlert, X } from 'lucide-vue-next'
import FankarrLogo from '@/components/FankarrLogo.vue'
import SetupWelcomeStep from '@/components/setup/SetupWelcomeStep.vue'
import SetupPathsStep from '@/components/setup/SetupPathsStep.vue'
import SetupClientStep from '@/components/setup/SetupClientStep.vue'
import SetupImportStep from '@/components/setup/SetupImportStep.vue'
import SetupMediaServerStep from '@/components/setup/SetupMediaServerStep.vue'
import SetupCatalogStep from '@/components/setup/SetupCatalogStep.vue'
import SetupRecapStep from '@/components/setup/SetupRecapStep.vue'
import { setupContextKey, type SetupContext, type SetupSettings, type SetupStepId } from '@/components/setup/setup'
import { useAuthStore } from '@/stores/auth'
import { useTourStore } from '@/stores/tour'
import { useToast } from '@/composables/useToast'
interface StepDef {
id : SetupStepId
label : string
hint : string
title : string
description: string
component : Component
optional? : boolean
}
const steps: StepDef[] = [
{
id: 'welcome', label: 'Bienvenue', hint: 'Ce qui vous attend', component: SetupWelcomeStep,
title: 'Bienvenue dans FanKarr',
description: 'Quelques minutes pour préparer FanKarr : où ranger vos séries, quel client torrent utiliser et comment les retrouver dans votre lecteur.',
},
{
id: 'paths', label: 'Dossiers', hint: 'Téléchargements et médiathèque', component: SetupPathsStep,
title: 'Dossiers et mode d\'import',
description: 'Indiquez où arrivent les téléchargements terminés et où FanKarr doit ranger vos séries.',
},
{
id: 'client', label: 'Client torrent', hint: 'qBittorrent, Transmission…', component: SetupClientStep,
title: 'Client de téléchargement',
description: 'FanKarr envoie les torrents à votre client puis surveille leur progression pour les importer.',
},
{
id: 'import', label: 'Import', hint: 'Automatisme et nommage', component: SetupImportStep,
title: 'Options d\'import',
description: 'Réglez ce qui se passe quand un téléchargement se termine.',
},
{
id: 'media-server', label: 'Serveur multimédia', hint: 'Jellyfin, Plex', component: SetupMediaServerStep, optional: true,
title: 'Jellyfin et Plex',
description: 'Connectez votre serveur multimédia pour y retrouver vos séries. Vous pouvez passer cette étape.',
},
{
id: 'catalog', label: 'Catalogue', hint: 'Séries Fankai', component: SetupCatalogStep,
title: 'Catalogue Fankai',
description: 'Le catalogue liste toutes les séries Fankai et leurs torrents. Il se met à jour tout seul ensuite.',
},
{
id: 'recap', label: 'Récapitulatif', hint: 'Vérifier et terminer', component: SetupRecapStep,
title: 'Tout est prêt ?',
description: 'Vérifiez la configuration avant de commencer. Chaque point reste modifiable.',
},
]
const router = useRouter()
const auth = useAuthStore()
const tour = useTourStore()
const { add: toast } = useToast()
const loaded = ref(false)
const busy = ref(false)
const index = ref(0)
const reached = ref(0)
const isDocker = ref(false)
const defaultPath = ref('/')
const titleRef = ref<HTMLElement | null>(null)
const stepRef = ref<{ submit?: () => Promise<boolean> } | null>(null)
const settings = reactive<SetupSettings>({
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
nfoSupport : false,
autoImport : true,
deleteTorrentOnMove: false,
autoUnimportMissing: false,
englishDirectory : false,
})
const context = reactive<SetupContext>({
relaunch : false,
clientSkipped: false,
plexOpened : false,
goto : (id) => visit(steps.findIndex(s => s.id === id)),
finish: finish,
})
provide(setupContextKey, context)
const current = computed(() => steps[index.value]!)
function canVisit(i: number) {
return i !== index.value && (context.relaunch || i <= reached.value)
}
function stepBadgeClass(id: SetupStepId, i: number) {
if (i === index.value) return 'border-accent text-accent bg-accent-muted'
if (id === 'client' && context.clientSkipped) return 'border-yellow-500/50 text-yellow-500'
if (i < reached.value) return 'border-green-500/50 text-green-400'
return 'border-border text-muted'
}
async function refreshSettings() {
const res = await fetch('/api/settings', { credentials: 'include' })
if (!res.ok) return
const data = await res.json()
for (const key of Object.keys(settings) as (keyof SetupSettings)[]) {
if (data[key] !== undefined) (settings as any)[key] = data[key]
}
}
async function focusTitle() {
await nextTick()
window.scrollTo({ top: 0 })
titleRef.value?.focus()
}
function visit(i: number) {
if (i < 0 || i >= steps.length || busy.value) return
index.value = i
focusTitle()
}
async function next(submit: boolean) {
busy.value = true
try {
if (submit && stepRef.value?.submit && !(await stepRef.value.submit())) return
await refreshSettings()
const target = index.value + 1
if (target > reached.value) {
reached.value = target
fetch('/api/settings/onboarding', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ step: steps[target]!.id }),
}).catch(() => {})
}
index.value = target
focusTitle()
} finally {
busy.value = false
}
}
async function finish(withTour: boolean) {
if (!context.relaunch && !(await auth.completeOnboarding())) {
toast('Impossible de terminer la configuration', 'error')
return
}
if (withTour) {
tour.start()
await router.push('/dashboard')
} else {
if (!auth.tourSeen) auth.markTourSeen()
await router.push(context.relaunch ? '/settings/advanced' : '/series')
}
}
function close() {
router.push('/settings/advanced')
}
async function logout() {
await auth.logout()
router.push('/auth')
}
onMounted(async () => {
const [onboardingRes, systemRes, infoRes] = await Promise.all([
fetch('/api/settings/onboarding', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
fetch('/api/system/info', { credentials: 'include' }),
refreshSettings(),
])
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) defaultPath.value = (await infoRes.json()).defaultPath ?? '/'
if (onboardingRes.ok) {
const { step, completedAt } = await onboardingRes.json()
if (completedAt) {
context.relaunch = true
auth.onboardingDone = true
reached.value = steps.length - 1
} else {
const i = steps.findIndex(s => s.id === step)
reached.value = Math.max(0, i)
index.value = reached.value
}
}
loaded.value = true
})
</script>
+16
View File
@@ -16,12 +16,28 @@
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div> <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>
</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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue' import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useTourStore } from '@/stores/tour'
const tour = useTourStore()
const devMode = ref(false) const devMode = ref(false)
const saved = ref(false) const saved = ref(false)
+25 -254
View File
@@ -118,117 +118,16 @@
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4"> <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"> <div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3> <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> <button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
</div> </div>
<div class="flex flex-col gap-3"> <TorrentClientForm
<div> :key="modal.key"
<label class="settings-label">Nom</label> :client="modal.client"
<input v-model="modal.name" placeholder="Mon client torrent" class="settings-input" /> :definitions="availableClients"
</div> @saved="onSaved"
<div> />
<label class="settings-label">Type</label>
<select v-model="modal.type" @change="onTypeChange" class="settings-input">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<!-- Champs principaux -->
<template v-if="currentDefinition">
<div v-for="field in basicFields" :key="field.key">
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(modal.config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="modal.config[field.key] = $event"
/>
<template v-else>
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div>
<!-- Paramètres avancés -->
<div v-if="advancedFields.length > 0">
<button
type="button"
@click="advancedOpen = !advancedOpen"
class="flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-full py-1"
>
<ChevronDown
:size="13"
class="transition-transform duration-200 shrink-0"
:class="advancedOpen ? 'rotate-180' : ''"
/>
Paramètres avancés
</button>
<div v-if="advancedOpen" class="flex flex-col gap-3 mt-2 pl-3 border-l border-border">
<div v-for="field in advancedFields" :key="field.key">
<SettingsToggle
v-if="field.type === 'boolean'"
:model-value="toBool(modal.config[field.key])"
:label="field.label"
:description="fieldTooltip(field.key)"
@update:model-value="modal.config[field.key] = $event"
/>
<template v-else>
<label class="settings-label flex items-center gap-1.5">
{{ field.label }}
<span
class="inline-flex items-center justify-center w-4 h-4 rounded-full border border-border text-[10px] text-muted cursor-default leading-none shrink-0 relative group"
tabindex="-1"
>
i
<span class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-56 text-[11px] text-secondary bg-card border border-border rounded-lg px-2.5 py-1.5 shadow-lg pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-10 leading-relaxed">
{{ fieldTooltip(field.key) }}
</span>
</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="settings-input"
/>
</template>
</div>
</div>
</div>
</template>
</div>
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
Testez la connexion avant d'enregistrer.
</p>
<div class="flex gap-2 pt-1">
<button
@click="saveClient"
:disabled="modal.saving || !modal.tested"
class="btn-primary"
>
{{ modal.saving ? '...' : 'Enregistrer' }}
</button>
<button
@click="testNewClient"
:disabled="modal.testing || !modal.type"
class="btn-secondary"
>
{{ modal.testing ? '...' : modal.tested ? 'Testé ✓' : 'Tester' }}
</button>
</div>
</div> </div>
</div> </div>
@@ -238,15 +137,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ChevronDown } from 'lucide-vue-next' import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import type { TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
const { add: toast } = useToast() const { add: toast } = useToast()
const clients = ref<any[]>([]) const clients = ref<SavedTorrentClient[]>([])
const availableClients = ref<any[]>([]) const availableClients = ref<TorrentClientDefinition[]>([])
const healthStatus = ref<Record<string, boolean | null>>({}) const healthStatus = ref<Record<string, boolean | null>>({})
const clientMatrix = [ const clientMatrix = [
@@ -276,48 +175,7 @@ const clientMatrix = [
} }
] ]
const modal = ref({ const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
open : false,
uuid : null as string | null,
name : '',
type : '',
config : {} as Record<string, string | number | boolean>,
testing: false,
saving : false,
tested : false,
})
const ADVANCED_KEYS = ['savePath', 'remotePath', 'localPath', 'ignoreCertificateErrors']
const FIELD_TOOLTIPS: Record<string, string> = {
savePath : 'Optionnel. Permet de placer les téléchargements dans un sous-dossier spécifique à l\'intérieur du dossier déjà configuré dans la gestion des médias.',
remotePath : 'Si le client tourne sur une autre machine, indiquez ici le chemin qu\'il utilise (ex: /downloads). À associer avec le chemin local ci-dessous.',
localPath : 'Chemin équivalent au chemin distant, mais vu par FanKarr sur sa machine (ex: /mnt/nas/downloads). Les deux champs fonctionnent en binôme pour faire la correspondance.',
ignoreCertificateErrors: 'Accepte les certificats auto-signés ou invalides en HTTPS. À n\'utiliser que sur un réseau de confiance.',
}
function fieldTooltip(key: string): string {
return FIELD_TOOLTIPS[key] ?? ''
}
// Tolère la forme string "true" en plus du booléen natif
function toBool(v: unknown): boolean {
return v === true || v === 'true'
}
const advancedOpen = ref(false)
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
const basicFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => !ADVANCED_KEYS.includes(f.key)) ?? []
)
const advancedFields = computed(() =>
currentDefinition.value?.fields.filter((f: any) => ADVANCED_KEYS.includes(f.key)) ?? []
)
function clientLabel(type: string) { function clientLabel(type: string) {
return availableClients.value.find(d => d.id === type)?.label ?? type return availableClients.value.find(d => d.id === type)?.label ?? type
@@ -362,110 +220,23 @@ async function deleteClient(uuid: string) {
} }
function openAddModal() { function openAddModal() {
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false } modal.value = { open: true, key: modal.value.key + 1, client: null }
advancedOpen.value = false
} }
function editClient(client: any) { function editClient(client: SavedTorrentClient) {
modal.value = { modal.value = { open: true, key: modal.value.key + 1, client }
open : true,
uuid : client.uuid,
name : client.name,
type : client.type,
config : JSON.parse(JSON.stringify(client.config)),
testing: false,
saving : false,
tested : true,
}
} }
function closeModal() { modal.value.open = false } function closeModal() { modal.value.open = false }
function onTypeChange() { function onSaved(client: SavedTorrentClient, isEdit: boolean) {
modal.value.config = {} if (isEdit) {
modal.value.tested = false const index = clients.value.findIndex(c => c.uuid === client.uuid)
advancedOpen.value = false if (index !== -1) clients.value[index] = client
const def = currentDefinition.value } else {
if (def) { clients.value.push(client)
for (const field of def.fields) {
if (field.default === undefined) continue
modal.value.config[field.key] = field.type === 'boolean' ? field.default : String(field.default)
}
} }
refreshHealth(client.uuid)
closeModal()
} }
</script>
// Reset tested dès que l'utilisateur modifie un champ (nom ou config)
// sauf si c'est un mot de passe masqué non touché
watch(() => modal.value.name, () => {
if (modal.value.uuid) modal.value.tested = false
})
watch(() => modal.value.config, (newConfig) => {
if (!modal.value.uuid) return
const driver = currentDefinition.value
if (!driver) return
// Si au moins un champ non-password a changé → reset tested
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) {
const field = driver.fields.find((f: any) => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
return
}
}, { deep: true })
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const isEdit = !!modal.value.uuid
const method = isEdit ? 'PUT' : 'POST'
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
config: modal.value.config,
}),
})
if (res.ok) {
const client = await res.json()
if (isEdit) {
const index = clients.value.findIndex(c => c.uuid === client.uuid)
if (index !== -1) clients.value[index] = client
} else {
clients.value.push(client)
}
refreshHealth(client.uuid)
closeModal()
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
</script>
+3 -76
View File
@@ -11,34 +11,7 @@
</p> </p>
</div> </div>
<div class="settings-card flex flex-col gap-4"> <JellyfinConnectionForm @change="hasToken = $event.hasToken" />
<div>
<label class="settings-label mb-1.5">URL du serveur</label>
<input v-model="jellyfinUrl" type="url" class="settings-input" placeholder="http://jellyfin:8096" />
<p class="text-xs text-muted mt-1">URL accessible depuis FanKarr (réseau local ou Docker)</p>
</div>
<div>
<label class="settings-label mb-1.5">Token admin Jellyfin</label>
<input v-model="jellyfinAdminToken" type="password" class="settings-input"
:placeholder="hasToken ? '••••••••••••••••••••• (configuré)' : 'Tableau de bord → Clés API'" />
<p class="text-xs text-muted mt-1">
Dans Jellyfin : Tableau de bord → Clés API → Créer une clé
</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button @click="save" :disabled="saving" class="btn-primary">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
<button @click="testConnection" :disabled="testing" class="btn-secondary">
{{ testing ? 'Test…' : 'Tester la connexion' }}
</button>
<span v-if="testResult" class="text-xs" :class="testResult.ok ? 'text-green-400' : 'text-red-400'">
{{ testResult.ok ? `Connecté — Jellyfin ${testResult.version}` : testResult.error }}
</span>
<span v-if="saved" class="text-xs text-green-400">Enregistré.</span>
</div>
</div>
</section> </section>
<!-- ── Synchronisation des utilisateurs ──────────────────── --> <!-- ── Synchronisation des utilisateurs ──────────────────── -->
@@ -102,15 +75,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref } from 'vue'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
const hasToken = ref(false) const hasToken = ref(false)
const saving = ref(false)
const saved = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; version?: string; error?: string } | null>(null)
const syncing = ref(false) const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null) const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null) const syncError = ref<string | null>(null)
@@ -127,44 +95,6 @@ function methodClass(m: string) {
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted' return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
} }
async function loadSettings() {
const res = await fetch('/api/jellyfin/settings', { credentials: 'include' })
if (res.ok) {
const d = await res.json()
jellyfinUrl.value = d.jellyfinUrl ?? ''
hasToken.value = d.hasToken ?? false
}
}
onMounted(() => { loadSettings() })
async function save() {
saving.value = true
saved.value = false
const body: Record<string, string> = { jellyfinUrl: jellyfinUrl.value }
if (jellyfinAdminToken.value) body.jellyfinAdminToken = jellyfinAdminToken.value
const res = await fetch('/api/jellyfin/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify(body),
})
if (res.ok) {
const d = await res.json()
hasToken.value = d.hasToken
jellyfinAdminToken.value = '' // vider le champ après sauvegarde
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
saving.value = false
}
async function testConnection() {
testing.value = true
testResult.value = null
const res = await fetch('/api/jellyfin/test', { method: 'POST', credentials: 'include' })
testResult.value = await res.json()
testing.value = false
}
async function syncUsers() { async function syncUsers() {
syncing.value = true syncing.value = true
syncError.value = null syncError.value = null
@@ -175,7 +105,4 @@ async function syncUsers() {
else syncError.value = data.error ?? 'Erreur lors de la synchronisation' else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
syncing.value = false syncing.value = false
} }
</script> </script>
+1 -1
View File
@@ -57,7 +57,7 @@
</section> </section>
<!-- ── Token API personnel ──────────────────────────────── --> <!-- ── Token API personnel ──────────────────────────────── -->
<section class="flex flex-col gap-4"> <section data-tour="profile-token" class="flex flex-col gap-4">
<div> <div>
<h2 class="text-base font-semibold text-primary">Token API personnel</h2> <h2 class="text-base font-semibold text-primary">Token API personnel</h2>
<p class="text-sm text-muted mt-1"> <p class="text-sm text-muted mt-1">