feat(onboarding): assistant de configuration au premier lancement et visite guidée des écrans
This commit is contained in:
1 parent
6348cc40cb
commit
4b3c629ff9
43 files changed
+2498
-366
No files matched your search
+21
-2
@@ -8,9 +8,11 @@ import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
findByUsername, findById, findByApiToken,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen,
|
||||
type User,
|
||||
} from './users.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { startOnboarding } from './onboarding.js'
|
||||
|
||||
// ── Typage Express étendu ─────────────────────────────────────
|
||||
declare global {
|
||||
@@ -50,7 +52,11 @@ export function authStatus(req: Request, res: Response): void {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
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 {
|
||||
res.json({ setup, loggedIn: false })
|
||||
}
|
||||
@@ -67,9 +73,13 @@ export function authSetup(req: Request, res: Response): void {
|
||||
if (!username || !password) {
|
||||
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 {
|
||||
const user = createUser(username, password, 'admin')
|
||||
startOnboarding()
|
||||
logger.info('auth', `Premier compte admin créé pour "${username}"`)
|
||||
setCookieAndRespond(res, user)
|
||||
} 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)
|
||||
export function authRegister(req: Request, res: Response): void {
|
||||
const { username, password, inviteCode } = req.body
|
||||
|
||||
+4
-1
@@ -3,8 +3,9 @@ import cookieParser from 'cookie-parser'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
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 { migrateOnboarding } from './onboarding.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { registerDriver, dispatchList, dispatchRemove } from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
@@ -71,6 +72,7 @@ if (fs.existsSync(PUBLIC_PATH)) {
|
||||
|
||||
// ── Migration mono-user → multi-user ──────────────────────────
|
||||
migrateIfNeeded()
|
||||
migrateOnboarding()
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────
|
||||
app.get ('/api/auth/status', authStatus)
|
||||
@@ -80,6 +82,7 @@ app.post('/api/auth/logout', authLogout)
|
||||
app.get ('/api/auth/me', requireAuth, authMe)
|
||||
app.post('/api/auth/change-password', requireAuth, authChangePassword)
|
||||
app.post('/api/auth/regenerate-token', requireAuth, authRegenerateToken)
|
||||
app.post('/api/auth/tour-seen', requireAuth, authTourSeen)
|
||||
|
||||
// ── API publique v1 (auth par token) ──────────────────────────
|
||||
app.use('/api', publicApiRouter)
|
||||
|
||||
@@ -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
@@ -268,7 +268,7 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
// Désimport automatique des fichiers manquants sur le disque
|
||||
const { autoUnimportMissing } = readSettings() as any
|
||||
const { autoUnimportMissing } = readSettings()
|
||||
let autoRemoved = 0
|
||||
if (autoUnimportMissing && !mediaEmpty) {
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
@@ -300,11 +300,15 @@ export async function autoOrganizeAll(
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
const { autoImport } = readSettings()
|
||||
const { autoImport, mediaPath } = readSettings()
|
||||
if (!autoImport) {
|
||||
logger.debug('organize', 'Import automatique désactivé — skip')
|
||||
return
|
||||
}
|
||||
if (!mediaPath?.trim()) {
|
||||
logger.debug('organize', 'Médiathèque non configurée — skip')
|
||||
return
|
||||
}
|
||||
|
||||
if (_queued > 0) {
|
||||
logger.debug('organize', 'Import déjà en cours — skip')
|
||||
|
||||
@@ -2,9 +2,22 @@ import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { readSettings, writeSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { readOnboarding, advanceOnboarding, isOnboardingStep } from '../onboarding.js'
|
||||
|
||||
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) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { requireAuth } from '../auth.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from '../config.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 { readAvailable, cacheClear } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
@@ -36,6 +36,14 @@ router.get('/system', requireAuth, (_req, res) => {
|
||||
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) => {
|
||||
try {
|
||||
const versionPath = path.join(BASE_DIR, 'version.txt')
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface Settings {
|
||||
jellyfinUrl : string // URL du serveur Jellyfin (ex: http://jellyfin:8096)
|
||||
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")
|
||||
autoUnimportMissing : boolean
|
||||
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
|
||||
onboardingCompletedAt: string | null
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -32,6 +35,9 @@ const defaults: Settings = {
|
||||
jellyfinUrl : '',
|
||||
jellyfinAdminToken : '',
|
||||
englishDirectory : false,
|
||||
autoUnimportMissing: false,
|
||||
onboardingStep : null,
|
||||
onboardingCompletedAt: null,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
+139
-1
@@ -1,5 +1,7 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// GET /api/system/info
|
||||
@@ -12,4 +14,140 @@ export function systemInfo(_req: Request, res: Response): void {
|
||||
isTermux,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export interface User {
|
||||
role : UserRole
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
tourSeenAt? : string | null
|
||||
}
|
||||
|
||||
const USERS_PATH = path.join(DATA_DIR, 'users.json')
|
||||
@@ -92,6 +93,7 @@ export function createUser(username: string, password: string, role: UserRole =
|
||||
role,
|
||||
apiToken : crypto.randomBytes(32).toString('hex'),
|
||||
createdAt : new Date().toISOString(),
|
||||
tourSeenAt : null,
|
||||
}
|
||||
writeUsers([...users, user])
|
||||
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}"`)
|
||||
}
|
||||
|
||||
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 {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
<template>
|
||||
<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">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
|
||||
<!-- Logo + logout -->
|
||||
<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">
|
||||
<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>
|
||||
<FankarrLogo class="w-7 h-7 shrink-0" />
|
||||
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
|
||||
FanKarr
|
||||
</RouterLink>
|
||||
@@ -48,9 +45,34 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Version actuelle -->
|
||||
<div class="px-3 pb-1 pt-0.5">
|
||||
<!-- Version actuelle + aide -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Thème -->
|
||||
@@ -62,16 +84,34 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
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 { useTourStore } from '@/stores/tour'
|
||||
import SidebarNavItem from './SidebarNavItem.vue'
|
||||
import ThemeSwitcher from './ThemeSwitcher.vue'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
defineProps<{ items: NavItem[] }>()
|
||||
|
||||
const router = useRouter()
|
||||
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(
|
||||
import.meta.env.DEV ? 'dev' : ''
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :data-tour="item.tour">
|
||||
<component
|
||||
:is="item.to ? RouterLink : 'button'"
|
||||
:to="item.to ?? undefined"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 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 -->
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -79,6 +79,7 @@
|
||||
<!-- Sélection multiple -->
|
||||
<button
|
||||
v-if="selectable"
|
||||
data-tour="series-select"
|
||||
@click="emit('update:selecting', !selecting)"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 shrink-0"
|
||||
:class="selecting ? 'border-accent text-accent' : ''"
|
||||
@@ -122,7 +123,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
|
||||
<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>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div v-if="!loaded" class="flex items-center justify-center py-10">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="settings-card flex items-center gap-4">
|
||||
<span
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||
:class="status.empty ? 'bg-yellow-500/10 text-yellow-500' : 'bg-green-500/10 text-green-400'"
|
||||
>
|
||||
<Library :size="17" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Catalogue non téléchargé' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ status.empty
|
||||
? 'Sans catalogue, la médiathèque reste vide.'
|
||||
: 'Le catalogue se met à jour automatiquement.' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" :class="status.empty ? 'btn-primary' : 'btn-secondary'" class="shrink-0">
|
||||
{{ updating ? 'Téléchargement…' : status.empty ? 'Télécharger' : 'Mettre à jour' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="settings-card flex items-center gap-4">
|
||||
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<ScanSearch :size="17" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium">Vous avez déjà des séries Fankai ?</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
<template v-if="scanResult">{{ scanResult.found }} fichiers analysés · {{ scanResult.added }} référencés.</template>
|
||||
<template v-else>Analysez la médiathèque pour référencer les fichiers présents, sans les déplacer.</template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="scan" :disabled="scanning || status.empty || !settings.mediaPath" class="btn-secondary shrink-0">
|
||||
{{ scanning ? 'Analyse…' : 'Analyser' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<p v-if="warnEmpty" class="text-sm text-yellow-500" role="alert">
|
||||
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Library, ScanSearch } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { SetupSettings } from './setup'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const loaded = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
const updating = ref(false)
|
||||
const scanning = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
const warnEmpty = ref(false)
|
||||
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
status.value = { exists: true, count: data.count, empty: data.count === 0 }
|
||||
warnEmpty.value = false
|
||||
toast(`${data.count} séries chargées ✓`, 'success')
|
||||
} else {
|
||||
toast(data.error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
if (!status.value.empty || warnEmpty.value) return true
|
||||
warnEmpty.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
} catch {}
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
defineExpose({ submit })
|
||||
</script>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div v-if="!loaded" class="flex items-center justify-center py-10">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Clients existants -->
|
||||
<div v-if="clients.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="client in clients" :key="client.uuid" class="settings-card flex items-center gap-3">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="health[client.uuid] === true ? 'bg-green-500' : health[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ client.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ labelOf(client.type) }}
|
||||
<template v-if="health[client.uuid] === false"> · <span class="text-red-400">injoignable</span></template>
|
||||
<template v-else-if="health[client.uuid] === true"> · <span class="text-green-400">connecté</span></template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="edit(client)" class="btn-ghost text-xs">Modifier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<div v-if="formOpen" class="settings-card flex flex-col gap-4">
|
||||
<p class="text-sm font-semibold text-primary">{{ editing ? 'Modifier le client' : 'Ajouter un client' }}</p>
|
||||
<TorrentClientForm
|
||||
:key="formKey"
|
||||
:client="editing"
|
||||
:definitions="definitions"
|
||||
:show-cancel="clients.length > 0"
|
||||
@saved="onSaved"
|
||||
@cancel="formOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<button v-else @click="add" class="btn-secondary self-start flex items-center gap-1.5">
|
||||
<Plus :size="14" /> {{ clients.length > 0 ? 'Ajouter un autre client' : 'Ajouter un client' }}
|
||||
</button>
|
||||
|
||||
<!-- Rappel chemins -->
|
||||
<div class="flex items-start gap-3 px-4 py-3 rounded-lg border border-border bg-card/50">
|
||||
<Info :size="15" class="text-muted mt-0.5 shrink-0" />
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Le client doit enregistrer ses téléchargements dans un dossier que FanKarr voit
|
||||
<template v-if="settings.completePath">(<code class="font-mono text-secondary">{{ settings.completePath }}</code>)</template>.
|
||||
S'il tourne sur une autre machine ou dans un autre conteneur, renseignez
|
||||
<span class="text-secondary">Chemin distant</span> et <span class="text-secondary">Chemin local</span>
|
||||
dans les paramètres avancés du client.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="warnSkip" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5" role="alert">
|
||||
<TriangleAlert :size="15" class="text-yellow-500 mt-0.5 shrink-0" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Aucun client configuré</p>
|
||||
<p class="text-xs text-muted">
|
||||
Sans client, FanKarr ne pourra rien télécharger. Cliquez à nouveau sur « Suivant » pour continuer quand même,
|
||||
vous pourrez l'ajouter plus tard dans Paramètres → Clients de téléchargement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, onMounted } from 'vue'
|
||||
import { Info, Plus, TriangleAlert } from 'lucide-vue-next'
|
||||
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
|
||||
import { setupContextKey, type SetupSettings } from './setup'
|
||||
import type { SavedTorrentClient, TorrentClientDefinition } from '@/types/torrent-client'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const context = inject(setupContextKey)!
|
||||
|
||||
const loaded = ref(false)
|
||||
const clients = ref<SavedTorrentClient[]>([])
|
||||
const definitions = ref<TorrentClientDefinition[]>([])
|
||||
const health = ref<Record<string, boolean | null>>({})
|
||||
const formOpen = ref(false)
|
||||
const formKey = ref(0)
|
||||
const editing = ref<SavedTorrentClient | null>(null)
|
||||
const warnSkip = ref(false)
|
||||
|
||||
function labelOf(type: string) {
|
||||
return definitions.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
|
||||
async function refreshHealth(uuid: string) {
|
||||
health.value[uuid] = null
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||
health.value[uuid] = res.ok ? (await res.json()).online : false
|
||||
} catch {
|
||||
health.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
function add() {
|
||||
editing.value = null
|
||||
formKey.value++
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function edit(client: SavedTorrentClient) {
|
||||
editing.value = client
|
||||
formKey.value++
|
||||
formOpen.value = true
|
||||
}
|
||||
|
||||
function onSaved(client: SavedTorrentClient, isEdit: boolean) {
|
||||
if (isEdit) {
|
||||
const i = clients.value.findIndex(c => c.uuid === client.uuid)
|
||||
if (i !== -1) clients.value[i] = client
|
||||
} else {
|
||||
clients.value.push(client)
|
||||
}
|
||||
formOpen.value = false
|
||||
warnSkip.value = false
|
||||
context.clientSkipped = false
|
||||
refreshHealth(client.uuid)
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
if (clients.value.length > 0) {
|
||||
context.clientSkipped = false
|
||||
return true
|
||||
}
|
||||
if (!warnSkip.value) {
|
||||
warnSkip.value = true
|
||||
return false
|
||||
}
|
||||
context.clientSkipped = true
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [clientsRes, defsRes] = await Promise.all([
|
||||
fetch('/api/torrent-clients', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients/available', { credentials: 'include' }),
|
||||
])
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (defsRes.ok) definitions.value = await defsRes.json()
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
formOpen.value = clients.value.length === 0
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
defineExpose({ submit })
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importe dès qu'un téléchargement est terminé, puis vérifie toutes les 5 minutes. Sinon, l'import se lance à la main depuis Activité."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Dossiers « Season » au lieu de « Saison »"
|
||||
description="Nomme les dossiers de saison « Season 01 » plutôt que « Saison 1 »."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si le fichier disparaît"
|
||||
description="Un épisode supprimé du disque est retiré de la médiathèque FanKarr au scan suivant."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="settings.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Retire automatiquement le torrent du client une fois le fichier déplacé."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
label="Fichiers NFO et images"
|
||||
description="Télécharge NFO et visuels à côté de chaque épisode lors de l'import."
|
||||
/>
|
||||
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-lg border border-border bg-shell/50 text-xs text-muted leading-relaxed">
|
||||
<Info :size="13" class="shrink-0 mt-0.5" />
|
||||
<p>
|
||||
Inutile si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span> :
|
||||
il gère déjà les métadonnées et les NFO peuvent créer des conflits.
|
||||
À activer pour <span class="text-primary">Infuse</span> ou un lecteur qui lit les NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-400" role="alert">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { Info } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import { postSettings, type SetupSettings } from './setup'
|
||||
|
||||
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const form = reactive({
|
||||
autoImport : props.settings.autoImport,
|
||||
englishDirectory : props.settings.englishDirectory,
|
||||
autoUnimportMissing: props.settings.autoUnimportMissing,
|
||||
deleteTorrentOnMove: props.settings.deleteTorrentOnMove,
|
||||
nfoSupport : props.settings.nfoSupport,
|
||||
})
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
error.value = null
|
||||
if (await postSettings(form)) return true
|
||||
error.value = "Erreur lors de l'enregistrement."
|
||||
return false
|
||||
}
|
||||
|
||||
defineExpose({ submit })
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Jellyfin -->
|
||||
<section class="settings-card flex flex-col gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<Server :size="17" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium flex items-center gap-2">
|
||||
Jellyfin
|
||||
<span v-if="jellyfin?.test?.ok" class="text-[11px] text-green-400 font-normal">connecté</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-0.5 leading-relaxed">
|
||||
Permet à vos utilisateurs Jellyfin de se connecter à FanKarr et de faire des demandes depuis le plugin FanKarr Search.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary shrink-0 text-xs">
|
||||
{{ jellyfinOpen ? 'Replier' : 'Configurer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="jellyfinOpen">
|
||||
<JellyfinConnectionForm @change="jellyfin = $event" />
|
||||
|
||||
<div v-if="jellyfin?.test?.ok" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="syncUsers" :disabled="syncing" class="btn-secondary text-xs">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
|
||||
</button>
|
||||
<span v-if="syncResult" class="text-xs text-green-400">
|
||||
{{ syncResult.created }} créé{{ syncResult.created > 1 ? 's' : '' }} · {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-if="syncError" class="text-xs text-red-400">{{ syncError }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">Crée un compte FanKarr pour chaque utilisateur Jellyfin. La synchronisation se relance ensuite toutes les heures.</p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Plex -->
|
||||
<section class="settings-card flex flex-col gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="w-9 h-9 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<Tv :size="17" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium flex items-center gap-2">
|
||||
Plex
|
||||
<span v-if="context.plexOpened" class="text-[11px] text-muted font-normal">assistant ouvert</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-0.5 leading-relaxed">
|
||||
Crée une bibliothèque Plex sur votre médiathèque, reliée à l'agent de métadonnées Fankai.
|
||||
Requiert Plex Media Server 1.43 ou plus récent pour la configuration automatique.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary shrink-0 text-xs">
|
||||
Lancer l'assistant Plex
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!settings.mediaPath" class="text-xs text-yellow-500">Renseignez d'abord la médiathèque à l'étape Dossiers.</p>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<PlexWizard v-if="plexOpen" :media-path="settings.mediaPath" @close="plexOpen = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue'
|
||||
import { Server, Tv } from 'lucide-vue-next'
|
||||
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
|
||||
import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
import { setupContextKey, type SetupSettings } from './setup'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const context = inject(setupContextKey)!
|
||||
|
||||
const jellyfinOpen = ref(false)
|
||||
const jellyfin = ref<{ jellyfinUrl: string; hasToken: boolean; test: { ok: boolean } | null } | null>(null)
|
||||
const syncing = ref(false)
|
||||
const syncResult = ref<{ created: number; skipped: number } | null>(null)
|
||||
const syncError = ref<string | null>(null)
|
||||
const plexOpen = ref(false)
|
||||
|
||||
function openPlex() {
|
||||
plexOpen.value = true
|
||||
context.plexOpened = true
|
||||
}
|
||||
|
||||
async function syncUsers() {
|
||||
syncing.value = true
|
||||
syncError.value = null
|
||||
syncResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (res.ok) syncResult.value = data
|
||||
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
|
||||
} catch {
|
||||
syncError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div v-for="field in fields" :key="field.key">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label :for="`setup-${field.key}`" class="settings-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
</label>
|
||||
<span v-if="!field.required" class="text-[10px] text-muted border border-border px-1.5 py-0.5 rounded">recommandé</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:id="`setup-${field.key}`"
|
||||
v-model.trim="form[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
class="settings-input font-mono"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button @click="openPicker(field.key)" class="btn-secondary shrink-0 flex items-center gap-1.5" :title="`Parcourir — ${field.label}`">
|
||||
<FolderOpen :size="14" /> <span class="hidden sm:inline">Parcourir</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="checking && form[field.key]" class="text-xs text-muted mt-1.5">Vérification…</p>
|
||||
<p v-else-if="statusOf(field.key)?.error" class="text-xs text-red-400 mt-1.5 flex items-center gap-1.5">
|
||||
<X :size="12" class="shrink-0" /> {{ statusOf(field.key)?.error }}
|
||||
</p>
|
||||
<p v-else-if="statusOf(field.key)?.writable" class="text-xs text-green-400 mt-1.5 flex items-center gap-1.5">
|
||||
<Check :size="12" class="shrink-0" /> Dossier accessible en écriture
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">{{ field.help }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="result?.relation === 'same'" class="text-xs text-red-400 flex items-start gap-1.5">
|
||||
<X :size="12" class="shrink-0 mt-0.5" /> La médiathèque doit être distincte du dossier de téléchargements.
|
||||
</p>
|
||||
<p v-else-if="result?.relation === 'nested'" class="text-xs text-yellow-500 flex items-start gap-1.5">
|
||||
<TriangleAlert :size="12" class="shrink-0 mt-0.5" />
|
||||
Un dossier est à l'intérieur de l'autre : ça fonctionne, mais le scan de la médiathèque verra aussi les téléchargements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode d'import -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
@click="form.organizeMode = mode.id"
|
||||
:aria-pressed="form.organizeMode === mode.id"
|
||||
class="p-3 rounded-lg border text-left transition-colors"
|
||||
:class="form.organizeMode === mode.id ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
|
||||
>
|
||||
<p class="text-sm font-medium" :class="form.organizeMode === mode.id ? 'text-accent' : 'text-primary'">
|
||||
{{ mode.label }}
|
||||
<span v-if="mode.id === 'hardlink'" class="text-[10px] font-normal text-muted ml-1">recommandé</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-1 leading-relaxed">{{ mode.text }}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Résultat du test hardlink -->
|
||||
<div v-if="result && form.organizeMode === 'hardlink'" class="text-xs">
|
||||
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-green-400 flex items-center gap-1.5">
|
||||
<Check :size="12" class="shrink-0" /> {{ result.hardlink.message }}
|
||||
</p>
|
||||
<div v-else-if="result.hardlink.tested" class="flex flex-col gap-2 px-3 py-2.5 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<p class="text-yellow-500 flex items-start gap-1.5">
|
||||
<TriangleAlert :size="12" class="shrink-0 mt-0.5" /> {{ result.hardlink.message }}
|
||||
</p>
|
||||
<p class="text-muted">Sans hardlink, FanKarr copiera les fichiers (espace disque doublé).</p>
|
||||
<button @click="form.organizeMode = 'copy'" class="btn-secondary text-xs self-start">Passer en mode Copier</button>
|
||||
</div>
|
||||
<p v-else class="text-muted">{{ result.hardlink.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-400" role="alert">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.initialPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, onMounted } from 'vue'
|
||||
import { Check, FolderOpen, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import { checkPaths, postSettings, type PathsCheckResult, type SetupSettings } from './setup'
|
||||
|
||||
type PathKey = 'completePath' | 'mediaPath'
|
||||
|
||||
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const fields: { key: PathKey; label: string; required: boolean; placeholder: string; help: string }[] = [
|
||||
{
|
||||
key: 'completePath', label: 'Dossier des téléchargements terminés', required: false,
|
||||
placeholder: props.isDocker ? '/data/downloads' : 'D:/Torrents/complete',
|
||||
help: 'Là où votre client torrent dépose les fichiers terminés. Sert aussi à tester les hardlinks.',
|
||||
},
|
||||
{
|
||||
key: 'mediaPath', label: 'Médiathèque Fankai', required: true,
|
||||
placeholder: props.isDocker ? '/data/medias/kai' : 'D:/Medias/Fankai',
|
||||
help: 'Dossier racine où FanKarr range les séries, à ajouter ensuite dans Jellyfin ou Plex.',
|
||||
},
|
||||
]
|
||||
|
||||
const modes = [
|
||||
{ id: 'hardlink' as const, label: 'Hardlink', text: 'Aucune copie : le fichier reste en partage dans le client torrent.' },
|
||||
{ id: 'copy' as const, label: 'Copier', text: 'Duplique le fichier. Fonctionne partout, prend deux fois la place.' },
|
||||
{ id: 'move' as const, label: 'Déplacer', text: 'Retire le fichier du client : le partage s\'arrête.' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
completePath: props.settings.completePath,
|
||||
mediaPath : props.settings.mediaPath,
|
||||
organizeMode: props.settings.organizeMode,
|
||||
})
|
||||
|
||||
const result = ref<PathsCheckResult | null>(null)
|
||||
const checking = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const picker = ref<{ open: boolean; field: PathKey; initialPath: string }>({ open: false, field: 'mediaPath', initialPath: '/' })
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestId = 0
|
||||
|
||||
function statusOf(key: PathKey) {
|
||||
if (!form[key] || !result.value) return null
|
||||
return key === 'mediaPath' ? result.value.mediaPath : result.value.completePath
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
const id = ++requestId
|
||||
if (!form.mediaPath && !form.completePath) { result.value = null; return result.value }
|
||||
checking.value = true
|
||||
const res = await checkPaths(form.mediaPath, form.completePath)
|
||||
if (id === requestId) {
|
||||
result.value = res
|
||||
checking.value = false
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
watch(() => [form.mediaPath, form.completePath], () => {
|
||||
error.value = null
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(runCheck, 400)
|
||||
})
|
||||
|
||||
function openPicker(field: PathKey) {
|
||||
picker.value = { open: true, field, initialPath: form[field] || props.defaultPath }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
form[picker.value.field] = path
|
||||
picker.value.open = false
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
error.value = null
|
||||
if (!form.mediaPath) { error.value = 'Choisissez le dossier de la médiathèque pour continuer.'; return false }
|
||||
|
||||
clearTimeout(timer)
|
||||
const res = await runCheck()
|
||||
if (!res) { error.value = 'Impossible de vérifier les dossiers.'; return false }
|
||||
if (!res.ok) { error.value = 'Corrigez les dossiers signalés avant de continuer.'; return false }
|
||||
|
||||
if (!(await postSettings({ ...form, mediaPath: res.mediaPath.resolved, completePath: res.completePath?.resolved ?? '' }))) {
|
||||
error.value = "Erreur lors de l'enregistrement."
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
onMounted(() => { if (form.mediaPath || form.completePath) runCheck() })
|
||||
|
||||
defineExpose({ submit })
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<ul class="settings-card p-0 divide-y divide-border">
|
||||
<li v-for="row in rows" :key="row.label" class="flex items-center gap-3 px-4 py-3">
|
||||
<span class="w-6 h-6 rounded-full flex items-center justify-center shrink-0" :class="stateClass[row.state]">
|
||||
<span v-if="row.state === 'loading'" class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<Check v-else-if="row.state === 'ok'" :size="13" />
|
||||
<TriangleAlert v-else-if="row.state === 'warn'" :size="12" />
|
||||
<X v-else-if="row.state === 'error'" :size="13" />
|
||||
<Minus v-else :size="13" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary">{{ row.label }}</p>
|
||||
<p class="text-xs text-muted truncate" :title="row.value">{{ row.value }}</p>
|
||||
</div>
|
||||
<button @click="context.goto(row.step)" class="btn-ghost text-xs shrink-0">Modifier</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary flex items-center justify-center gap-1.5">
|
||||
<Compass :size="15" />
|
||||
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
|
||||
</button>
|
||||
<button @click="done(false)" :disabled="finishing || !settings.mediaPath" class="btn-secondary">
|
||||
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!context.relaunch" class="text-xs text-muted -mt-3">
|
||||
La visite guidée présente chaque écran en une minute. Vous pourrez la relancer depuis le bouton « ? » de la barre latérale.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, inject, onMounted } from 'vue'
|
||||
import { Check, Compass, Minus, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { checkPaths, setupContextKey, type SetupSettings, type SetupStepId } from './setup'
|
||||
|
||||
type RowState = 'loading' | 'ok' | 'warn' | 'error' | 'neutral'
|
||||
interface Row { label: string; value: string; state: RowState; step: SetupStepId }
|
||||
|
||||
const props = defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const context = inject(setupContextKey)!
|
||||
const finishing = ref(false)
|
||||
|
||||
const stateClass: Record<RowState, string> = {
|
||||
loading: 'bg-hover',
|
||||
ok : 'bg-green-500/15 text-green-400',
|
||||
warn : 'bg-yellow-500/15 text-yellow-500',
|
||||
error : 'bg-red-500/15 text-red-400',
|
||||
neutral: 'bg-hover text-muted',
|
||||
}
|
||||
|
||||
const modeLabel = { hardlink: 'Hardlink', copy: 'Copie', move: 'Déplacement' }
|
||||
|
||||
const row = (label: string, step: SetupStepId, value = '…', state: RowState = 'loading'): Row => ({ label, step, value, state })
|
||||
|
||||
const rows = reactive({
|
||||
paths : row('Dossiers', 'paths'),
|
||||
mode : row("Mode d'import", 'paths'),
|
||||
client : row('Client torrent', 'client'),
|
||||
import : row('Import automatique', 'import',
|
||||
props.settings.autoImport ? 'Activé' : 'Désactivé : import manuel depuis Activité',
|
||||
props.settings.autoImport ? 'ok' : 'neutral'),
|
||||
nfo : row('NFO et images', 'import', props.settings.nfoSupport ? 'Activés' : 'Désactivés', 'neutral'),
|
||||
jellyfin: row('Jellyfin', 'media-server'),
|
||||
plex : row('Plex', 'media-server', context.plexOpened ? 'Assistant Plex lancé' : 'Non configuré', 'neutral'),
|
||||
catalog : row('Catalogue Fankai', 'catalog'),
|
||||
})
|
||||
|
||||
function set(target: Row, value: string, state: RowState) {
|
||||
target.value = value
|
||||
target.state = state
|
||||
}
|
||||
|
||||
async function getJson(url: string, init?: RequestInit) {
|
||||
const res = await fetch(url, { credentials: 'include', ...init })
|
||||
if (!res.ok) throw new Error(String(res.status))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function loadPaths() {
|
||||
const s = props.settings
|
||||
const res = s.mediaPath ? await checkPaths(s.mediaPath, s.completePath) : null
|
||||
if (!res) {
|
||||
set(rows.paths, 'Médiathèque non configurée', 'error')
|
||||
} else {
|
||||
set(rows.paths,
|
||||
`${s.mediaPath}${s.completePath ? ` ← ${s.completePath}` : ' · téléchargements non renseignés'}`,
|
||||
!res.ok ? 'error' : s.completePath ? 'ok' : 'warn')
|
||||
}
|
||||
|
||||
const h = res?.hardlink
|
||||
if (s.organizeMode !== 'hardlink') set(rows.mode, modeLabel[s.organizeMode], 'ok')
|
||||
else if (h?.tested && h.ok) set(rows.mode, 'Hardlink · testé avec succès', 'ok')
|
||||
else if (h?.tested) set(rows.mode, `Hardlink · ${h.message}`, 'warn')
|
||||
else set(rows.mode, 'Hardlink · non testé', 'neutral')
|
||||
}
|
||||
|
||||
async function loadClients() {
|
||||
try {
|
||||
const clients: { uuid: string; name: string }[] = await getJson('/api/torrent-clients')
|
||||
if (clients.length === 0) {
|
||||
set(rows.client, 'Aucun client : les téléchargements sont impossibles', 'warn')
|
||||
return
|
||||
}
|
||||
const online = await Promise.all(clients.map(c =>
|
||||
getJson(`/api/torrent-clients/${c.uuid}/healthcheck`).then(d => !!d.online).catch(() => false)
|
||||
))
|
||||
const up = online.filter(Boolean).length
|
||||
set(rows.client,
|
||||
`${clients.map(c => c.name).join(', ')} · ${up}/${clients.length} joignable${clients.length > 1 ? 's' : ''}`,
|
||||
up === clients.length ? 'ok' : 'warn')
|
||||
} catch {
|
||||
set(rows.client, 'Impossible de vérifier', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadJellyfin() {
|
||||
try {
|
||||
const { jellyfinUrl, hasToken } = await getJson('/api/jellyfin/settings')
|
||||
if (!jellyfinUrl || !hasToken) {
|
||||
set(rows.jellyfin, 'Non configuré', 'neutral')
|
||||
return
|
||||
}
|
||||
const test = await getJson('/api/jellyfin/test', { method: 'POST' })
|
||||
if (test.ok) set(rows.jellyfin, `${jellyfinUrl} · Jellyfin ${test.version ?? ''}`.trim(), 'ok')
|
||||
else set(rows.jellyfin, `${jellyfinUrl} · ${test.error ?? 'connexion impossible'}`, 'error')
|
||||
} catch {
|
||||
set(rows.jellyfin, 'Impossible de vérifier', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
const { count, empty } = await getJson('/api/torrents/status')
|
||||
if (empty) set(rows.catalog, 'Catalogue vide', 'warn')
|
||||
else set(rows.catalog, `${count} séries disponibles`, 'ok')
|
||||
} catch {
|
||||
set(rows.catalog, 'Impossible de vérifier', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
async function done(withTour: boolean) {
|
||||
finishing.value = true
|
||||
try { await context.finish(withTour) }
|
||||
finally { finishing.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPaths()
|
||||
loadClients()
|
||||
loadJellyfin()
|
||||
loadCatalog()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- Fonctionnement -->
|
||||
<ol class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<li v-for="(item, i) in flow" :key="item.title" class="settings-card flex gap-3">
|
||||
<span class="w-8 h-8 rounded-lg bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<component :is="item.icon" :size="16" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-primary font-medium">{{ i + 1 }}. {{ item.title }}</p>
|
||||
<p class="text-xs text-muted mt-0.5 leading-relaxed">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Ce qu'on va configurer -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<p class="settings-label">Au programme</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li v-for="item in checklist" :key="item.label" class="flex items-start gap-2.5 text-sm">
|
||||
<Check :size="14" class="text-accent mt-0.5 shrink-0" />
|
||||
<span class="text-secondary">
|
||||
{{ item.label }}
|
||||
<span v-if="item.optional" class="text-muted text-xs">(optionnel)</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-xs text-muted">Comptez environ 5 minutes.</p>
|
||||
</div>
|
||||
|
||||
<!-- Docker -->
|
||||
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-accent/30 bg-accent-muted">
|
||||
<HardDrive :size="16" class="text-accent mt-0.5 shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-sm text-primary font-medium">FanKarr tourne dans Docker</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Les chemins à renseigner sont ceux <span class="text-primary">vus depuis le conteneur</span>.
|
||||
Pour profiter des hardlinks, montez un seul volume parent qui contient à la fois les téléchargements
|
||||
et la médiathèque (ex. <code class="font-mono text-secondary">/data/downloads</code> et
|
||||
<code class="font-mono text-secondary">/data/medias</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Download, FolderOpen, HardDrive, Library, Tv } from 'lucide-vue-next'
|
||||
import type { SetupSettings } from './setup'
|
||||
|
||||
defineProps<{ settings: SetupSettings; isDocker: boolean; defaultPath: string }>()
|
||||
|
||||
const flow = [
|
||||
{ icon: Library, title: 'Catalogue', text: 'Toutes les séries Fankai, avec leurs torrents par épisode, saison ou intégrale.' },
|
||||
{ icon: Download, title: 'Téléchargement', text: 'Un clic envoie le torrent à votre client (qBittorrent, Transmission…).' },
|
||||
{ icon: FolderOpen, title: 'Import', text: 'Une fois terminé, le fichier est renommé et rangé par série et saison.' },
|
||||
{ icon: Tv, title: 'Lecture', text: 'Jellyfin ou Plex retrouvent vos séries avec les métadonnées Fankai.' },
|
||||
]
|
||||
|
||||
const checklist = [
|
||||
{ label: 'Dossier de téléchargements et médiathèque' },
|
||||
{ label: 'Client torrent' },
|
||||
{ label: 'Import automatique et nommage' },
|
||||
{ label: 'Jellyfin ou Plex', optional: true },
|
||||
{ label: 'Téléchargement du catalogue' },
|
||||
]
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export type SetupStepId = 'welcome' | 'paths' | 'client' | 'import' | 'media-server' | 'catalog' | 'recap'
|
||||
|
||||
export interface SetupSettings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'copy' | 'move'
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
deleteTorrentOnMove: boolean
|
||||
autoUnimportMissing: boolean
|
||||
englishDirectory : boolean
|
||||
}
|
||||
|
||||
export interface PathCheck {
|
||||
input : string
|
||||
resolved : string
|
||||
exists : boolean
|
||||
isDirectory: boolean
|
||||
writable : boolean
|
||||
error? : string
|
||||
}
|
||||
|
||||
export interface PathsCheckResult {
|
||||
ok : boolean
|
||||
mediaPath : PathCheck
|
||||
completePath: PathCheck | null
|
||||
relation : 'distinct' | 'same' | 'nested' | null
|
||||
hardlink : { tested: boolean; ok: boolean; code?: string; message: string }
|
||||
}
|
||||
|
||||
export interface SetupContext {
|
||||
relaunch : boolean
|
||||
clientSkipped: boolean
|
||||
plexOpened : boolean
|
||||
goto : (id: SetupStepId) => void
|
||||
finish : (withTour: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const setupContextKey: InjectionKey<SetupContext> = Symbol('setup-context')
|
||||
|
||||
export async function postSettings(patch: Partial<SetupSettings>): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPaths(mediaPath: string, completePath: string): Promise<PathsCheckResult | null> {
|
||||
try {
|
||||
const res = await fetch('/api/system/check-paths', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ mediaPath, completePath }),
|
||||
})
|
||||
return res.ok ? await res.json() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -31,20 +31,37 @@
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<TourOverlay />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import TourOverlay from '@/components/tour/TourOverlay.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import type { NavItem, NavChild } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const dlStore = useDownloadsStore()
|
||||
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 items: NavItem[] = [
|
||||
@@ -52,11 +69,13 @@ const navItems = computed<NavItem[]>(() => {
|
||||
label: 'Dashboard',
|
||||
icon : 'LayoutDashboard',
|
||||
to : '/dashboard',
|
||||
tour : 'nav-dashboard',
|
||||
},
|
||||
{
|
||||
label : 'Médiathèque',
|
||||
icon : 'Tv',
|
||||
to : '/series',
|
||||
tour : 'nav-series',
|
||||
children: [
|
||||
{ label: 'Séries', to: '/series' },
|
||||
],
|
||||
@@ -65,6 +84,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
label: 'Demandes',
|
||||
icon : 'ClipboardList',
|
||||
to : '/requests',
|
||||
tour : 'nav-requests',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -73,6 +93,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
label: 'Activité',
|
||||
icon : 'Activity',
|
||||
to : '/activity',
|
||||
tour : 'nav-activity',
|
||||
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
|
||||
})
|
||||
}
|
||||
@@ -99,6 +120,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
label : 'Paramètres',
|
||||
icon : 'Settings',
|
||||
to : '/settings',
|
||||
tour : 'nav-settings',
|
||||
children: settingsChildren,
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/InviteView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('@/views/SetupView.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
|
||||
// ─── App principale avec layout sidebar ──────────────────
|
||||
{
|
||||
@@ -127,6 +133,7 @@ const router = createRouter({
|
||||
|
||||
scrollBehavior(to, from, 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
|
||||
if (to.name === 'series' && from.name === 'serie-detail') return false
|
||||
return { el: '#main-scroll', top: 0 }
|
||||
@@ -147,6 +154,10 @@ router.beforeEach(async (to) => {
|
||||
|
||||
// Redirige les non-admins hors des pages admin
|
||||
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
|
||||
+31
-5
@@ -9,6 +9,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const username = ref<string | null>(null)
|
||||
const userId = ref<string | null>(null)
|
||||
|
||||
const onboardingDone = ref(true)
|
||||
const tourSeen = ref(true)
|
||||
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
|
||||
async function checkStatus() {
|
||||
@@ -20,6 +23,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
role.value = data.role ?? null
|
||||
username.value = data.username ?? null
|
||||
userId.value = data.userId ?? null
|
||||
onboardingDone.value = data.onboardingDone ?? true
|
||||
tourSeen.value = data.tourSeen ?? true
|
||||
} catch {
|
||||
loggedIn.value = false
|
||||
} finally {
|
||||
@@ -35,10 +40,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
loggedIn.value = true
|
||||
role.value = data.user?.role ?? null
|
||||
username.value = data.user?.username ?? null
|
||||
userId.value = data.user?.id ?? null
|
||||
await checkStatus()
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
@@ -57,6 +59,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
role.value = data.user?.role ?? 'admin'
|
||||
username.value = data.user?.username ?? u
|
||||
userId.value = data.user?.id ?? null
|
||||
onboardingDone.value = false
|
||||
tourSeen.value = false
|
||||
return null
|
||||
}
|
||||
return data.error
|
||||
@@ -68,7 +72,29 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
role.value = null
|
||||
username.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,
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
@@ -9,5 +9,6 @@ export interface NavItem {
|
||||
to? : string
|
||||
children? : NavChild[]
|
||||
separator?: true
|
||||
badge? : string | number // ← badge optionnel
|
||||
badge? : string | number
|
||||
tour? : string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@
|
||||
|
||||
<!-- Logo -->
|
||||
<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">
|
||||
<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>
|
||||
<FankarrLogo class="w-8 h-8" />
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +49,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -67,6 +65,7 @@ const isSetup = computed(() => !auth.setup)
|
||||
async function submit() {
|
||||
error.value = null
|
||||
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 }
|
||||
|
||||
submitting.value = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- 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">
|
||||
<span class="text-xs text-muted">Catalogue</span>
|
||||
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h2 class="text-sm font-medium text-secondary">
|
||||
{{ auth.isAdmin ? 'Demandes en attente' : 'Mes demandes' }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- 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">
|
||||
<h1 class="text-lg font-semibold text-primary">Activité</h1>
|
||||
<div class="flex items-center gap-1.5">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<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>
|
||||
<h1 class="text-lg font-semibold text-primary">Demandes</h1>
|
||||
<p class="text-sm text-muted mt-0.5">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
/>
|
||||
|
||||
<!-- 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 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))` }">
|
||||
<component
|
||||
:is="selecting ? 'div' : RouterLink"
|
||||
v-for="serie in filtered"
|
||||
v-for="(serie, i) in filtered"
|
||||
:key="serie.id"
|
||||
:data-tour="i === 0 ? 'series-poster' : undefined"
|
||||
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="{
|
||||
|
||||
@@ -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,12 +16,28 @@
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
|
||||
const tour = useTourStore()
|
||||
|
||||
const devMode = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
@@ -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="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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="settings-label">Nom</label>
|
||||
<input v-model="modal.name" placeholder="Mon client torrent" class="settings-input" />
|
||||
</div>
|
||||
<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>
|
||||
<TorrentClientForm
|
||||
:key="modal.key"
|
||||
:client="modal.client"
|
||||
:definitions="availableClients"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,15 +137,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const clients = ref<SavedTorrentClient[]>([])
|
||||
const availableClients = ref<TorrentClientDefinition[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const clientMatrix = [
|
||||
@@ -276,48 +175,7 @@ const clientMatrix = [
|
||||
}
|
||||
]
|
||||
|
||||
const modal = ref({
|
||||
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)) ?? []
|
||||
)
|
||||
const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
|
||||
|
||||
function clientLabel(type: string) {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
@@ -362,110 +220,23 @@ async function deleteClient(uuid: string) {
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
advancedOpen.value = false
|
||||
modal.value = { open: true, key: modal.value.key + 1, client: null }
|
||||
}
|
||||
|
||||
function editClient(client: any) {
|
||||
modal.value = {
|
||||
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 editClient(client: SavedTorrentClient) {
|
||||
modal.value = { open: true, key: modal.value.key + 1, client }
|
||||
}
|
||||
|
||||
function closeModal() { modal.value.open = false }
|
||||
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
advancedOpen.value = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
if (field.default === undefined) continue
|
||||
modal.value.config[field.key] = field.type === 'boolean' ? field.default : String(field.default)
|
||||
}
|
||||
function onSaved(client: SavedTorrentClient, isEdit: boolean) {
|
||||
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()
|
||||
}
|
||||
|
||||
// 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>
|
||||
</script>
|
||||
@@ -11,34 +11,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
|
||||
</section>
|
||||
|
||||
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
|
||||
@@ -102,15 +75,10 @@
|
||||
</template>
|
||||
|
||||
<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 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 syncResult = ref<{ created: number; skipped: number; users: 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'
|
||||
}
|
||||
|
||||
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() {
|
||||
syncing.value = true
|
||||
syncError.value = null
|
||||
@@ -175,7 +105,4 @@ async function syncUsers() {
|
||||
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
|
||||
syncing.value = false
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@
|
||||
</section>
|
||||
|
||||
<!-- ── Token API personnel ──────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<section data-tour="profile-token" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
|
||||
Reference in new issue
Block a user