feat(ui): refonte de l'interface
This commit is contained in:
1 parent
3ede3e808c
commit
09eb1fa3aa
79 files changed
+5323
-3514
No files matched your search
+5
-2
@@ -1,9 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=DM+Serif+Display&display=swap" rel="stylesheet">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;600;700;800&family=Zen+Kaku+Gothic+New:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#13100c" />
|
||||
<title>Fankarr</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ import { DATA_DIR } from './config.js'
|
||||
import { logger } from './logger.js'
|
||||
import {
|
||||
findByUsername, findById, findByApiToken,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen,
|
||||
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
|
||||
type User,
|
||||
} from './users.js'
|
||||
import { readSettings } from './settings.js'
|
||||
@@ -34,6 +34,7 @@ function signToken(user: User): string {
|
||||
}
|
||||
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
|
||||
const token = signToken(user)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
|
||||
+7
-4
@@ -22,6 +22,7 @@ import { readAvailable, readInfohashMap } from './lib/github-cache.js'
|
||||
import { loadCatalog } from './lib/serie-helpers.js'
|
||||
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
|
||||
import { pushNotif } from './lib/notifs.js'
|
||||
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
|
||||
import { readRequests, completeRequest } from './requests.js'
|
||||
import { checkNfoUpdates } from './lib/nfo.js'
|
||||
import { runJellyfinSync } from './routes/jellyfin.js'
|
||||
@@ -96,7 +97,7 @@ const ADMIN_PREFIXES = [
|
||||
'/users', '/jellyfin', '/settings', '/torrent-clients',
|
||||
'/downloads', '/download', '/organize', '/import',
|
||||
'/system', '/nfo-updates', '/plex', '/rss-sync',
|
||||
'/torrent', '/manual-import', '/organized', '/organized-summary',
|
||||
'/torrent', '/manual-import', '/organized', '/organized-summary', '/organized-folders',
|
||||
'/rename-episode', '/rename-all', '/purge-nfo',
|
||||
'/logs', '/browse', '/browse-files', '/update', '/scan', '/debug',
|
||||
]
|
||||
@@ -119,10 +120,9 @@ app.use('/api', systemRouter)
|
||||
app.use('/api', nfoUpdatesRouter)
|
||||
app.use('/api', plexRouter)
|
||||
app.use('/api', rssSyncRouter)
|
||||
app.use('/api', invitesRouter)
|
||||
app.use('/api', requireAuth, seriesRouter)
|
||||
app.use('/api', requireAuth, requestsRouter)
|
||||
// Invites : mix public + admin (le router gère ses propres middlewares)
|
||||
app.use('/api', invitesRouter)
|
||||
|
||||
// ── Catch-all SPA ──────────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
@@ -188,6 +188,8 @@ server.listen(PORT, async () => {
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
const autoOrganize = async () => {
|
||||
autoImportSchedule.lastRunAt = new Date().toISOString()
|
||||
planNextAutoImport()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
@@ -230,9 +232,10 @@ server.listen(PORT, async () => {
|
||||
}
|
||||
}
|
||||
|
||||
planNextAutoImport(10_000)
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
setInterval(autoOrganize, AUTO_IMPORT_INTERVAL_MS)
|
||||
}, 10_000)
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ const GITHUB_BASE =
|
||||
|
||||
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
interface CacheEntry<T> { data: T; expiresAt: number; fetchedAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
export function cacheGet<T>(key: string): T | null {
|
||||
@@ -19,7 +19,13 @@ export function cacheGet<T>(key: string): T | null {
|
||||
}
|
||||
|
||||
export function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
|
||||
}
|
||||
|
||||
// Date de la dernière lecture d'une clé
|
||||
export function cacheFetchedAt(key: string): string | null {
|
||||
const entry = _cache.get(key)
|
||||
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
|
||||
}
|
||||
|
||||
export function cacheClear(): void { _cache.clear() }
|
||||
|
||||
+46
-4
@@ -1,12 +1,54 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
export interface OrganizeNotif {
|
||||
hash: string; name: string; done: number; skipped: number; errors: number
|
||||
hash: string; name: string; serieId?: number | null; done: number; skipped: number; errors: number
|
||||
errorFiles: { file: string; error: string }[]; at: string
|
||||
}
|
||||
|
||||
export const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
export interface RecentSerieImport { serieId: number; episodes: number; at: string }
|
||||
|
||||
const HISTORY_PATH = path.join(DATA_DIR, 'import-history.json')
|
||||
const MAX_HISTORY = 200
|
||||
const RECENT_WINDOW_MS = 7 * 24 * 60 * 60_000
|
||||
|
||||
function loadHistory(): OrganizeNotif[] {
|
||||
try {
|
||||
if (!fs.existsSync(HISTORY_PATH)) return []
|
||||
const data = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8'))
|
||||
return Array.isArray(data) ? data.slice(0, MAX_HISTORY) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function saveHistory(): void {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true })
|
||||
fs.writeFileSync(HISTORY_PATH, JSON.stringify(recentOrganized, null, 2))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Du plus récent au plus ancien
|
||||
export const recentOrganized: OrganizeNotif[] = loadHistory()
|
||||
|
||||
export function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
if (recentOrganized.length > MAX_HISTORY) recentOrganized.length = MAX_HISTORY
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
export function clearNotifs() {
|
||||
recentOrganized.length = 0
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
export function recentImportsBySerie(): RecentSerieImport[] {
|
||||
const bySerie = new Map<number, RecentSerieImport>()
|
||||
for (const n of recentOrganized) {
|
||||
if (n.serieId == null || n.done <= 0) continue
|
||||
const current = bySerie.get(n.serieId)
|
||||
if (!current) { bySerie.set(n.serieId, { serieId: n.serieId, episodes: n.done, at: n.at }); continue }
|
||||
if (Date.parse(current.at) - Date.parse(n.at) <= RECENT_WINDOW_MS) current.episodes += n.done
|
||||
}
|
||||
return [...bySerie.values()]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
|
||||
|
||||
/**
|
||||
* Torrents à envoyer pour une portée de demande.
|
||||
* episodes non vide → épisodes ciblés ; sinon seasons non vide → saisons ciblées ; sinon toute la série puis voila
|
||||
*/
|
||||
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
|
||||
const toDownload: RequestTorrent[] = []
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
const allPackTorrents: any[] = [
|
||||
...(serieData.torrents ?? []),
|
||||
...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
|
||||
]
|
||||
|
||||
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
|
||||
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
|
||||
if (seenHashes.has(key)) return
|
||||
seenHashes.add(key)
|
||||
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le premier torrent disponible par épisode
|
||||
*/
|
||||
function addEpisodesByIndex(epList: any[]) {
|
||||
for (const ep of epList) {
|
||||
if (ep.torrents?.length > 0) {
|
||||
// Premier torrent individuel disponible
|
||||
addTorrent(ep.torrents[0])
|
||||
} else if (ep.paths?.length > 0) {
|
||||
// Premier path dans un pack
|
||||
const p = ep.paths[0]
|
||||
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
|
||||
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.length > 0) {
|
||||
// ── Épisodes ciblés ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
|
||||
addEpisodesByIndex(targets)
|
||||
}
|
||||
} else if (seasons.length > 0) {
|
||||
// ── Saisons ciblées ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (!seasons.includes(season.season_number)) continue
|
||||
if ((season.torrents ?? []).length > 0) {
|
||||
// Premier pack saison disponible → télécharger en bloc
|
||||
addTorrent(season.torrents[0])
|
||||
} else {
|
||||
// Pas de pack → épisode par épisode avec file_index
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Toutes les saisons : intégrale en priorité ────────────
|
||||
if ((serieData.torrents ?? []).length > 0) {
|
||||
addTorrent(serieData.torrents[0])
|
||||
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
|
||||
// Packs saison (premier par saison)
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
|
||||
}
|
||||
} else {
|
||||
// Fallback : épisode par épisode avec file_index
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toDownload
|
||||
}
|
||||
|
||||
// Épisodes uniques concernés par la même portée
|
||||
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
|
||||
const keys = new Set<string>()
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const inScope = episodes.length > 0
|
||||
? episodes.includes(ep.id)
|
||||
: seasons.length === 0 || seasons.includes(season.season_number)
|
||||
if (inScope) keys.add(`${season.season_number}:${ep.episode_number}`)
|
||||
}
|
||||
}
|
||||
return keys.size
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Cadence de l'import automatique
|
||||
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
export const autoImportSchedule = {
|
||||
lastRunAt: null as string | null,
|
||||
nextRunAt: null as string | null,
|
||||
}
|
||||
|
||||
export function planNextAutoImport(delayMs = AUTO_IMPORT_INTERVAL_MS) {
|
||||
autoImportSchedule.nextRunAt = new Date(Date.now() + delayMs).toISOString()
|
||||
}
|
||||
@@ -212,12 +212,20 @@ export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: numb
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
export function computeSerieDownloadState(
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, any>>,
|
||||
activeTorrents: Set<string>,
|
||||
counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 },
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
if (extractTorrentsFromSerieData(serieData).some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
if (counts.total === 0 || counts.organized === 0) return 'none'
|
||||
if (counts.organized >= counts.total) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
|
||||
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
|
||||
// pour éviter de compter x264 + x265 comme deux épisodes distincts
|
||||
const uniqueEpKeys = new Set<string>()
|
||||
@@ -232,7 +240,7 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
|
||||
const organizedEpKeys = new Set<string>()
|
||||
|
||||
for (const t of allTorrents) {
|
||||
for (const t of extractTorrentsFromSerieData(serieData)) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
@@ -250,7 +258,5 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
|
||||
for (const [id, key] of epIdToKey)
|
||||
if (trackedIds.has(String(id))) organizedEpKeys.add(key)
|
||||
|
||||
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
|
||||
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
|
||||
return 'partial'
|
||||
return { total: uniqueEpKeys.size, organized: organizedEpKeys.size }
|
||||
}
|
||||
@@ -22,6 +22,7 @@ function resolveWorkerPath(): string {
|
||||
}
|
||||
|
||||
export interface OrganizeResult {
|
||||
serieId?: number | null
|
||||
total : number
|
||||
skipped : number
|
||||
done : number
|
||||
@@ -85,6 +86,8 @@ function swapExt(filename: string, ext: string): string {
|
||||
return filename.slice(0, -cur.length) + ext
|
||||
}
|
||||
|
||||
export let lastScan: { at: string; found: number; added: number } | null = null
|
||||
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
@@ -288,6 +291,7 @@ export async function scanMediaPath(
|
||||
writeOrganized(organized, organizedPath)
|
||||
}
|
||||
|
||||
lastScan = { at: new Date().toISOString(), ...result }
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés${autoRemoved > 0 ? `, ${autoRemoved} désimportés auto (fichier manquant)` : ''}${noMatch > 0 ? `, ${noMatch} non matchés` : ''}`)
|
||||
|
||||
return result
|
||||
@@ -637,6 +641,7 @@ export async function organizeTorrent(
|
||||
_pendingManual.delete(key)
|
||||
try {
|
||||
await runWorker([fakeTorrent], seriesData, msg => {
|
||||
result.serieId = msg.serieId ?? null
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
|
||||
@@ -170,6 +170,26 @@ export function deleteRequest(id: string): void {
|
||||
writeRequests(requests.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
/** Retire un utilisateur surune demande en attente et la demande disparaît s'il était seul demandeur */
|
||||
export function withdrawRequest(id: string, userId: string): { deleted: boolean; request: SerieRequest | null } {
|
||||
const requests = readRequests()
|
||||
const req = requests.find(r => r.id === id)
|
||||
if (!req || !req.requesters.some(r => r.userId === userId)) throw new Error('Demande introuvable')
|
||||
if (req.status !== 'pending') throw new Error('Seule une demande en attente peut être annulée')
|
||||
|
||||
const username = req.requesters.find(r => r.userId === userId)!.username
|
||||
req.requesters = req.requesters.filter(r => r.userId !== userId)
|
||||
if (req.requesters.length === 0) {
|
||||
writeRequests(requests.filter(r => r.id !== id))
|
||||
logger.info('requests', `Demande "${req.serieName}" annulée par "${username}"`)
|
||||
return { deleted: true, request: null }
|
||||
}
|
||||
req.updatedAt = new Date().toISOString()
|
||||
writeRequests(requests)
|
||||
logger.info('requests', `"${username}" retiré de la demande "${req.serieName}"`)
|
||||
return { deleted: false, request: req }
|
||||
}
|
||||
|
||||
export function getRequestsForUser(userId: string): SerieRequest[] {
|
||||
return readRequests().filter(r => r.requesters.some(req => req.userId === userId))
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
let serieName: string | undefined
|
||||
let serieId: number | undefined
|
||||
let episodes: any[] = []
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
@@ -61,6 +62,7 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||
totalFiles = resolved.length || 1
|
||||
serieName = sd.title ?? sd.show_title
|
||||
serieId = sd.id
|
||||
episodes = resolved
|
||||
break
|
||||
}
|
||||
@@ -68,8 +70,9 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const imported = recentOrganized.find(n => n.hash === t.hash && n.done > 0)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], importedAt: imported?.at ?? null, serieId, serieName, episodes }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
|
||||
@@ -531,6 +531,27 @@ router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
|
||||
})
|
||||
|
||||
// ── Dossier série ──────────────────────────────────────────────
|
||||
router.get('/organized-folders', requireAdmin, async (_req, res) => {
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath) { res.json([]); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
try {
|
||||
const result: { serie_id: number; serie_title: string; expected: string; current: string[] }[] = []
|
||||
for (const sd of await loadCatalog()) {
|
||||
const title = sd.title ?? sd.show_title ?? ''
|
||||
const expected = path.join(mediaPath, serieFolderName(title))
|
||||
const stale = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
|
||||
if (stale.length > 0) result.push({ serie_id: sd.id, serie_title: title, expected, current: stale })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `organized-folders échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId)
|
||||
|
||||
@@ -3,8 +3,10 @@ import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||
import { recentOrganized, pushNotif, clearNotifs } from '../lib/notifs.js'
|
||||
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule } from '../lib/schedule.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import path from 'path'
|
||||
|
||||
@@ -23,11 +25,15 @@ router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
|
||||
})
|
||||
|
||||
router.get('/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
res.json(recentOrganized.slice(0, 20))
|
||||
})
|
||||
|
||||
router.get('/organize/schedule', requireAuth, (_req, res) => {
|
||||
res.json({ autoImport: readSettings().autoImport, intervalMs: AUTO_IMPORT_INTERVAL_MS, ...autoImportSchedule })
|
||||
})
|
||||
|
||||
router.post('/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
clearNotifs()
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
@@ -45,7 +51,7 @@ router.post('/organize', requireAuth, async (req, res) => {
|
||||
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData, files)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
pushNotif({ hash, name, serieId: result.serieId ?? null, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
|
||||
+29
-72
@@ -2,25 +2,41 @@ import { Router } from 'express'
|
||||
import { requireAdmin } from '../auth.js'
|
||||
import {
|
||||
readRequests, upsertRequest, approveRequest,
|
||||
rejectRequest, completeRequest, deleteRequest,
|
||||
rejectRequest, completeRequest, deleteRequest, withdrawRequest,
|
||||
getRequestsForUser, getPendingCount, mergedSeasons, mergedEpisodes,
|
||||
type SerieRequest,
|
||||
} from '../requests.js'
|
||||
import { dispatchDownload } from '../torrent-clients/index.js'
|
||||
import { readSerieData } from '../lib/github-cache.js'
|
||||
import { resolveRequestTorrents, countRequestedEpisodes } from '../lib/request-scope.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// GET /api/requests — admin voit tout, user voit les siennes
|
||||
router.get('/requests', (req, res) => {
|
||||
router.get('/requests', async (req, res) => {
|
||||
const list = req.user!.role === 'admin'
|
||||
? readRequests()
|
||||
: getRequestsForUser(req.user!.id)
|
||||
res.json(list)
|
||||
res.json(await withScope(list))
|
||||
})
|
||||
|
||||
async function withScope(list: SerieRequest[]) {
|
||||
const serieData = new Map<number, Promise<any | null>>()
|
||||
return Promise.all(list.map(async r => {
|
||||
if (!serieData.has(r.serieId)) serieData.set(r.serieId, readSerieData(r.serieId))
|
||||
const sd = await serieData.get(r.serieId)
|
||||
const seasons = mergedSeasons(r)
|
||||
const episodes = mergedEpisodes(r)
|
||||
return {
|
||||
...r,
|
||||
episodeCount: sd ? countRequestedEpisodes(sd, seasons, episodes) : null,
|
||||
hasTorrents : !!sd && resolveRequestTorrents(sd, seasons, episodes).length > 0,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// GET /api/requests/pending-count — pour le widget dashboard
|
||||
router.get('/requests/pending-count', (req, res) => {
|
||||
const count = req.user!.role === 'admin'
|
||||
@@ -119,75 +135,7 @@ export async function autoDownloadRequest(req: SerieRequest, override?: { season
|
||||
const seasons = override !== undefined ? override.seasons : mergedSeasons(req)
|
||||
const episodes = override !== undefined ? override.episodes : mergedEpisodes(req)
|
||||
|
||||
type TorrentEntry = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
|
||||
const toDownload: TorrentEntry[] = []
|
||||
const seenHashes = new Set<string>()
|
||||
|
||||
// Tous les torrents pack (série + saisons) pour résoudre les ep.paths
|
||||
const allPackTorrents: any[] = [
|
||||
...(serieData.torrents ?? []),
|
||||
...(serieData.seasons ?? []).flatMap((s: any) => s.torrents ?? []),
|
||||
]
|
||||
|
||||
function addTorrent(t: any, file_index?: number | null, file_path?: string | null) {
|
||||
const key = `${t.infohash ?? t.magnet ?? t.torrent_url}:${file_index ?? ''}`
|
||||
if (seenHashes.has(key)) return
|
||||
seenHashes.add(key)
|
||||
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute le premier torrent disponible par épisode (individuel ou via file_index dans un pack).
|
||||
* On prend le premier uniquement pour éviter les doublons quand plusieurs sources existent.
|
||||
*/
|
||||
function addEpisodesByIndex(epList: any[]) {
|
||||
for (const ep of epList) {
|
||||
if (ep.torrents?.length > 0) {
|
||||
// Premier torrent individuel disponible
|
||||
addTorrent(ep.torrents[0])
|
||||
} else if (ep.paths?.length > 0) {
|
||||
// Premier path dans un pack
|
||||
const p = ep.paths[0]
|
||||
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
|
||||
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.length > 0) {
|
||||
// ── Épisodes ciblés ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
const targets = (season.episodes ?? []).filter((ep: any) => episodes.includes(ep.id))
|
||||
addEpisodesByIndex(targets)
|
||||
}
|
||||
} else if (seasons.length > 0) {
|
||||
// ── Saisons ciblées ───────────────────────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (!seasons.includes(season.season_number)) continue
|
||||
if ((season.torrents ?? []).length > 0) {
|
||||
// Premier pack saison disponible → télécharger en bloc
|
||||
addTorrent(season.torrents[0])
|
||||
} else {
|
||||
// Pas de pack → épisode par épisode avec file_index
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Toutes les saisons : intégrale en priorité ────────────
|
||||
if ((serieData.torrents ?? []).length > 0) {
|
||||
addTorrent(serieData.torrents[0])
|
||||
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
|
||||
// Packs saison (premier par saison)
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
|
||||
}
|
||||
} else {
|
||||
// Fallback : épisode par épisode avec file_index
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
addEpisodesByIndex(season.episodes ?? [])
|
||||
}
|
||||
}
|
||||
}
|
||||
const toDownload = resolveRequestTorrents(serieData, seasons, episodes)
|
||||
|
||||
if (toDownload.length === 0) {
|
||||
logger.warn('requests', `Auto-dl : aucun torrent trouvé pour "${req.serieName}"`)
|
||||
@@ -218,6 +166,15 @@ router.delete('/requests', requireAdmin, (_req, res) => {
|
||||
res.json({ deleted: all.length })
|
||||
})
|
||||
|
||||
// DELETE /api/requests/:id/mine — retire l'utilisateur d'une demande en attente
|
||||
router.delete('/requests/:id/mine', (req, res) => {
|
||||
try {
|
||||
res.json(withdrawRequest(String(req.params.id), req.user!.id))
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/requests/:id — admin seulement
|
||||
router.delete('/requests/:id', requireAdmin, (req, res) => {
|
||||
try {
|
||||
|
||||
+16
-2
@@ -10,12 +10,19 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac
|
||||
import { loadSynced } from '../lib/rss-sync.js'
|
||||
import {
|
||||
fankaiGet, fetchSerieFromApi, normalizeSerie, normalizeSeason, normalizeEpisode,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
|
||||
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState, countOrganizedEpisodes,
|
||||
deduplicateEpisodes, serieHasEpisodes,
|
||||
} from '../lib/serie-helpers.js'
|
||||
import { recentImportsBySerie } from '../lib/notifs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Séries récemment ajoutées à la médiathèque
|
||||
router.get('/library/recent', requireAuth, (req, res) => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 12, 1), 50)
|
||||
res.json(recentImportsBySerie().slice(0, limit))
|
||||
})
|
||||
|
||||
router.get('/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
|
||||
@@ -30,12 +37,19 @@ router.get('/series', requireAuth, async (_req, res) => {
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(seriesRaw.filter((s: any) => availableSet.has(s.id)).map(async (s: any) => { const sd = await readSerieData(s.id); if (sd) serieDataMap.set(s.id, sd) }))
|
||||
const lastImports = new Map(recentImportsBySerie().map(r => [r.serieId, r.at]))
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
const torrents = serieData ? extractTorrentsFromSerieData(serieData) : []
|
||||
const inClient = torrents.some(t => t.infohash && clientHashes.has(t.infohash.toLowerCase()))
|
||||
const hasFiles = (serieData?.seasons ?? []).some((s: any) => (s.episodes ?? []).some((ep: any) => organizedEpisodeIds.has(String(ep.id))))
|
||||
return { ...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0, download_state: computeSerieDownloadState(serieData, organized, activeTorrents), in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id] }
|
||||
const counts = serieData ? countOrganizedEpisodes(serieData, organized) : { total: 0, organized: 0 }
|
||||
return {
|
||||
...serie, torrent_count: torrents.length, has_torrents: torrents.length > 0,
|
||||
download_state: computeSerieDownloadState(serieData, organized, activeTorrents, counts),
|
||||
episode_count: counts.total, organized_count: counts.organized, last_imported_at: lastImports.get(serie.id) ?? null,
|
||||
in_client: inClient, has_files: hasFiles, rss_synced: !!synced[serie.id],
|
||||
}
|
||||
})})
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
|
||||
@@ -9,9 +9,9 @@ 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'
|
||||
import { cacheSize } from '../lib/github-cache.js'
|
||||
import { cacheSize, cacheFetchedAt } from '../lib/github-cache.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
import { workerRunning } from '../organize.js'
|
||||
import { workerRunning, lastScan } from '../organize.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -129,7 +129,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
router.get('/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0, syncedAt: cacheFetchedAt('available.json') })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
@@ -173,6 +173,10 @@ router.post('/update', requireAuth, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/scan', requireAuth, (_req, res) => {
|
||||
res.json({ lastScan })
|
||||
})
|
||||
|
||||
router.post('/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
@@ -188,8 +192,6 @@ router.post('/scan', requireAuth, async (_req, res) => {
|
||||
|
||||
// ── Debug ──────────────────────────────────────────────────────
|
||||
router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
@@ -205,7 +207,7 @@ router.get('/debug/stats', requireAuth, (req, res) => {
|
||||
} catch {}
|
||||
res.json({
|
||||
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
|
||||
cache : { entries: cacheSize(), ttlHours: 6 },
|
||||
cache : { entries: cacheSize(), ttlHours: 1 },
|
||||
uptime : `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker : { running: workerRunning },
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface User {
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
tourSeenAt? : string | null
|
||||
lastLoginAt?: string | null
|
||||
}
|
||||
|
||||
const USERS_PATH = path.join(DATA_DIR, 'users.json')
|
||||
@@ -144,6 +145,15 @@ export function changePassword(id: string, currentPassword: string, newPassword:
|
||||
logger.info('users', `Mot de passe changé pour "${user.username}"`)
|
||||
}
|
||||
|
||||
export function markLogin(id: string): string | null {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) return null
|
||||
user.lastLoginAt = new Date().toISOString()
|
||||
writeUsers(users)
|
||||
return user.lastLoginAt
|
||||
}
|
||||
|
||||
export function markTourSeen(id: string): string {
|
||||
const users = readUsers()
|
||||
const user = users.find(u => u.id === id)
|
||||
|
||||
+229
-88
@@ -1,18 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Tokens custom consommables via Tailwind v4 ─── */
|
||||
/* ─── Des polices un peu stylées si vous voulez mon avis ─── */
|
||||
@theme {
|
||||
--font-sans: 'Zen Kaku Gothic New', 'Yu Gothic', 'Hiragino Sans', system-ui, sans-serif;
|
||||
--font-display: 'Shippori Mincho', 'Yu Mincho', Georgia, serif;
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-dim: var(--accent-dim);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
--color-on-accent: var(--on-accent);
|
||||
|
||||
--color-shell: var(--bg-shell);
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-main: var(--bg-main);
|
||||
--color-card: var(--bg-card);
|
||||
--color-hover: var(--bg-hover);
|
||||
--color-active: var(--bg-active);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-border-light: var(--border-light);
|
||||
@@ -21,85 +22,55 @@
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
|
||||
--color-gold: var(--gold);
|
||||
--color-ok: var(--ok);
|
||||
--color-err: var(--err);
|
||||
|
||||
--radius-card: 14px;
|
||||
--radius-field: 10px;
|
||||
--radius-poster: 6px;
|
||||
|
||||
--text-body: 13.5px;
|
||||
--text-body--line-height: 1.5;
|
||||
--text-meta: 12.5px;
|
||||
--text-meta--line-height: 1.45;
|
||||
}
|
||||
|
||||
/* ─── Thème Indigo Électrique (défaut) ─── */
|
||||
:root,
|
||||
[data-theme="indigo"] {
|
||||
--accent: #7B7EF8;
|
||||
--accent-hover: #9496fa;
|
||||
--accent-dim: #1c1c38;
|
||||
--accent-muted: rgba(123, 126, 248, 0.15);
|
||||
/* ─── Palette ─── */
|
||||
:root {
|
||||
--bg-sidebar: #0e0a08;
|
||||
--bg-main: #13100c;
|
||||
--bg-card: #1d1814;
|
||||
--bg-hover: #27221d;
|
||||
|
||||
--bg-shell: #111116;
|
||||
--bg-sidebar: #0d0d14;
|
||||
--bg-main: #16161e;
|
||||
--bg-card: #111116;
|
||||
--bg-hover: #1c1c28;
|
||||
--bg-active: #22223a;
|
||||
--border: #352f2a;
|
||||
--border-light: #27221d;
|
||||
|
||||
--border: #2a2a3a;
|
||||
--border-light: #1e1e2e;
|
||||
--text-primary: #efe9dd;
|
||||
--text-secondary: #b8b0a3;
|
||||
--text-muted: #948b80;
|
||||
|
||||
--text-primary: #e8e8f8;
|
||||
--text-secondary: #9898b8;
|
||||
--text-muted: #55556a;
|
||||
--gold: #f2c44a;
|
||||
--ok: #89d298;
|
||||
--err: #ea8470;
|
||||
--on-accent: #0e0a08;
|
||||
|
||||
--gold: #f5c842;
|
||||
--accent: #f2c44a;
|
||||
--accent-hover: color-mix(in srgb, var(--accent) 80%, white);
|
||||
--accent-muted: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Thème Crimson Night ─── */
|
||||
[data-theme="crimson"] {
|
||||
--accent: #E74C3C;
|
||||
--accent-hover: #f06050;
|
||||
--accent-dim: #2a0e0e;
|
||||
--accent-muted: rgba(231, 76, 60, 0.15);
|
||||
|
||||
--bg-shell: #110e0e;
|
||||
--bg-sidebar: #0d0a0a;
|
||||
--bg-main: #161010;
|
||||
--bg-card: #110e0e;
|
||||
--bg-hover: #1e1414;
|
||||
--bg-active: #2a1616;
|
||||
|
||||
--border: #2e2020;
|
||||
--border-light: #201616;
|
||||
|
||||
--text-primary: #f0e8e8;
|
||||
--text-secondary: #b89898;
|
||||
--text-muted: #6a5555;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
|
||||
/* ─── Thème Teal Froid ─── */
|
||||
[data-theme="teal"] {
|
||||
--accent: #00CDBF;
|
||||
--accent-hover: #1de8da;
|
||||
--accent-dim: #001e1c;
|
||||
--accent-muted: rgba(0, 205, 191, 0.15);
|
||||
|
||||
--bg-shell: #0a0f0f;
|
||||
--bg-sidebar: #060c0c;
|
||||
--bg-main: #0e1616;
|
||||
--bg-card: #0a0f0f;
|
||||
--bg-hover: #101e1e;
|
||||
--bg-active: #142828;
|
||||
|
||||
--border: #1a2e2e;
|
||||
--border-light: #121e1e;
|
||||
|
||||
--text-primary: #e0f0f0;
|
||||
--text-secondary: #88b0b0;
|
||||
--text-muted: #4a6868;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
/* ─── Thèmes ─── */
|
||||
[data-theme="vermillon"] { --accent: #e8734f; }
|
||||
[data-theme="jade"] { --accent: #6fc79a; }
|
||||
|
||||
/* ─── Base globale ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
@@ -109,41 +80,211 @@ html, body, #app {
|
||||
body {
|
||||
background: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
:where(a, button, [role="switch"], summary):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ─── Composants réutilisables ─── */
|
||||
@layer components {
|
||||
/* Typo */
|
||||
.page-title {
|
||||
@apply font-display text-[28px] leading-tight font-bold text-primary;
|
||||
}
|
||||
.section-title {
|
||||
@apply font-display text-[21px] leading-snug font-bold text-primary;
|
||||
}
|
||||
.card-title {
|
||||
@apply text-[15px] font-bold text-primary;
|
||||
}
|
||||
.tag-label {
|
||||
@apply text-[10.5px] font-bold uppercase tracking-[0.14em] text-muted;
|
||||
}
|
||||
|
||||
/* Boutons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 h-10 px-[18px] rounded-full text-body whitespace-nowrap
|
||||
transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-accent text-white px-4 py-2 text-sm rounded-lg
|
||||
hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
@apply px-5 bg-accent text-on-accent font-bold hover:bg-accent-hover;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-border text-secondary px-4 py-2 text-sm rounded-lg
|
||||
hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
@apply border border-border text-primary font-semibold hover:bg-hover;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-muted px-2 py-1 text-sm rounded-md
|
||||
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
|
||||
@apply border border-border-light text-secondary font-medium hover:bg-hover hover:text-primary;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply border border-err/30 text-err font-semibold hover:bg-err/10;
|
||||
}
|
||||
.btn-icon {
|
||||
@apply inline-flex items-center justify-center shrink-0 w-10 h-10 rounded-full border border-border-light text-secondary
|
||||
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply h-8 px-3.5 text-meta;
|
||||
}
|
||||
.btn-icon.btn-sm {
|
||||
@apply w-8 px-0;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply bg-card border border-border rounded-xl p-4;
|
||||
/* Cartes et champs */
|
||||
.card {
|
||||
@apply bg-card rounded-card px-5 py-4;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
@apply text-xs text-muted block;
|
||||
.field-label {
|
||||
@apply block text-meta text-secondary;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
@apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg
|
||||
outline-none focus:border-accent transition-colors;
|
||||
.field {
|
||||
@apply w-full bg-main border border-border-light text-primary text-body px-3.5 py-2.5 rounded-field
|
||||
placeholder:text-muted outline-none focus:border-accent transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pastilles d'état */
|
||||
.pill {
|
||||
@apply inline-flex items-center gap-[7px] h-6 px-[11px] rounded-full border border-transparent
|
||||
text-[11.5px] font-bold whitespace-nowrap;
|
||||
}
|
||||
.pill-ok { @apply border-ok/30 text-ok; }
|
||||
.pill-active { @apply bg-accent-muted text-accent; }
|
||||
.pill-wait { @apply border-accent/35 text-accent; }
|
||||
.pill-neutral { @apply border-border text-secondary; }
|
||||
.pill-err { @apply border-err/35 text-err; }
|
||||
.pill-muted { @apply border-border-light text-muted; }
|
||||
|
||||
/* Filtres et onglets compteurs */
|
||||
.chip {
|
||||
@apply inline-flex items-center gap-2 h-[34px] px-3.5 rounded-full border border-border-light text-[13px] font-medium
|
||||
text-secondary whitespace-nowrap transition-colors cursor-pointer hover:text-primary hover:bg-hover;
|
||||
}
|
||||
.chip-count {
|
||||
@apply text-muted;
|
||||
}
|
||||
.chip.is-active {
|
||||
@apply border-primary bg-primary text-on-accent font-bold hover:bg-primary;
|
||||
}
|
||||
.chip.is-active .chip-count {
|
||||
@apply text-on-accent/55 font-medium;
|
||||
}
|
||||
.chip-row {
|
||||
@apply flex items-center gap-1.5 flex-wrap;
|
||||
}
|
||||
.chip-row > * {
|
||||
@apply shrink-0;
|
||||
}
|
||||
/* Sur mobile, les filtres défilent sur une ligne jusqu'aux bords de l'écran */
|
||||
@media (width < 48rem) {
|
||||
.chip-row {
|
||||
@apply flex-nowrap overflow-x-auto -mx-4 px-4 -my-1 py-1;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bascule */
|
||||
.switch {
|
||||
@apply relative inline-flex shrink-0 w-[46px] h-[26px] p-[3px] rounded-full bg-hover transition-colors cursor-pointer
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.switch-knob {
|
||||
@apply block w-5 h-5 rounded-full bg-muted transition-transform duration-200;
|
||||
}
|
||||
.switch[aria-checked="true"] {
|
||||
@apply bg-accent;
|
||||
}
|
||||
.switch[aria-checked="true"] .switch-knob {
|
||||
@apply translate-x-5 bg-on-accent;
|
||||
}
|
||||
|
||||
/* Sélecteur */
|
||||
.segmented {
|
||||
@apply inline-flex items-center gap-0.5 h-[34px] p-[3px] rounded-full border border-border-light;
|
||||
}
|
||||
.segmented-item {
|
||||
@apply inline-flex items-center h-[26px] px-3.5 rounded-full text-meta text-secondary whitespace-nowrap
|
||||
transition-colors cursor-pointer hover:text-primary;
|
||||
}
|
||||
.segmented-item.is-active {
|
||||
@apply bg-accent text-on-accent font-bold hover:text-on-accent;
|
||||
}
|
||||
.segmented-lg {
|
||||
@apply h-[38px];
|
||||
}
|
||||
.segmented-lg .segmented-item {
|
||||
@apply h-[30px] px-4 text-[13px];
|
||||
}
|
||||
|
||||
/* Barres de progression */
|
||||
.progress {
|
||||
@apply h-[3px] rounded-[2px] bg-hover overflow-hidden;
|
||||
}
|
||||
.progress-bar {
|
||||
@apply h-full rounded-[2px] bg-accent transition-[width] duration-500;
|
||||
}
|
||||
|
||||
/* Menus déroulants et pop */
|
||||
.menu {
|
||||
@apply bg-card border border-border rounded-card p-1.5 shadow-[0_16px_40px_rgb(0_0_0/0.5)] flex flex-col gap-0.5;
|
||||
}
|
||||
.menu-item {
|
||||
@apply flex items-center gap-2.5 w-full min-h-9 px-3 py-2 rounded-lg text-left text-body text-secondary
|
||||
transition-colors cursor-pointer hover:bg-hover hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
.modal-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center px-4 bg-sidebar/75;
|
||||
}
|
||||
.modal {
|
||||
@apply w-full bg-card border border-border rounded-card p-6 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.55)];
|
||||
}
|
||||
|
||||
|
||||
.stamp-new {
|
||||
@apply inline-block px-2 pt-[3px] pb-[2px] border-2 border-accent bg-sidebar/85 text-accent
|
||||
font-display text-[11px] font-bold leading-none tracking-[0.12em] -rotate-6;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply h-11;
|
||||
}
|
||||
.btn-icon {
|
||||
@apply w-11 h-11;
|
||||
}
|
||||
.btn-sm {
|
||||
@apply h-10;
|
||||
}
|
||||
.btn-icon.btn-sm {
|
||||
@apply w-10;
|
||||
}
|
||||
.chip {
|
||||
@apply h-10;
|
||||
}
|
||||
.menu-item {
|
||||
@apply min-h-11;
|
||||
}
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
@apply absolute inset-0 pointer-events-none;
|
||||
background-image: radial-gradient(color-mix(in srgb, var(--accent) 12%, transparent) 1px, transparent 1.5px);
|
||||
background-size: 7px 7px;
|
||||
mask-image: linear-gradient(var(--dot-grid-angle, 90deg), #000 0%, transparent var(--dot-grid-fade, 55%));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-[#0d1219] flex gap-4 p-3 hover:bg-[#121920] transition-colors">
|
||||
<div class="w-24 flex-shrink-0">
|
||||
<img
|
||||
v-if="ep.cover"
|
||||
:src="imageUrl(ep.cover) ?? undefined"
|
||||
:alt="ep.name"
|
||||
class="w-full aspect-video object-cover"
|
||||
/>
|
||||
<div v-else class="w-full aspect-video bg-[#121920]"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-[10px] text-[#e8513a]">FILM {{ ep.number }}</span>
|
||||
<span class="text-sm font-bold text-white truncate">{{ ep.name }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-[#5a7a94] mb-1">
|
||||
Épisodes {{ ep.episodes }} · {{ ep.duration }}
|
||||
</div>
|
||||
<p class="text-xs text-[#5a7a94] leading-relaxed line-clamp-2">{{ ep.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<a
|
||||
v-if="ep.path_fr"
|
||||
:href="`https://fankai.fr/productions/${productionId}?episode=${ep.number}`"
|
||||
target="_blank"
|
||||
title="Streamable sur fankai.fr"
|
||||
class="text-[10px] text-[#3ae87a] border border-[#3ae87a] px-2 py-1 tracking-widest hover:bg-[#3ae87a]/10 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
▶ STREAM
|
||||
</a>
|
||||
<div
|
||||
v-else
|
||||
class="text-[10px] text-[#1e2d3d] border border-[#1e2d3d] px-2 py-1 tracking-widest"
|
||||
>
|
||||
BIENTÔT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { imageUrl, type Episode } from '@/api/fankai'
|
||||
|
||||
defineProps<{
|
||||
ep: Episode
|
||||
productionId: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,32 +1,30 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
<div class="modal-backdrop" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[70vh] shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-label="Choisir un dossier">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
|
||||
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0">
|
||||
<p class="card-title">Choisir un dossier</p>
|
||||
<p class="text-meta text-muted truncate">{{ current }}</p>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
<button @click="$emit('cancel')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<div class="px-5 py-3 border-b border-hover shrink-0 flex gap-2">
|
||||
<button @click="navigateTo('/')" class="btn-icon h-[42px] w-[42px]" :title="drivesRoot ? 'Lecteurs' : 'Retour à la racine'" :aria-label="drivesRoot ? 'Lecteurs' : 'Retour à la racine'">
|
||||
<House :size="15" />
|
||||
</button>
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="settings-input flex-1 font-mono text-xs"
|
||||
class="field flex-1"
|
||||
placeholder="/"
|
||||
aria-label="Chemin"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary h-[42px]">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
@@ -35,39 +33,35 @@
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button
|
||||
@click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-xs text-muted hover:text-primary transition-colors border-b border-border"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-meta text-secondary hover:text-primary transition-colors border-b border-hover"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
<ArrowLeft :size="14" />
|
||||
..
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-xs gap-2">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-muted text-meta gap-2">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-muted">Aucun sous-dossier</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-meta text-err">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-meta text-muted">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
|
||||
class="flex items-center gap-3 w-full px-5 min-h-10 py-2 text-body text-primary hover:bg-hover transition-colors border-b border-hover text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||
</svg>
|
||||
<Folder :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
|
||||
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<div class="flex items-center justify-between gap-4 px-5 py-4 border-t border-hover shrink-0">
|
||||
<span class="text-meta text-muted truncate">{{ current }}</span>
|
||||
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
|
||||
<button @click="selectCurrent()" class="btn-primary">
|
||||
Choisir
|
||||
@@ -81,6 +75,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ArrowLeft, Folder, House, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ initialPath?: string }>()
|
||||
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
|
||||
@@ -93,7 +88,6 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
|
||||
|
||||
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
|
||||
function selectCurrent() {
|
||||
emit('select', current.value.replace(/\/+$/, '') || '/')
|
||||
}
|
||||
|
||||
@@ -1,87 +1,84 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('close')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-3xl flex flex-col" style="max-height: 85vh">
|
||||
<div class="modal-backdrop" @click.self="$emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-3xl flex flex-col shadow-[0_24px_60px_rgb(0_0_0/0.55)] max-h-[85vh]" role="dialog" aria-modal="true" aria-labelledby="manual-import-title">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
|
||||
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-start justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<h3 id="manual-import-title" class="font-display text-xl font-bold text-primary">Import manuel <span class="font-sans text-body font-normal text-muted">— {{ serieName }}</span></h3>
|
||||
<p class="text-meta text-muted truncate" :title="serieFolder">{{ serieFolder }}</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
<button @click="$emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<!-- Chargement -->
|
||||
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
|
||||
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Scan du dossier…</p>
|
||||
<p class="text-body">Scan du dossier…</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-muted">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="font-mono text-primary">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
<p class="text-body text-primary">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-meta text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="text-secondary break-all">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
</p>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-red-400">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary">Réessayer</button>
|
||||
<p class="text-body text-err">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<!-- Liste des fichiers -->
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-hover shrink-0 gap-3 flex-wrap">
|
||||
<p class="text-meta text-muted">
|
||||
<span class="text-primary font-bold">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-accent">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · {{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span>
|
||||
</p>
|
||||
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Fichiers -->
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="item.file.path"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
|
||||
class="flex items-center gap-3 px-3.5 py-3 rounded-field border transition-colors flex-wrap sm:flex-nowrap"
|
||||
:class="item.alreadyImported
|
||||
? 'border-blue-500/20 bg-blue-500/5'
|
||||
? 'border-ok/25 bg-ok/5'
|
||||
: item.episode_id
|
||||
? 'border-green-500/20 bg-green-500/5'
|
||||
: 'border-border bg-shell'"
|
||||
? 'border-accent/30 bg-accent/5'
|
||||
: 'border-border-light bg-main'"
|
||||
>
|
||||
<!-- Icône -->
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
|
||||
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
|
||||
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
|
||||
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="item.alreadyImported ? 'bg-ok/15 text-ok' : item.episode_id ? 'bg-accent-muted text-accent' : 'bg-hover text-muted'"
|
||||
>
|
||||
<Check v-if="item.alreadyImported || item.episode_id" :size="12" :stroke-width="3" />
|
||||
<Minus v-else :size="12" :stroke-width="3" />
|
||||
</span>
|
||||
|
||||
<!-- Nom + taille -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
<p class="text-body text-primary truncate" :title="item.file.name">{{ item.file.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ formatSize(item.file.size) }}
|
||||
<span v-if="item.alreadyImported" class="text-blue-400 ml-1">— déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
|
||||
<span v-if="item.alreadyImported" class="text-ok"> · déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent"> · sera renommé</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sélecteur épisode -->
|
||||
<select
|
||||
v-model="item.episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
class="field py-2 text-meta w-full sm:w-auto sm:max-w-[280px] shrink-0"
|
||||
@change="onEpisodeChange(i)"
|
||||
:aria-label="`Épisode associé à ${item.file.name}`"
|
||||
>
|
||||
<option :value="null">— Non associé —</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
@@ -96,18 +93,16 @@
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
|
||||
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 border-t border-hover shrink-0 flex-wrap">
|
||||
<p v-if="importError" class="text-meta text-err">{{ importError }}</p>
|
||||
<p v-else class="text-meta text-muted">
|
||||
Les fichiers de ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
<div class="flex gap-2.5">
|
||||
<button @click="$emit('close')" class="btn-ghost">Annuler</button>
|
||||
<button
|
||||
@click="doImport"
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
@@ -121,28 +116,30 @@
|
||||
</template>
|
||||
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="importResult.errors.length === 0 ? 'bg-green-500/10' : 'bg-yellow-500/10'">
|
||||
<svg v-if="importResult.errors.length === 0" width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" stroke="#eab308" stroke-width="2" fill="none"><path d="M12 2l9.27 16H2.73L12 2z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="16.5" r=".5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Import terminé</p>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center overflow-y-auto">
|
||||
<span
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="importResult.errors.length === 0 ? 'bg-ok/10 text-ok' : 'bg-err/10 text-err'"
|
||||
>
|
||||
<Check v-if="importResult.errors.length === 0" :size="22" :stroke-width="2.5" />
|
||||
<TriangleAlert v-else :size="22" :stroke-width="2" />
|
||||
</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-display text-xl font-bold text-primary">Import terminé</p>
|
||||
<p class="text-body text-secondary">
|
||||
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
|
||||
<span v-if="importResult.errors.length > 0" class="text-red-400">
|
||||
<span v-if="importResult.errors.length > 0" class="text-err">
|
||||
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="importResult.errors.length > 0" class="w-full text-left bg-red-500/5 border border-red-500/20 rounded-lg p-3">
|
||||
<p class="text-xs text-red-400 font-medium mb-1.5">Erreurs</p>
|
||||
<div v-for="e in importResult.errors" :key="e.file" class="text-xs text-muted mb-1">
|
||||
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
|
||||
</div>
|
||||
<div v-if="importResult.errors.length > 0" class="w-full text-left rounded-field border border-err/25 bg-err/5 px-4 py-3">
|
||||
<p class="tag-label text-err mb-1.5">Erreurs</p>
|
||||
<p v-for="e in importResult.errors" :key="e.file" class="text-meta text-secondary mb-1 break-words">
|
||||
<span class="text-primary">{{ e.file }}</span> — {{ e.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2.5">
|
||||
<button @click="backToList" class="btn-secondary">Voir les fichiers</button>
|
||||
<button @click="$emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
@@ -156,6 +153,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { Check, Minus, RefreshCw, TriangleAlert, X } from 'lucide-vue-next'
|
||||
import { matchEpisodeFile } from '@/utils/episode-match'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed bottom-4 right-4 z-[70] flex flex-col gap-2">
|
||||
<div class="fixed bottom-4 right-4 left-4 sm:left-auto z-[70] flex flex-col items-end gap-2 pointer-events-none" role="status" aria-live="polite">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
@click="remove(t.id)"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
|
||||
class="pointer-events-auto flex items-center gap-3 pl-3.5 pr-4 py-3 rounded-card border bg-card cursor-pointer w-full sm:w-auto sm:min-w-72 max-w-sm shadow-[0_16px_40px_rgb(0_0_0/0.5)]"
|
||||
:class="{
|
||||
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
|
||||
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
|
||||
'bg-card border-accent/30 text-accent' : t.type === 'info',
|
||||
'border-ok/30' : t.type === 'success',
|
||||
'border-err/35' : t.type === 'error',
|
||||
'border-accent/35' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<polyline points="2 7 5.5 10.5 12 3"/>
|
||||
</svg>
|
||||
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="{
|
||||
'bg-ok/15 text-ok' : t.type === 'success',
|
||||
'bg-err/15 text-err' : t.type === 'error',
|
||||
'bg-accent-muted text-accent' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
<Check v-if="t.type === 'success'" :size="13" :stroke-width="2.75" />
|
||||
<X v-else-if="t.type === 'error'" :size="13" :stroke-width="2.75" />
|
||||
<Info v-else :size="13" :stroke-width="2.5" />
|
||||
</span>
|
||||
|
||||
<span class="flex-1 text-xs leading-snug">{{ t.message }}</span>
|
||||
<span class="flex-1 text-meta text-primary leading-snug">{{ t.message }}</span>
|
||||
|
||||
<!-- Badge count si groupé -->
|
||||
<!-- Nbre de messages regroupés -->
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
class="shrink-0 pill h-5 px-2 text-[10.5px]"
|
||||
:class="{
|
||||
'bg-green-500/20 text-green-400' : t.type === 'success',
|
||||
'bg-red-500/20 text-red-400' : t.type === 'error',
|
||||
'bg-accent/20 text-accent' : t.type === 'info',
|
||||
'pill-ok' : t.type === 'success',
|
||||
'pill-err' : t.type === 'error',
|
||||
'pill-active' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
×{{ t.count }}
|
||||
@@ -44,6 +47,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Info, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
const { toasts, remove } = useToast()
|
||||
</script>
|
||||
|
||||
@@ -1,122 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Barre search + tri + colonnes -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-x-4 gap-y-2.5 md:flex-wrap">
|
||||
<!-- Onglets à compteurs -->
|
||||
<div class="chip-row">
|
||||
<button
|
||||
v-for="t in tabs" :key="t.value"
|
||||
@click="emit('update:activeTab', t.value)"
|
||||
class="chip"
|
||||
:class="{
|
||||
'is-active': activeTab === t.value,
|
||||
'border-err/30 text-err hover:text-err': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
|
||||
}"
|
||||
:aria-pressed="activeTab === t.value"
|
||||
>
|
||||
{{ t.label }}<span
|
||||
class="chip-count"
|
||||
:class="{
|
||||
'text-accent! font-bold': t.tone === 'accent' && t.count > 0 && activeTab !== t.value,
|
||||
'text-err! font-bold': t.tone === 'err' && t.count > 0 && activeTab !== t.value,
|
||||
}"
|
||||
>{{ t.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<label class="w-full sm:w-60 h-[34px] pointer-coarse:h-10 rounded-full bg-card border border-border-light flex items-center gap-[9px] pl-3.5 pr-1.5 cursor-text focus-within:border-accent transition-colors">
|
||||
<Search :size="15" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
<input
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
type="text" placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
@keydown.esc="emit('update:search', '')"
|
||||
type="text" placeholder="Filtrer" aria-label="Filtrer les torrents"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-[13px] text-primary placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button @click="sortOpen = !sortOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="activeSort !== 'none' ? 'border-accent text-accent' : ''">
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer" class="w-6 h-6 rounded-full flex items-center justify-center text-muted hover:text-primary">
|
||||
<X :size="13" />
|
||||
</button>
|
||||
<div v-if="sortOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl">
|
||||
<button v-for="s in sortOptions" :key="s.value" @click="handleSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'">
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-[9px] text-meta text-secondary cursor-pointer select-none">
|
||||
Masquer les importés
|
||||
<button
|
||||
type="button" role="switch" :aria-checked="hideImported"
|
||||
@click="emit('update:hideImported', !hideImported)"
|
||||
class="switch w-9 h-5"
|
||||
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': hideImported }" /></button>
|
||||
</label>
|
||||
|
||||
<!-- Tri, colonnes, seeds -->
|
||||
<div ref="optionsRef" class="relative">
|
||||
<button
|
||||
@click="optionsOpen = !optionsOpen"
|
||||
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border flex items-center justify-center transition-colors relative"
|
||||
:class="optionsOpen || activeSort !== 'none' || seedingOnly ? 'border-accent/40 text-accent' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
|
||||
aria-label="Tri et affichage" aria-haspopup="menu" :aria-expanded="optionsOpen"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
</button>
|
||||
<div v-if="optionsOpen" class="menu absolute top-full right-0 mt-1.5 w-60 z-20" role="menu">
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Trier</p>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
role="menuitem"
|
||||
@click="emit('sort', s.value)"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
<ArrowUp v-if="activeSort === s.value && sortDir === 'asc'" :size="14" class="text-accent" />
|
||||
<ArrowDown v-else-if="activeSort === s.value && sortDir === 'desc'" :size="14" class="text-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonnes -->
|
||||
<div class="relative shrink-0" ref="colRef">
|
||||
<button @click="colOpen = !colOpen" class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5" :class="colOpen ? 'border-accent text-accent' : ''">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="hidden md:inline text-xs">Colonnes</span>
|
||||
</button>
|
||||
<div v-if="colOpen" class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-52 flex flex-col gap-0.5 shadow-xl">
|
||||
<p class="text-[10px] text-muted px-3 py-1">Colonnes affichées</p>
|
||||
<label v-for="col in columnOptions" :key="col.key" class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs cursor-pointer hover:bg-hover transition-colors">
|
||||
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="accent-accent" />
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Afficher</p>
|
||||
<label v-for="col in columnOptions" :key="col.key" class="menu-item cursor-pointer">
|
||||
<input type="checkbox" :checked="columns[col.key]" @change="emit('toggle-col', col.key)" class="w-4 h-4 rounded" />
|
||||
{{ col.label }}
|
||||
</label>
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<label class="menu-item cursor-pointer justify-between">
|
||||
Seeds uniquement
|
||||
<button
|
||||
type="button" role="switch" :aria-checked="seedingOnly"
|
||||
@click="emit('update:seedingOnly', !seedingOnly)"
|
||||
class="switch w-9 h-5"
|
||||
><span class="switch-knob w-3.5 h-3.5" :class="{ 'translate-x-4!': seedingOnly }" /></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets + filtres -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button @click="emit('update:activeTab', 'active')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'active' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
En cours
|
||||
<span v-if="activeCount > 0" class="ml-1.5 text-[10px] text-accent">{{ activeCount }}</span>
|
||||
</button>
|
||||
<button @click="emit('update:activeTab', 'done')" class="px-4 py-1.5 text-xs rounded-md transition-colors" :class="activeTab === 'done' ? 'bg-active text-primary' : 'text-muted hover:text-primary'">
|
||||
Terminés
|
||||
<span v-if="doneCount > 0" class="ml-1.5 text-[10px] text-accent">{{ doneCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Seeds uniquement</span>
|
||||
<button @click="emit('update:seedingOnly', !seedingOnly)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="seedingOnly ? 'bg-green-500' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="seedingOnly ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button @click="emit('update:hideImported', !hideImported)" class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200" :class="hideImported ? 'bg-accent' : 'bg-border'">
|
||||
<span class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200" :class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { ArrowDown, ArrowUp, Search, SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
search : string
|
||||
activeSort : string
|
||||
sortDir : 'asc' | 'desc'
|
||||
activeTab : 'active' | 'done'
|
||||
seedingOnly : boolean
|
||||
hideImported: boolean
|
||||
columns : Record<string, boolean>
|
||||
activeCount : number
|
||||
doneCount : number
|
||||
export interface ActivityTab { value: string; label: string; count: number; tone?: 'accent' | 'err' }
|
||||
|
||||
defineProps<{
|
||||
search : string
|
||||
activeSort : string
|
||||
sortDir : 'asc' | 'desc'
|
||||
activeTab : string
|
||||
seedingOnly : boolean
|
||||
hideImported : boolean
|
||||
columns : Record<string, boolean>
|
||||
tabs : ActivityTab[]
|
||||
columnOptions: { key: string; label: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeTab' : [value: 'active' | 'done']
|
||||
'update:seedingOnly': [value: boolean]
|
||||
'update:search' : [value: string]
|
||||
'update:activeTab' : [value: string]
|
||||
'update:seedingOnly' : [value: boolean]
|
||||
'update:hideImported': [value: boolean]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
'sort' : [value: string]
|
||||
'toggle-col' : [key: string]
|
||||
}>()
|
||||
|
||||
const sortOpen = ref(false)
|
||||
const colOpen = ref(false)
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
const colRef = ref<HTMLElement | null>(null)
|
||||
const optionsOpen = ref(false)
|
||||
const optionsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
function handleSort(val: string) {
|
||||
emit('sort', val)
|
||||
sortOpen.value = false
|
||||
}
|
||||
onClickOutside(optionsRef, () => { optionsOpen.value = false })
|
||||
</script>
|
||||
@@ -1,161 +1,188 @@
|
||||
<template>
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<article class="bg-card rounded-xl px-4 py-3 flex flex-col md:flex-row md:items-center gap-x-4 gap-y-3">
|
||||
|
||||
<!-- Nom + état -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Nom du torrent + tooltip épisodes -->
|
||||
<div class="relative group/eps inline-block max-w-full">
|
||||
<p class="text-sm text-primary font-medium truncate cursor-default">{{ torrent.name }}</p>
|
||||
<!-- Tooltip épisodes -->
|
||||
<div v-if="episodesWithProgress.length > 0"
|
||||
class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||
<div class="bg-card border border-border rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-muted font-medium mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<div v-for="ep in episodesWithProgress" :key="ep.episode_id"
|
||||
class="flex items-center justify-between gap-3">
|
||||
<span class="text-primary shrink-0">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
<span v-if="ep.progress !== null"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded shrink-0"
|
||||
:class="ep.progress >= 100 ? 'bg-green-500/15 text-green-500' : ep.priority === 0 ? 'bg-border/50 text-muted' : 'bg-accent/15 text-accent'">
|
||||
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress}%` }}
|
||||
</span>
|
||||
<div class="flex items-center gap-4 flex-1 min-w-0">
|
||||
<!-- Affiche -->
|
||||
<RouterLink v-if="torrent.serieId" :to="`/series/${torrent.serieId}`" class="shrink-0">
|
||||
<img v-if="poster" :src="poster" alt="" class="w-[34px] h-[51px] object-cover rounded-[4px]" />
|
||||
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center"><Tv :size="16" :stroke-width="1.75" /></span>
|
||||
</RouterLink>
|
||||
<span v-else class="w-[34px] h-[51px] rounded-[4px] bg-hover text-muted flex items-center justify-center shrink-0" title="Série non reconnue">
|
||||
<FileQuestion :size="16" :stroke-width="1.75" />
|
||||
</span>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[7px]">
|
||||
<!-- Nom, état, client -->
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="relative group/eps min-w-0">
|
||||
<p class="text-sm font-medium truncate cursor-default" :class="isImported ? 'text-secondary' : 'text-primary'" :title="torrent.name">
|
||||
{{ torrent.serieName && !torrent.name.startsWith(torrent.serieName) ? torrent.serieName : torrent.name }}
|
||||
</p>
|
||||
<!-- Épisodes du torrent -->
|
||||
<div v-if="episodesWithProgress.length > 0" class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||
<div class="menu p-3 gap-0">
|
||||
<p class="text-meta text-primary font-medium truncate mb-1">{{ torrent.name }}</p>
|
||||
<p class="tag-label mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
|
||||
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||
<div v-for="ep in episodesWithProgress" :key="ep.episode_id" class="flex items-center justify-between gap-3 text-meta">
|
||||
<span class="text-primary shrink-0 tabular-nums">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||
<span
|
||||
v-if="ep.progress !== null"
|
||||
class="pill h-5 px-2 text-[10.5px]"
|
||||
:class="ep.progress >= 100 ? 'pill-ok' : ep.priority === 0 ? 'pill-muted' : 'pill-active'"
|
||||
>
|
||||
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress} %` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill h-5 px-2 text-[11px] shrink-0" :class="[badge.class, { 'max-sm:hidden': torrent.state === 'downloading' }]">{{ badge.label }}</span>
|
||||
<span v-if="clientLine" class="text-[11.5px] text-muted shrink-0 hidden sm:inline">{{ clientLine }}</span>
|
||||
<span v-if="!isDone" class="ml-auto text-[13px] tabular-nums shrink-0" :class="torrent.state === 'error' ? 'text-err' : 'text-primary'">{{ clampedProgress }} %</span>
|
||||
</div>
|
||||
<!-- Nom de la série -->
|
||||
<p v-if="torrent.serieName" class="text-[11px] text-muted truncate mt-0.5">{{ torrent.serieName }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
||||
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ clampedProgress }}%</p>
|
||||
<p v-if="columns.size" class="text-xs text-muted mt-0.5">{{ formatSize(torrent.downloaded) }} / {{ formatSize(torrent.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="torrent.state === 'seeding' || torrent.state === 'unknown' ? 'bg-green-500' : torrent.state === 'error' ? 'bg-red-500' : torrent.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Infos + actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">↓ {{ formatSpeed(torrent.speed) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">ETA {{ formatEta(torrent.eta) }}</span>
|
||||
<template v-if="torrent.state === 'seeding'">
|
||||
<span class="text-green-500">Complété</span>
|
||||
<span v-if="columns.ratio" class="font-mono" :class="torrent.ratio >= 1 ? 'text-green-400' : torrent.ratio >= 0.5 ? 'text-yellow-500' : 'text-muted'">R {{ torrent.ratio?.toFixed(2) ?? '0.00' }}</span>
|
||||
<span v-if="columns.uploaded">↑ {{ formatSize(torrent.uploaded ?? 0) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
|
||||
<!-- Progression, vitesse, ETA -->
|
||||
<template v-if="!isDone">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :class="{ 'bg-muted': torrent.state === 'paused', 'bg-err': torrent.state === 'error' }" :style="{ width: `${clampedProgress}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
|
||||
<span v-if="torrent.state === 'downloading'">{{ formatSpeed(torrent.speed ?? 0) }}</span>
|
||||
<span v-if="torrent.state === 'downloading' && torrent.eta > 0">reste {{ formatDuration(torrent.eta) }}</span>
|
||||
<span v-if="torrent.state === 'error'" class="text-err">Le client signale une erreur sur ce torrent</span>
|
||||
<span v-if="columns.size">{{ formatSize(torrent.downloaded) }} sur {{ formatSize(torrent.size) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Actions terminés -->
|
||||
<template v-if="torrent.state === 'seeding' || torrent.state === 'unknown'">
|
||||
<!-- Erreurs -->
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-xs px-2 py-0.5 rounded border border-red-500/40 text-red-400 cursor-default">{{ torrent.errorFiles.length }} erreur{{ torrent.errorFiles.length > 1 ? 's' : '' }}</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
|
||||
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-primary truncate">{{ e.file }}</p>
|
||||
<!-- Terminé : erreurs d'import ou détails -->
|
||||
<template v-else>
|
||||
<div v-if="torrent.errorFiles?.length > 0" class="relative group/err flex items-center gap-[9px] text-meta text-err min-w-0 w-fit max-w-full">
|
||||
<TriangleAlert :size="14" :stroke-width="2" class="shrink-0" />
|
||||
<span class="truncate">{{ errorSummary }}</span>
|
||||
<div class="absolute bottom-full left-0 mb-2 hidden group-hover/err:block z-20 w-96 max-w-[80vw]">
|
||||
<div class="menu p-3 gap-0 border-err/30">
|
||||
<p class="tag-label text-err mb-2">Fichiers en erreur</p>
|
||||
<div v-for="e in torrent.errorFiles" :key="e.file" class="mb-1.5 last:mb-0 text-meta">
|
||||
<p class="text-primary truncate" :title="e.file">{{ e.file }}</p>
|
||||
<p class="text-muted">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge import -->
|
||||
<span class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="torrent.organizeState === 'done' ? 'border-green-500/40 text-green-500' : torrent.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' : 'border-border text-muted'">
|
||||
{{ torrent.organizeState === 'done'
|
||||
? `Importé (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: torrent.organizeState === 'partial'
|
||||
? `En cours (${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total})`
|
||||
: 'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
|
||||
class="btn-secondary text-xs py-1 flex items-center gap-1.5 min-w-[72px] justify-center">
|
||||
<svg v-if="importing" width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" fill="none" class="animate-spin shrink-0">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
</svg>
|
||||
{{ importing ? 'Import…' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-lg border border-border text-muted hover:border-red-500/40 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="absolute bottom-full right-0 mb-2 bg-card border border-border rounded-xl p-3 z-10 w-52 shadow-xl">
|
||||
<p class="text-xs text-primary font-medium mb-2">
|
||||
{{ tab === 'active' ? 'Annuler ce téléchargement ?' : 'Supprimer ce torrent ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-xs text-muted mb-3 cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="accent-red-500" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="flex-1 text-xs py-1 px-2 rounded-lg bg-red-500/10 text-red-400 border border-red-500/30 hover:bg-red-500/20 transition-colors">
|
||||
{{ deleting ? '...' : 'Confirmer' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="flex-1 text-xs py-1 px-2 rounded-lg border border-border text-muted hover:text-primary transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-x-4 gap-y-1 flex-wrap text-xs text-muted">
|
||||
<span>
|
||||
<template v-if="torrent.organizeProgress?.total > 1">{{ torrent.organizeProgress.total }} fichiers · </template>{{ formatSize(torrent.size) }}
|
||||
</span>
|
||||
<span v-if="torrent.state === 'unknown'" class="text-accent">État non remonté par le client</span>
|
||||
<span v-if="isImported && torrent.importedAt">importé {{ formatRelative(torrent.importedAt) }}</span>
|
||||
<span v-else-if="!isImported && autoImportIn">import automatique {{ autoImportIn }}</span>
|
||||
<span v-if="columns.uploaded && torrent.uploaded">↑ {{ formatSize(torrent.uploaded) }}</span>
|
||||
<span v-if="columns.upspeed && torrent.upspeed > 0">{{ formatSpeed(torrent.upspeed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- État d'import et actions -->
|
||||
<div class="md:w-[260px] shrink-0 flex items-center justify-end gap-2.5 flex-wrap">
|
||||
<template v-if="!isDone">
|
||||
<span class="text-meta text-muted max-md:mr-auto">{{ torrent.serieName ? (autoImport ? 'Import auto à la fin' : 'Import manuel à la fin') : 'Série non reconnue' }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="isImported" class="flex items-center gap-1.5 text-meta text-ok max-md:mr-auto">
|
||||
<Check :size="14" :stroke-width="2.5" /> Importé {{ torrent.organizeProgress?.done }}/{{ torrent.organizeProgress?.total }}
|
||||
</span>
|
||||
<span v-else class="text-meta text-secondary max-md:mr-auto">
|
||||
{{ torrent.organizeState === 'partial' ? `Importé ${torrent.organizeProgress?.done}/${torrent.organizeProgress?.total}` : 'Non importé' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="!isImported"
|
||||
@click="emit('import', torrent)"
|
||||
:disabled="importing"
|
||||
class="btn-sm min-w-[88px] max-md:min-w-[128px]"
|
||||
:class="torrent.errorFiles?.length > 0 || torrent.organizeState === 'partial' ? 'btn-secondary' : 'btn-primary'"
|
||||
>
|
||||
<Loader v-if="importing" :size="13" class="animate-spin" />
|
||||
{{ importing ? 'Import…' : torrent.errorFiles?.length > 0 ? 'Réessayer' : 'Importer' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Supprimer -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="emit('toggle-confirm', showConfirmDelete ? null : torrent.hash)"
|
||||
:disabled="deleting"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
|
||||
:title="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
|
||||
:aria-label="isDone ? 'Supprimer le torrent' : 'Annuler le téléchargement'"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
<div v-if="showConfirmDelete" class="menu absolute bottom-full right-0 mb-2 w-60 z-20 p-3 gap-3">
|
||||
<p class="text-body text-primary font-medium">
|
||||
{{ isDone ? 'Supprimer ce torrent ?' : 'Annuler ce téléchargement ?' }}
|
||||
</p>
|
||||
<label class="flex items-center gap-2 text-meta text-secondary cursor-pointer">
|
||||
<input type="checkbox" v-model="withFiles" class="w-4 h-4 rounded" />
|
||||
Supprimer aussi les fichiers
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="emit('delete', torrent, withFiles)" :disabled="deleting" class="btn-danger btn-sm flex-1">
|
||||
{{ deleting ? '…' : 'Confirmer' }}
|
||||
</button>
|
||||
<button @click="emit('toggle-confirm', null)" class="btn-ghost btn-sm flex-1">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Check, FileQuestion, Loader, Trash2, TriangleAlert, Tv } from 'lucide-vue-next'
|
||||
import { formatDuration, formatRelative, formatSize, formatSpeed } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent : any
|
||||
tab : 'active' | 'done'
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
torrent : any
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
showConfirmDelete: boolean
|
||||
poster? : string | null
|
||||
autoImport? : boolean
|
||||
autoImportIn? : string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
import : [torrent: any]
|
||||
delete : [torrent: any, withFiles: boolean]
|
||||
import : [torrent: any]
|
||||
delete : [torrent: any, withFiles: boolean]
|
||||
'toggle-confirm': [hash: string | null]
|
||||
}>()
|
||||
|
||||
const withFiles = ref(false)
|
||||
|
||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||
const isDone = computed(() => props.torrent.state === 'seeding' || props.torrent.state === 'unknown')
|
||||
const isImported = computed(() => isDone.value && props.torrent.organizeState === 'done')
|
||||
|
||||
const clientLine = computed(() => [
|
||||
props.columns.client ? props.torrent.client_name : null,
|
||||
isDone.value && props.columns.ratio ? `R ${(props.torrent.ratio ?? 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : null,
|
||||
].filter(Boolean).join(' · '))
|
||||
|
||||
|
||||
const errorSummary = computed(() => {
|
||||
const errs: { file: string; error: string }[] = props.torrent.errorFiles ?? []
|
||||
if (!errs.length) return ''
|
||||
const name = errs[0]!.file.split(/[\\/]/).pop() ?? errs[0]!.file
|
||||
const ep = name.match(/S\d+E(\d+)/i)
|
||||
return `${ep ? `E${ep[1]}` : name} · ${errs[0]!.error}${errs.length > 1 ? ` (+${errs.length - 1})` : ''}`
|
||||
})
|
||||
|
||||
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
|
||||
const episodesWithProgress = computed(() => {
|
||||
@@ -174,28 +201,12 @@ const episodesWithProgress = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function stateBadge(state: string) {
|
||||
return ({
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
} as Record<string, any>)[state] ?? { label: 'Inconnu', class: 'border-border text-muted' }
|
||||
}
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
function formatSpeed(bps: number) { return `${formatSize(bps)}/s` }
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
const badge = computed(() => ({
|
||||
downloading: { label: 'Téléchargement', class: 'pill-active' },
|
||||
seeding : { label: 'Terminé', class: 'pill-ok' },
|
||||
paused : { label: 'En pause', class: 'pill-neutral' },
|
||||
checking : { label: 'Vérification', class: 'pill-wait' },
|
||||
error : { label: 'Erreur', class: 'pill-err' },
|
||||
unknown : { label: 'État inconnu', class: 'pill-wait' },
|
||||
} as Record<string, { label: string; class: string }>)[props.torrent.state] ?? { label: 'Inconnu', class: 'pill-muted' })
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<header class="flex items-center gap-1 pl-4 pr-2 h-14 shrink-0 bg-main">
|
||||
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-2.5">
|
||||
<FankarrLogo class="w-6 h-[25px] shrink-0" />
|
||||
<span class="font-display text-xl font-bold tracking-[0.01em] text-primary">FanKarr</span>
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
@click="librarySearch.open()"
|
||||
aria-label="Rechercher une série"
|
||||
class="w-11 h-11 rounded-full flex items-center justify-center text-secondary hover:text-primary transition-colors"
|
||||
>
|
||||
<Search :size="19" :stroke-width="1.75" />
|
||||
</button>
|
||||
|
||||
<!-- Menu du compte -->
|
||||
<div ref="menuRef" class="relative">
|
||||
<button
|
||||
data-tour="help"
|
||||
@click="open = !open"
|
||||
aria-label="Menu du compte"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="open"
|
||||
class="relative w-11 h-11 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="w-[34px] h-[34px] rounded-full bg-card border border-border text-accent font-display text-base font-bold flex items-center justify-center">
|
||||
{{ initial }}
|
||||
</span>
|
||||
<span v-if="auth.isAdmin && updateAvailable" class="absolute top-[7px] right-[7px] w-2 h-2 rounded-full bg-accent ring-2 ring-main" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" role="menu" class="menu absolute top-full right-0 mt-1 w-64 z-40" @keydown.esc="open = false">
|
||||
<div class="flex flex-col gap-px px-3 pt-2 pb-2.5 mb-1 border-b border-hover">
|
||||
<span class="text-body font-medium text-primary truncate">{{ auth.username }}</span>
|
||||
<span class="text-meta text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
|
||||
</div>
|
||||
|
||||
<RouterLink v-if="auth.isAdmin" to="/settings" role="menuitem" class="menu-item" @click="open = false">
|
||||
<Settings :size="15" class="shrink-0" /> Paramètres
|
||||
</RouterLink>
|
||||
<button role="menuitem" class="menu-item" @click="startTour">
|
||||
<Compass :size="15" class="shrink-0" /> Visite guidée
|
||||
</button>
|
||||
<button v-if="auth.isAdmin" role="menuitem" class="menu-item" @click="openSetup">
|
||||
<Wand :size="15" class="shrink-0" /> Assistant de configuration
|
||||
</button>
|
||||
<a
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
role="menuitem"
|
||||
class="menu-item text-accent hover:text-accent"
|
||||
@click="open = false"
|
||||
>
|
||||
<ExternalLink :size="15" class="shrink-0" /> Mise à jour {{ latestVersion }}
|
||||
</a>
|
||||
|
||||
<div class="h-px bg-hover my-1" />
|
||||
<button role="menuitem" class="menu-item text-err hover:text-err" @click="logout">
|
||||
<LogOut :size="15" class="shrink-0" /> Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Compass, ExternalLink, LogOut, Search, Settings, Wand } from 'lucide-vue-next'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import { useUpdateCheck } from '@/composables/useUpdateCheck'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const tour = useTourStore()
|
||||
const librarySearch = useLibrarySearch()
|
||||
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
|
||||
|
||||
const open = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(menuRef, () => { open.value = false })
|
||||
|
||||
function startTour() {
|
||||
open.value = false
|
||||
tour.start()
|
||||
}
|
||||
|
||||
function openSetup() {
|
||||
open.value = false
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
open.value = false
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<nav aria-label="Navigation principale" class="shrink-0 bg-sidebar border-t border-card pb-[env(safe-area-inset-bottom)]">
|
||||
<div class="flex h-16">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to ?? '/'"
|
||||
:data-tour="item.tour"
|
||||
:aria-current="isActive(item) ? 'page' : undefined"
|
||||
class="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 text-[11.5px] transition-colors"
|
||||
:class="isActive(item) ? 'text-accent font-bold' : 'text-muted font-medium hover:text-secondary'"
|
||||
>
|
||||
<span class="relative">
|
||||
<component :is="item.icon" :size="22" :stroke-width="1.75" />
|
||||
<span
|
||||
v-if="item.badge && item.badgeStyle === 'dot'"
|
||||
class="absolute -top-0.5 -right-1 w-[7px] h-[7px] rounded-full bg-accent ring-2 ring-sidebar"
|
||||
/>
|
||||
<span
|
||||
v-else-if="item.badge"
|
||||
class="absolute -top-1.5 -right-2.5 min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-on-accent text-[10.5px] font-bold tabular-nums flex items-center justify-center ring-2 ring-sidebar"
|
||||
>{{ item.badge }}</span>
|
||||
</span>
|
||||
<span class="max-w-full px-1 truncate">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
defineProps<{ items: NavItem[] }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isActive = (item: NavItem) => !!item.to && route.path.startsWith(item.to)
|
||||
</script>
|
||||
@@ -1,53 +1,11 @@
|
||||
<template>
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-card px-3.5 pt-[22px] pb-[18px]">
|
||||
|
||||
<!-- Logo + logout -->
|
||||
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
|
||||
<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
|
||||
<div class="flex items-center gap-2 pl-2 pb-7">
|
||||
<RouterLink to="/dashboard" class="flex-1 min-w-0 flex items-center gap-[11px]">
|
||||
<FankarrLogo class="w-[30px] h-[31px] shrink-0" />
|
||||
<span class="font-display text-[23px] font-bold tracking-[0.01em] text-primary">FanKarr</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
@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>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 flex flex-col gap-0.5">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<hr v-if="item.separator" class="border-border my-2 mx-1" />
|
||||
<SidebarNavItem v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Nouvelle version disponible -->
|
||||
<a
|
||||
v-if="updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mx-2 mb-2 flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-accent/30 bg-accent/5 hover:bg-accent/10 transition-colors group"
|
||||
>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-accent">Mise à jour disponible</p>
|
||||
<p class="text-[11px] text-muted truncate">{{ latestVersion }} est disponible</p>
|
||||
</div>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="shrink-0 text-muted group-hover:text-accent transition-colors">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- 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"
|
||||
@@ -55,41 +13,79 @@
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="helpOpen"
|
||||
title="Aide"
|
||||
class="text-muted hover:text-primary transition-colors p-1 rounded-md hover:bg-hover"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
|
||||
>
|
||||
<CircleQuestionMark :size="15" />
|
||||
<CircleQuestionMark :size="16" :stroke-width="1.75" />
|
||||
</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"
|
||||
class="menu absolute top-full left-0 mt-2 w-60 z-30"
|
||||
@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 role="menuitem" @click="startTour" class="menu-item">
|
||||
<Compass :size="15" 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 v-if="auth.isAdmin" role="menuitem" @click="openSetup" class="menu-item">
|
||||
<Wand :size="15" class="shrink-0" /> Assistant de configuration
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thème -->
|
||||
<ThemeSwitcher />
|
||||
<nav class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden flex flex-col gap-0.5">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<div v-if="item.separator" class="h-px bg-card mx-2 my-3.5 shrink-0" />
|
||||
<SidebarNavItem v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Nouvelle version disponible -->
|
||||
<a
|
||||
v-if="auth.isAdmin && updateAvailable"
|
||||
:href="latestReleaseUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mb-3 flex items-center gap-2.5 px-3 py-2.5 rounded-field bg-accent-muted hover:bg-accent/20 transition-colors group"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
|
||||
<span class="flex-1 min-w-0">
|
||||
<span class="block text-meta font-bold text-accent">Mise à jour disponible</span>
|
||||
<span class="block text-[11.5px] text-muted truncate">{{ latestVersion }} est disponible</span>
|
||||
</span>
|
||||
<ExternalLink :size="13" class="shrink-0 text-muted group-hover:text-accent transition-colors" />
|
||||
</a>
|
||||
|
||||
<!-- Utilisateur et déconnexion -->
|
||||
<div class="flex items-center gap-2 pl-2 pt-3.5 border-t border-card">
|
||||
<span class="w-[34px] h-[34px] rounded-full bg-hover text-accent flex items-center justify-center font-display text-base font-bold shrink-0">
|
||||
{{ initial }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[13px] font-medium text-primary truncate">{{ auth.username }}</span>
|
||||
<span class="text-[11.5px] text-muted truncate">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}<template v-if="currentVersion"> · {{ currentVersion }}</template></span>
|
||||
</span>
|
||||
<button
|
||||
@click="logout"
|
||||
title="Se déconnecter"
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 text-muted hover:text-err hover:bg-hover transition-colors"
|
||||
>
|
||||
<LogOut :size="16" :stroke-width="1.75" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { CircleQuestionMark, Compass, LogOut, Wand } from 'lucide-vue-next'
|
||||
import { CircleQuestionMark, Compass, ExternalLink, LogOut, Wand } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import { useUpdateCheck } from '@/composables/useUpdateCheck'
|
||||
import SidebarNavItem from './SidebarNavItem.vue'
|
||||
import ThemeSwitcher from './ThemeSwitcher.vue'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
@@ -99,6 +95,8 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const tour = useTourStore()
|
||||
|
||||
const initial = computed(() => auth.username?.charAt(0).toUpperCase() ?? '?')
|
||||
|
||||
const helpOpen = ref(false)
|
||||
const helpRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(helpRef, () => { helpOpen.value = false })
|
||||
@@ -113,66 +111,10 @@ function openSetup() {
|
||||
router.push('/setup')
|
||||
}
|
||||
|
||||
const currentVersion = ref(
|
||||
import.meta.env.DEV ? 'dev' : ''
|
||||
)
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref('https://github.com/masutayunikon/FanKarr/releases/latest')
|
||||
const updateAvailable = ref(false)
|
||||
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
|
||||
function parseVersion(v: string): number[] {
|
||||
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
if (current === 'dev') return false
|
||||
const l = parseVersion(latest)
|
||||
const c = parseVersion(current)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] ?? 0
|
||||
const cv = c[i] ?? 0
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
// Version locale
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) {
|
||||
const { version } = await vRes.json()
|
||||
currentVersion.value = version
|
||||
}
|
||||
|
||||
if (currentVersion.value === 'dev') return
|
||||
|
||||
// Dernière release GitHub
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (!gRes.ok) return
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
|
||||
|
||||
if (isNewer(latestVersion.value, currentVersion.value)) {
|
||||
updateAvailable.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdates()
|
||||
// Revérifier toutes les 6h
|
||||
setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,102 +1,42 @@
|
||||
<template>
|
||||
<div :data-tour="item.tour">
|
||||
<RouterLink
|
||||
:to="item.to ?? '/'"
|
||||
:data-tour="item.tour"
|
||||
class="group flex items-center gap-3 h-10 px-3 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="isActive ? 'bg-hover text-primary' : 'text-secondary hover:bg-hover/60 hover:text-primary'"
|
||||
>
|
||||
<component
|
||||
:is="item.to ? RouterLink : 'button'"
|
||||
:to="item.to ?? undefined"
|
||||
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors duration-100 text-left group"
|
||||
:class="isActive
|
||||
? 'bg-active text-primary'
|
||||
: 'text-secondary hover:bg-hover hover:text-primary'"
|
||||
@click="item.children && !item.to && toggleOpen()"
|
||||
:is="item.icon"
|
||||
:size="18"
|
||||
:stroke-width="1.75"
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ item.label }}</span>
|
||||
|
||||
<span
|
||||
v-if="item.badge && item.badgeStyle === 'dot'"
|
||||
class="flex items-center gap-1.5 text-xs font-bold text-secondary"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="17"
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
|
||||
/>
|
||||
<span class="flex-1 text-sm truncate">{{ item.label }}</span>
|
||||
|
||||
<!-- Badge animé (ex: downloads actifs) -->
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-white text-[10px] font-medium flex items-center justify-center"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
|
||||
<!-- Chevron (seulement si pas de badge) -->
|
||||
<ChevronRight
|
||||
v-else-if="item.children"
|
||||
:size="13"
|
||||
class="shrink-0 text-muted transition-transform duration-200"
|
||||
:class="isOpen ? 'rotate-90' : ''"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<Transition name="slide">
|
||||
<div v-if="item.children && isOpen" class="ml-[34px] mt-0.5 flex flex-col gap-0.5">
|
||||
<RouterLink
|
||||
v-for="child in item.children"
|
||||
:key="child.to"
|
||||
:to="child.to"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md transition-colors duration-100"
|
||||
:class="route.path === child.to
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:text-secondary'"
|
||||
>
|
||||
<span
|
||||
class="w-1 h-1 rounded-full shrink-0 transition-colors"
|
||||
:class="route.path === child.to ? 'bg-accent' : 'bg-current opacity-40'"
|
||||
/>
|
||||
<span class="text-xs">{{ child.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-accent" />{{ item.badge }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.badge"
|
||||
class="min-w-[22px] h-5 px-[7px] rounded-full bg-accent-muted text-accent text-xs font-bold flex items-center justify-center"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const props = defineProps<{ item: NavItem }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (!props.item.icon) return Icons.Circle
|
||||
return (Icons as Record<string, unknown>)[props.item.icon] ?? Icons.Circle
|
||||
})
|
||||
|
||||
const isActive = computed(() =>
|
||||
props.item.to
|
||||
? route.path.startsWith(props.item.to)
|
||||
: props.item.children?.some(c => route.path.startsWith(c.to))
|
||||
)
|
||||
|
||||
const isOpen = ref(isActive.value)
|
||||
|
||||
watch(isActive, (val) => { if (val) isOpen.value = true })
|
||||
|
||||
function toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
const isActive = computed(() => !!props.item.to && route.path.startsWith(props.item.to))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.15s ease;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="px-3 py-3 border-t border-border">
|
||||
<p class="text-xs text-muted mb-2 uppercase tracking-widest">Thème</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.id"
|
||||
:title="theme.label"
|
||||
@click="setTheme(theme.id)"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all duration-150 flex items-center justify-center"
|
||||
:style="{ backgroundColor: theme.color }"
|
||||
:class="current === theme.id
|
||||
? 'border-primary scale-110'
|
||||
: 'border-transparent opacity-50 hover:opacity-80'"
|
||||
>
|
||||
<span v-if="current === theme.id" class="block w-1.5 h-1.5 rounded-full bg-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
|
||||
const themes = [
|
||||
{ id: 'indigo', label: 'Indigo Électrique', color: '#7B7EF8' },
|
||||
{ id: 'crimson', label: 'Crimson Night', color: '#E74C3C' },
|
||||
{ id: 'teal', label: 'Teal Froid', color: '#00CDBF' },
|
||||
]
|
||||
|
||||
const current = ref('indigo')
|
||||
|
||||
function setTheme(id: string) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) ?? 'indigo'
|
||||
setTheme(saved)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="reject-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="reject-title" class="card-title">Refuser la demande</h2>
|
||||
<p class="text-meta text-muted truncate">{{ serieName }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="reject-message" class="field-label">Motif (facultatif)</label>
|
||||
<input id="reject-message" ref="inputRef" v-model="message" type="text" class="field" placeholder="Raison du refus…" @keyup.enter="emit('confirm', message)" />
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="emit('close')" class="btn-ghost">Annuler</button>
|
||||
<button @click="emit('confirm', message)" class="btn-danger">Refuser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
defineProps<{ serieName: string }>()
|
||||
const emit = defineEmits<{ close: []; confirm: [message: string] }>()
|
||||
|
||||
const message = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
onMounted(() => inputRef.value?.focus())
|
||||
</script>
|
||||
@@ -1,74 +1,106 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div v-if="serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
:src="serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@error="(e) => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
|
||||
</div>
|
||||
<section class="relative overflow-hidden">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover blur-[40px] saturate-[1.25] scale-[1.4] opacity-50 pointer-events-none"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-linear-100 from-main/97 via-main/86 via-52% to-main/40" />
|
||||
<div class="dot-grid [--dot-grid-angle:100deg] [--dot-grid-fade:60%]" />
|
||||
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<div class="relative grid grid-cols-[96px_minmax(0,1fr)] gap-x-4 gap-y-4 sm:flex sm:items-start sm:gap-7 px-4 md:px-10 pt-4 pb-5 sm:py-6">
|
||||
<div class="shrink-0 w-[96px] sm:w-[152px] sm:mt-[22px]">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] object-cover rounded-lg shadow-[0_22px_44px_rgb(0_0_0/0.5),0_0_0_1px_rgb(239_233_221/0.08)]"
|
||||
/>
|
||||
<div v-else class="w-[96px] h-[144px] sm:w-[152px] sm:h-[228px] rounded-lg bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
<div class="contents sm:flex sm:flex-1 sm:min-w-0 sm:flex-col sm:gap-2.5">
|
||||
<div class="min-w-0 flex flex-col gap-2.5">
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-[7px] min-h-7 text-meta text-secondary hover:text-primary transition-colors w-fit">
|
||||
<ChevronLeft :size="14" :stroke-width="2" />
|
||||
Médiathèque
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
<h1 class="font-display text-[26px] sm:text-[42px] leading-[1.1] font-extrabold text-primary">{{ serie.title }}</h1>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="serie.year" class="text-sm text-muted">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>{{ serie.status }}</span>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span v-if="serie.year" class="text-body text-secondary">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="pill h-[22px] px-2.5"
|
||||
:class="serie.status.toLowerCase() === 'continuing' ? 'pill-ok' : 'pill-neutral'"
|
||||
>{{ statusLabel(serie.status) }}</span>
|
||||
<span v-if="episodeCount" class="text-body text-secondary">
|
||||
<template v-if="seasonCount">{{ seasonCount }} saison{{ seasonCount > 1 ? 's' : '' }} · </template>{{ episodeCount }} épisode{{ episodeCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-if="watched" class="pill pill-wait h-[22px] px-2.5">
|
||||
<Rss :size="11" :stroke-width="2.5" /> Surveillée
|
||||
</span>
|
||||
<a
|
||||
v-if="serie.wiki"
|
||||
:href="serie.wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-meta text-secondary hover:text-accent transition-colors"
|
||||
>
|
||||
<ExternalLink :size="12" /> Wiki
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="serie.plot"
|
||||
@click="expanded = !expanded"
|
||||
class="text-sm text-secondary leading-relaxed max-w-2xl cursor-pointer select-none transition-all"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
>
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
<div class="col-span-2 min-w-0 flex flex-col gap-2.5 empty:hidden">
|
||||
<p
|
||||
v-if="serie.plot"
|
||||
@click="expanded = !expanded"
|
||||
class="text-body leading-[1.6] text-secondary max-w-[640px] text-pretty cursor-pointer select-none"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
:title="expanded ? 'Réduire' : 'Lire la suite'"
|
||||
>
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Lien wiki -->
|
||||
<a
|
||||
v-if="serie.wiki"
|
||||
:href="serie.wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
|
||||
>
|
||||
<ExternalLink :size="12" />
|
||||
Wiki
|
||||
</a>
|
||||
<div v-if="episodeCount" class="flex items-center gap-3.5 mt-0.5 flex-wrap">
|
||||
<div class="w-[260px] max-w-full h-1 rounded-[2px] bg-hover overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-[2px] transition-[width] duration-500"
|
||||
:class="organizedCount >= episodeCount ? 'bg-ok' : 'bg-accent'"
|
||||
:style="{ width: `${Math.round(organizedCount / episodeCount * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-meta text-secondary">{{ organizedCount }} épisode{{ organizedCount > 1 ? 's' : '' }} {{ organizedLabel }}{{ organizedCount > 1 ? 's' : '' }} sur {{ episodeCount }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2.5 mt-1.5 max-sm:flex-nowrap flex-wrap">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
|
||||
import { ChevronLeft, ExternalLink, Rss, Tv } from 'lucide-vue-next'
|
||||
import { statusLabel } from '@/utils/series'
|
||||
|
||||
defineProps<{
|
||||
serie : any
|
||||
seasonCount : number
|
||||
episodeCount : number
|
||||
organizedCount: number
|
||||
organizedLabel: string
|
||||
watched? : boolean
|
||||
}>()
|
||||
|
||||
defineProps<{ serie: any }>()
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
@@ -1,81 +1,70 @@
|
||||
<template>
|
||||
<div class="bg-card border border-border rounded-xl">
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="emit('toggle', season.id)">
|
||||
<ChevronUp :size="15" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal ml-1">
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
<template v-if="availableCount > 0"> · {{ availableCount }}/{{ season.episodes.length }} dispo</template>
|
||||
<span
|
||||
v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="season.organized_state === 'complete' ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-500'"
|
||||
>
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${availableCount || season.episodes.length} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<section class="bg-card rounded-card">
|
||||
<!-- En-tête de saison -->
|
||||
<div class="flex items-center gap-3.5 px-4 sm:px-5 py-3.5 flex-wrap" :class="{ 'border-b border-hover': !collapsed }">
|
||||
<button
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 -ml-1.5 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
|
||||
:aria-expanded="!collapsed"
|
||||
:aria-label="collapsed ? 'Déplier la saison' : 'Replier la saison'"
|
||||
@click="emit('toggle', season.id)"
|
||||
>
|
||||
<ChevronUp :size="16" :stroke-width="2" class="transition-transform duration-200" :class="collapsed ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-[160px] flex flex-col gap-[3px] cursor-pointer" @click="emit('toggle', season.id)">
|
||||
<h2 class="font-display text-[17px] font-bold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="font-sans text-sm font-normal text-muted">— {{ season.title }}</span>
|
||||
</h2>
|
||||
<p class="text-meta text-muted">{{ seasonMeta }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons saison -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Corbeille saison (admin uniquement) -->
|
||||
<button
|
||||
v-if="!requestMode && season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20"
|
||||
title="Désimporter la saison"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
|
||||
<!-- ── Mode demande ────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<!-- ── Mode demande ──── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
</span>
|
||||
<span v-else-if="isSeasonRequested" class="flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" /> {{ requestStatus === 'approved' ? 'Demandée · bientôt là' : 'Demandée · en attente de réponse' }}
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click.stop="emit('requestSeason', season.season_number)"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="isSeasonRequested
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20 cursor-default'
|
||||
: 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'"
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<component :is="isSeasonRequested ? checkIcon : requestIcon" />
|
||||
{{ isSeasonRequested ? 'Demandé' : 'Demander' }}
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
{{ missingCount === season.episodes.length ? 'Demander la saison' : `Demander ${missingCount > 1 ? `les ${missingCount} manquants` : 'le manquant'}` }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- ── Mode téléchargement (admin) ─────────────────────── -->
|
||||
<!-- ── Mode téléchargement (admin) ──────── -->
|
||||
<template v-else>
|
||||
<!-- Pas de pack saison : bouton "Saison" (dropdown si plusieurs packs via intégrales) -->
|
||||
<span v-if="season.organized_state === 'complete'" class="flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> Saison complète
|
||||
</span>
|
||||
<span v-else-if="season.organized_state === 'partial'" class="text-meta text-secondary">
|
||||
Partielle {{ season.organized_count }}/{{ availableCount || season.episodes.length }}
|
||||
</span>
|
||||
|
||||
|
||||
<template v-if="season.torrents.length === 0 && hasDownloadable">
|
||||
<div v-if="uniquePackOptions.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
|
||||
<Download :size="14" />
|
||||
Télécharger {{ downloadableLabel }}
|
||||
<ChevronDown :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||||
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="opt in uniquePackOptions"
|
||||
:key="opt.infohash"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
@click="emit('downloadSeason', season, opt.infohash); seasonMenuOpen = false"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
{{ opt.label }}
|
||||
<Download :size="14" class="shrink-0" />
|
||||
<span class="truncate">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,20 +72,18 @@
|
||||
v-else
|
||||
@click="emit('downloadSeason', season)"
|
||||
:disabled="downloadingSeason"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border bg-accent-muted text-accent border-accent/20 hover:bg-accent/20 transition"
|
||||
class="btn-secondary btn-sm"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Saison' }}
|
||||
<Loader v-if="downloadingSeason" :size="14" class="animate-spin" />
|
||||
<Download v-else :size="14" />
|
||||
{{ downloadingSeason ? 'Envoi…' : `Télécharger ${downloadableLabel}` }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Pack saison unique -->
|
||||
<button
|
||||
v-if="season.torrents.length === 1"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="canDownloadSeason
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
v-if="season.torrents.length === 1 && season.organized_state !== 'complete'"
|
||||
class="btn-secondary btn-sm"
|
||||
:disabled="!canDownloadSeason"
|
||||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
@@ -104,223 +91,209 @@
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Dropdown packs saison multiples -->
|
||||
<div v-if="season.torrents.length > 1" class="relative" @click.stop>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition bg-accent-muted text-accent border-accent/20 hover:bg-accent/20"
|
||||
@click="seasonMenuOpen = !seasonMenuOpen"
|
||||
>
|
||||
<component :is="downloadIcon" />
|
||||
Saison ▾
|
||||
<!-- Packs saison multiples -->
|
||||
<div v-if="season.torrents.length > 1 && season.organized_state !== 'complete'" class="relative" @click.stop>
|
||||
<button class="btn-secondary btn-sm" @click="seasonMenuOpen = !seasonMenuOpen" aria-haspopup="menu" :aria-expanded="seasonMenuOpen">
|
||||
<Download :size="14" />
|
||||
Télécharger la saison
|
||||
<ChevronDown :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<div v-if="seasonMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in (season.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="menu-item"
|
||||
:disabled="isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`season-${season.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `season-${season.id}-${i}`, t.torrent_url, t.magnet), seasonMenuOpen = false)"
|
||||
>
|
||||
<component :is="(isDownloaded(`season-${season.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(season.torrents)[i] }}
|
||||
<span class="truncate">{{ groupLabels(season.torrents)[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Désimport de la saison -->
|
||||
<button
|
||||
v-if="season.organized_count > 0"
|
||||
@click.stop="openUnimportSeasonModal"
|
||||
class="w-8 h-8 pointer-coarse:w-10 pointer-coarse:h-10 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Désimporter la saison"
|
||||
aria-label="Désimporter la saison"
|
||||
>
|
||||
<Trash2 :size="15" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||||
<div v-if="!collapsed">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors last:rounded-b-xl"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
class="px-4 sm:px-5 py-2 border-t border-hover first:border-t-0 hover:bg-hover/30 transition-colors last:rounded-b-card"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div class="flex items-center gap-3 sm:gap-3.5">
|
||||
<!-- Vignette -->
|
||||
<div
|
||||
class="shrink-0 w-[64px] h-[36px] sm:w-[88px] sm:h-[50px] rounded-[4px] overflow-hidden bg-main flex items-center justify-center text-muted text-xs"
|
||||
:class="{ 'opacity-50': !requestMode && !ep.available && !ep.organized }"
|
||||
>
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-[26px] shrink-0 text-[11.5px] text-muted tabular-nums max-sm:hidden">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<span class="text-[14.5px] font-medium truncate" :class="!requestMode && !ep.available && !ep.organized ? 'text-muted' : 'text-primary'">
|
||||
{{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
class="shrink-0 h-[19px] px-[7px] rounded-[4px] border border-border text-[10.5px] font-bold text-muted flex items-center"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
>Hors Fankai</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
<!-- Badge langue -->
|
||||
<span
|
||||
v-if="epLang(ep) === 'MULTI'"
|
||||
title="Audio japonais + français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-blue-500/10 border-blue-500/20 text-blue-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
MULTI
|
||||
</span>
|
||||
<span
|
||||
v-else-if="epLang(ep) === 'VOSTFR'"
|
||||
title="Audio japonais · Sous-titres français"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-[10px] font-semibold leading-none px-1.5 py-0.5 rounded border bg-amber-500/10 border-amber-500/20 text-amber-300 select-none tracking-wide"
|
||||
>
|
||||
<!-- JP flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#fff"/><circle cx="7" cy="5" r="3" fill="#BC002D"/></svg>
|
||||
<!-- FR flag -->
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
|
||||
VOSTFR
|
||||
</span>
|
||||
<!-- Badge épisodes -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).episodes"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
|
||||
:title="`Plage d'épisodes couverts`"
|
||||
>{{ parseEpChap(ep.plot).episodes }}</span>
|
||||
<!-- Badge chapitres -->
|
||||
<span
|
||||
v-if="parseEpChap(ep.plot).chapters"
|
||||
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
|
||||
:title="`Plage de chapitres manga couverts`"
|
||||
>{{ parseEpChap(ep.plot).chapters }}</span>
|
||||
</div>
|
||||
<span v-if="episodeMeta(ep)" class="sm:pl-9 text-meta text-muted truncate" :title="episodeMeta(ep)">{{ episodeMeta(ep) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Bouton épisode -->
|
||||
<div v-if="!ep.organized" class="relative shrink-0" @click.stop>
|
||||
<!-- Mode demande -->
|
||||
<button
|
||||
v-if="requestMode"
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="isEpisodeRequested(ep.id)
|
||||
? 'border-blue-500/30 text-blue-400 bg-blue-500/10 cursor-default'
|
||||
: 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'"
|
||||
:title="isEpisodeRequested(ep.id) ? 'Déjà demandé' : 'Demander cet épisode'"
|
||||
@click="handleEpRequestClick(ep)"
|
||||
>
|
||||
<component :is="isEpisodeRequested(ep.id) ? checkIcon : requestIcon" />
|
||||
</button>
|
||||
<!-- Mode téléchargement (admin) -->
|
||||
<button
|
||||
v-else
|
||||
class="w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="epState(ep) !== 'idle'"
|
||||
@click="handleEpBtnClick(ep)"
|
||||
>
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown sélection torrent — mode demande -->
|
||||
<div v-if="requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-72 shadow-xl flex flex-col gap-0.5">
|
||||
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
|
||||
>
|
||||
<component :is="requestIcon" class="shrink-0 mt-0.5" />
|
||||
<span class="whitespace-normal break-words leading-relaxed">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
|
||||
<!-- ── État et actions, mode demande ─────────────────── -->
|
||||
<template v-if="requestMode">
|
||||
<span v-if="ep.organized" class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Disponible</span>
|
||||
</span>
|
||||
<span v-else-if="isEpisodeRequested(ep.id)" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" /> <span class="max-sm:sr-only">{{ requestStatus === 'approved' ? 'Demandé · bientôt là' : 'Demandé' }}</span>
|
||||
</span>
|
||||
<div v-else class="relative shrink-0" @click.stop>
|
||||
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpRequestClick(ep)" title="Demander l'épisode" aria-label="Demander l'épisode">
|
||||
<Plus :size="13" :stroke-width="2" /> <span class="max-sm:hidden">Demander</span>
|
||||
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-80 max-w-[90vw] z-20" role="menu">
|
||||
<p class="tag-label px-3 pt-1.5 pb-1">Choisir le torrent</p>
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="menu-item items-start"
|
||||
@click="emit('requestEpisode', season.season_number, ep.id, t); epOptionsOpen = null"
|
||||
>
|
||||
<Clock3 :size="14" class="shrink-0 mt-0.5" />
|
||||
<span class="whitespace-normal break-words">{{ t.raw ?? t.torrent_name ?? `Option ${i + 1}` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── État et actions, admin ────────────────────────── -->
|
||||
<template v-else>
|
||||
<template v-if="ep.organized">
|
||||
<button
|
||||
v-if="organizedByEpisode[String(ep.id)] && epNeedsRename(ep)"
|
||||
@click.stop="emit('renameEpisode', ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="pill pill-wait h-[26px] px-2.5 shrink-0 hover:bg-accent-muted transition-colors"
|
||||
:title="`Le fichier ne porte pas le nom attendu.\nActuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>
|
||||
<Loader v-if="epActionLoading[ep.id]" :size="12" class="animate-spin" />
|
||||
<PencilLine v-else :size="12" :stroke-width="2.25" />
|
||||
<span class="max-sm:hidden">Renommer</span>
|
||||
</button>
|
||||
<span class="shrink-0 flex items-center gap-[7px] text-meta text-ok">
|
||||
<Check :size="14" :stroke-width="2.5" /> <span class="max-sm:sr-only">Importé</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span v-else-if="epState(ep) === 'loading'" class="shrink-0 flex items-center gap-[7px] text-meta text-accent">
|
||||
<Clock3 :size="14" :stroke-width="2" />
|
||||
<template v-if="epProgress(ep) && epProgress(ep)!.progress > 0 && epProgress(ep)!.progress < 100"><span class="max-sm:hidden">Téléchargement · </span>{{ epProgress(ep)!.progress }} %</template>
|
||||
<span v-else class="max-sm:sr-only">En file d'attente</span>
|
||||
</span>
|
||||
|
||||
<span v-else-if="epState(ep) === 'unavailable'" class="shrink-0 text-meta text-muted">Sans torrent</span>
|
||||
|
||||
<div v-else class="relative shrink-0" @click.stop>
|
||||
<button class="btn-secondary btn-sm pointer-fine:h-[30px] max-sm:w-10 max-sm:px-0" @click="handleEpBtnClick(ep)" title="Télécharger l'épisode" aria-label="Télécharger l'épisode">
|
||||
<Download :size="13" /> <span class="max-sm:hidden">Télécharger</span>
|
||||
<ChevronDown v-if="ep.torrents && ep.torrents.length > 1" :size="12" :stroke-width="2.5" />
|
||||
</button>
|
||||
<div v-if="epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1" class="menu absolute right-0 bottom-full mb-1.5 w-72 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="menu-item"
|
||||
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
|
||||
>
|
||||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
<span class="truncate">{{ groupLabels(ep.torrents)[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown sélection torrent — mode téléchargement -->
|
||||
<div v-if="!requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
:class="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
:disabled="isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)"
|
||||
@click="!isDownloaded(`ep-${ep.id}-${i}`) && !isAlreadyQueued(t) && (emit('download', `ep-${ep.id}-${i}`, t.torrent_url, t.magnet, t.file_index ?? null, t.file_path ?? null, t.infohash ?? null), epOptionsOpen = null)"
|
||||
>
|
||||
<component :is="(isDownloaded(`ep-${ep.id}-${i}`) || isAlreadyQueued(t)) ? checkIcon : downloadIcon" />
|
||||
{{ groupLabels(ep.torrents)[i] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions épisode importé (admin uniquement) -->
|
||||
<template v-if="!requestMode && ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<!-- Badge rename cliquable -->
|
||||
<button
|
||||
v-if="epNeedsRename(ep)"
|
||||
@click.stop="emit('renameEpisode', ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 hover:bg-yellow-500/20 transition-colors"
|
||||
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>Rename</button>
|
||||
<!-- Corbeille épisode -->
|
||||
<button
|
||||
v-if="ep.organized && organizedByEpisode[String(ep.id)]"
|
||||
@click.stop="openUnimportModal(ep, season)"
|
||||
:disabled="epActionLoading[ep.id]"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-err/10 transition-colors"
|
||||
title="Désimporter l'épisode"
|
||||
aria-label="Désimporter l'épisode"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Chevron synopsis (à droite, visible si l'épisode a un plot) -->
|
||||
<!-- Synopsis -->
|
||||
<button
|
||||
v-if="ep.plot"
|
||||
@click.stop="togglePlot(ep.id)"
|
||||
class="w-6 h-6 flex items-center justify-center rounded transition-colors shrink-0"
|
||||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary'"
|
||||
class="w-[30px] h-[30px] pointer-coarse:w-10 pointer-coarse:h-10 shrink-0 rounded-full flex items-center justify-center transition-colors"
|
||||
:class="plotOpen === ep.id ? 'text-accent' : 'text-muted hover:text-primary hover:bg-hover'"
|
||||
:aria-expanded="plotOpen === ep.id"
|
||||
title="Voir le synopsis"
|
||||
>
|
||||
<ChevronUp :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? '' : 'rotate-180'" />
|
||||
<ChevronDown :size="14" class="transition-transform duration-200" :class="plotOpen === ep.id ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="mt-1.5 sm:pl-[92px]">
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
<!-- Progression de l'épisode -->
|
||||
<div v-if="epProgress(ep) && epProgress(ep)!.progress < 100" class="progress mt-2 sm:ml-[138px]">
|
||||
<div class="progress-bar" :style="{ width: `${epProgress(ep)!.progress}%` }" />
|
||||
</div>
|
||||
|
||||
<!-- Synopsis -->
|
||||
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
|
||||
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
|
||||
</div>
|
||||
<p v-if="plotOpen === ep.id && ep.plot" class="mt-2 mb-1 sm:ml-[138px] text-meta text-secondary leading-relaxed whitespace-pre-line max-w-[760px]">
|
||||
{{ parseEpChap(ep.plot).cleanPlot }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal désimport épisode -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter l'épisode</h3>
|
||||
<p class="text-xs text-muted mb-4">L'épisode sera retiré de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer le fichier du disque</span>
|
||||
<div v-if="unimportModal" class="modal-backdrop" @click.self="unimportModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-ep-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-ep-title" class="card-title">Désimporter l'épisode</h3>
|
||||
<p class="text-meta text-muted">L'épisode sera retiré de la bibliothèque.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteFileOnUnimport" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer le fichier du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="unimportModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimport" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimport" class="btn-danger">
|
||||
{{ deleteFileOnUnimport ? 'Supprimer' : 'Désimporter' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,17 +303,19 @@
|
||||
|
||||
<!-- Modal désimport saison -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSeasonModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" @click.self="unimportSeasonModal = false">
|
||||
<div class="bg-card border border-border rounded-2xl p-6 w-full max-w-sm shadow-2xl mx-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Désimporter la saison</h3>
|
||||
<p class="text-xs text-muted mb-4">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
|
||||
<label class="flex items-center gap-2 mb-5 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="accent-red-500" />
|
||||
<span class="text-xs text-muted">Supprimer les fichiers du disque</span>
|
||||
<div v-if="unimportSeasonModal" class="modal-backdrop" @click.self="unimportSeasonModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-season-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="unimport-season-title" class="card-title">Désimporter la saison</h3>
|
||||
<p class="text-meta text-muted">Tous les épisodes importés de cette saison seront retirés de la bibliothèque.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSeasonFiles" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer les fichiers du disque</span>
|
||||
</label>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="px-4 py-2 text-xs rounded-lg border border-border text-muted hover:bg-hover transition-colors">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="px-4 py-2 text-xs rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 transition-colors">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="unimportSeasonModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimportSeason" class="btn-danger">
|
||||
{{ deleteSeasonFiles ? 'Supprimer' : 'Désimporter' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -351,7 +326,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted, onUnmounted } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X, Trash2 } from 'lucide-vue-next'
|
||||
import { ChevronDown, ChevronUp, Clock3, Download, Loader, Check, PencilLine, Plus, X, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number; priority?: number }[] }
|
||||
|
||||
@@ -368,6 +343,7 @@ const props = defineProps<{
|
||||
requestMode ?: boolean // true = boutons demande au lieu de téléchargement
|
||||
requestedSeasons ?: number[] // saisons déjà demandées par cet user pour cette série
|
||||
requestedEpisodes ?: number[] // IDs d'épisodes déjà demandés par cet user pour cette série
|
||||
requestStatus ?: string // statut de la demande en cours de cet user
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -382,10 +358,6 @@ const emit = defineEmits<{
|
||||
requestEpisode : [seasonNumber: number, episodeId: number, torrent?: any]
|
||||
}>()
|
||||
|
||||
const requestIcon = h('svg', { viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': 2, fill: 'none', width: 12, height: 12 },
|
||||
[h('circle', { cx: 12, cy: 12, r: 10 }), h('path', { d: 'M12 8v4l2 2' })]
|
||||
)
|
||||
|
||||
const isSeasonRequested = computed(() =>
|
||||
props.requestedSeasons?.includes(props.season.season_number) ?? false
|
||||
)
|
||||
@@ -403,9 +375,8 @@ const deleteFileOnUnimport = ref(false)
|
||||
const unimportEpTarget = ref<{ ep: any; season: any } | null>(null)
|
||||
const unimportSeasonModal = ref(false)
|
||||
const deleteSeasonFiles = ref(false)
|
||||
const downloadIcon = h(Download, { size: 12 })
|
||||
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
|
||||
const checkIcon = h(Check, { size: 12 })
|
||||
const downloadIcon = h(Download, { size: 14, class: 'shrink-0' })
|
||||
const checkIcon = h(Check, { size: 14, class: 'shrink-0' })
|
||||
|
||||
// ── Helpers hash / progress ────────────────────────────────────
|
||||
function extractHash(torrent: any): string | null {
|
||||
@@ -442,18 +413,52 @@ const canDownloadSeason = computed(() =>
|
||||
!!props.season.torrent && !isDownloading(`season-${props.season.id}`) && !isDownloaded(`season-${props.season.id}`) && !isAlreadyQueued(props.season.torrent) && props.season.organized_state !== 'complete'
|
||||
)
|
||||
const seasonBtnLabel = computed(() => {
|
||||
if (props.season.organized_state === 'complete') return 'Importé'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajouté'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyé'
|
||||
return 'Saison entière'
|
||||
if (props.season.organized_state === 'complete') return 'Importée'
|
||||
if (isAlreadyQueued(props.season.torrent)) return 'Déjà ajoutée'
|
||||
if (isDownloaded(`season-${props.season.id}`)) return 'Envoyée'
|
||||
return 'Télécharger la saison'
|
||||
})
|
||||
const seasonBtnIcon = computed(() => {
|
||||
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 12 })
|
||||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 12, class: 'animate-spin' })
|
||||
if (!props.season.torrent) return h(X, { size: 12 })
|
||||
return h(Download, { size: 12 })
|
||||
if (props.season.organized_state === 'complete' || isDownloaded(`season-${props.season.id}`) || isAlreadyQueued(props.season.torrent)) return h(Check, { size: 14 })
|
||||
if (isDownloading(`season-${props.season.id}`)) return h(Loader, { size: 14, class: 'animate-spin' })
|
||||
if (!props.season.torrent) return h(X, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
})
|
||||
|
||||
// ── Résumés de saison ──────────────────────────────────────────
|
||||
const queuedCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized && epState(ep) === 'loading').length)
|
||||
const missingCount = computed(() => props.season.episodes.filter((ep: any) => !ep.organized).length)
|
||||
const downloadableCount = computed(() =>
|
||||
props.season.episodes.filter((ep: any) => ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)).length
|
||||
)
|
||||
const downloadableLabel = computed(() => {
|
||||
if (downloadableCount.value === props.season.episodes.length) return 'la saison'
|
||||
return downloadableCount.value > 1 ? `les ${downloadableCount.value} manquants` : 'le manquant'
|
||||
})
|
||||
|
||||
// Admin : « 12 épisodes · 12 disponibles · 5 importés · 1 en file » ; invité : ce qui est déjà visible
|
||||
const seasonMeta = computed(() => {
|
||||
const total = props.season.episodes.length
|
||||
const parts = [`${total} épisode${total > 1 ? 's' : ''}`]
|
||||
if (props.requestMode) {
|
||||
const ready = props.season.organized_count ?? 0
|
||||
parts.push(ready === 0 ? 'aucun disponible' : ready >= total ? 'tous disponibles' : `${ready} disponible${ready > 1 ? 's' : ''}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
if (availableCount.value > 0) parts.push(`${availableCount.value} disponible${availableCount.value > 1 ? 's' : ''}`)
|
||||
if (props.season.organized_count > 0) parts.push(`${props.season.organized_count} importé${props.season.organized_count > 1 ? 's' : ''}`)
|
||||
if (queuedCount.value > 0) parts.push(`${queuedCount.value} en file`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
// Date · durée · langue · sources · plages d'épisodes et de chapitres, en texte
|
||||
function episodeMeta(ep: any): string {
|
||||
const { episodes, chapters } = parseEpChap(ep.plot)
|
||||
const sources = ep.torrents?.length > 1 ? `${ep.torrents.length} sources` : ''
|
||||
return [ep.aired && formatDate(ep.aired), ep.duration && formatDuration(ep.duration), epLang(ep), sources, episodes, chapters]
|
||||
.filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// ── État épisode ───────────────────────────────────────────────
|
||||
|
||||
// Vérifie si n'importe quelle clé de téléchargement pour cet épisode est marquée done/downloading
|
||||
@@ -543,21 +548,6 @@ function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
function epStateIcon(ep: any) {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return h(Check, { size: 14 })
|
||||
if (state === 'loading') return h(Loader, { size: 14, class: 'animate-spin' })
|
||||
if (state === 'unavailable') return h(X, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
}
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
}
|
||||
|
||||
// ── Rename helpers ─────────────────────────────────────────────
|
||||
function epExpectedName(ep: any): string {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
@@ -714,11 +704,12 @@ function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
// 4 h 10, 24 min
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
if (h > 0) return m > 0 ? `${h} h ${String(m).padStart(2, '0')}` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toolbar -->
|
||||
<div data-tour="series-toolbar" 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="flex flex-col gap-[18px]">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
<header class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5">
|
||||
<h1 class="page-title tracking-[0.01em]">Médiathèque</h1>
|
||||
<span class="text-[13px] text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<div class="flex items-center gap-2.5 w-full md:w-auto">
|
||||
<label class="flex-1 md:flex-none md:w-[320px] h-10 rounded-full bg-card border border-border-light flex items-center gap-2.5 pl-4 pr-2 cursor-text focus-within:border-accent transition-colors">
|
||||
<Search :size="16" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
<input
|
||||
data-library-search
|
||||
:value="search"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
@keydown.esc="emit('update:search', '')"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
:placeholder="selectable ? 'Rechercher dans la médiathèque' : 'Rechercher une série'"
|
||||
aria-label="Rechercher une série"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="relative shrink-0" ref="filterRef">
|
||||
<button
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
|
||||
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
|
||||
title="Filtres"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span class="hidden md:inline text-xs">Filtres</span>
|
||||
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
|
||||
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
<button v-if="search" @click="emit('update:search', '')" aria-label="Effacer la recherche" class="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors">
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersDisponibilite" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-muted mb-1.5">État import</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersImport" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
|
||||
{{ f.label }}
|
||||
</div>
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasActiveFilter"
|
||||
@click="emit('update:activeFilter', 'all')"
|
||||
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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' : ''"
|
||||
title="Sélectionner plusieurs séries"
|
||||
class="h-10 px-4 rounded-full border text-body font-medium flex items-center gap-2 shrink-0 transition-colors"
|
||||
:class="selecting ? 'border-accent text-accent bg-accent-muted' : 'border-border-light text-secondary hover:text-primary hover:bg-hover'"
|
||||
:title="selecting ? 'Quitter la sélection' : 'Sélectionner plusieurs séries'"
|
||||
>
|
||||
<ListChecks :size="15" />
|
||||
<span class="hidden md:inline text-xs">Sélection</span>
|
||||
<SquareCheck :size="16" :stroke-width="1.75" />
|
||||
<span class="hidden sm:inline">Sélectionner</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filtres, tri et taille -->
|
||||
<div ref="rowRef" class="relative">
|
||||
<div class="chip-row md:justify-between md:gap-x-4 md:gap-y-2.5" @scroll="moreOpen = false; sortOpen = false">
|
||||
<div class="flex items-center gap-1.5 md:flex-wrap md:shrink md:min-w-0">
|
||||
<button
|
||||
v-for="f in filters" :key="f.value"
|
||||
@click="emit('update:activeFilter', f.value)"
|
||||
class="chip shrink-0"
|
||||
:class="{ 'is-active': activeFilter === f.value }"
|
||||
:aria-pressed="activeFilter === f.value"
|
||||
>
|
||||
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.attention && f.count > 0 && activeFilter !== f.value }">{{ f.count }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="moreFilters.length"
|
||||
ref="moreBtnRef"
|
||||
@click="toggleMore"
|
||||
class="chip shrink-0 pr-3"
|
||||
:class="{ 'is-active': activeMore }"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="moreOpen"
|
||||
>
|
||||
{{ activeMore ? activeMore.label : 'Plus' }}<span v-if="activeMore" class="chip-count">{{ activeMore.count }}</span>
|
||||
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': moreOpen }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button ref="sortBtnRef" @click="toggleSort" class="chip shrink-0 pr-3" aria-haspopup="menu" :aria-expanded="sortOpen">
|
||||
{{ sortOptions.find(s => s.value === activeSort)?.label }}
|
||||
<ChevronDown :size="14" class="transition-transform" :class="{ 'rotate-180': sortOpen }" />
|
||||
</button>
|
||||
|
||||
<!-- Sur mobile, la grille garde deux colonnes -->
|
||||
<div class="segmented max-sm:hidden" role="group" aria-label="Taille des affiches">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="emit('update:posterSize', size)"
|
||||
class="segmented-item w-[34px] justify-center px-0 text-[11.5px] font-bold"
|
||||
:class="posterSize === size ? 'bg-hover text-primary' : 'text-muted'"
|
||||
:aria-pressed="posterSize === size"
|
||||
:title="`Affiches ${size}`"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="moreOpen" ref="moreMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-56 z-20" :style="{ left: `${menuLeft}px` }">
|
||||
<button
|
||||
v-for="f in moreFilters" :key="f.value"
|
||||
role="menuitem"
|
||||
@click="emit('update:activeFilter', activeFilter === f.value ? 'all' : f.value); moreOpen = false"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeFilter === f.value }"
|
||||
>
|
||||
{{ f.label }}<span class="text-meta text-muted">{{ f.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="emit('update:activeSort', s.value)"
|
||||
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="activeSort === s.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="emit('update:posterSize', size)"
|
||||
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
|
||||
:class="posterSize === size
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted">{{ count }} série{{ count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende barres -->
|
||||
<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>
|
||||
<div v-if="sortOpen" ref="sortMenuRef" role="menu" class="menu absolute top-full mt-1.5 w-44 z-20" :style="{ left: `${menuLeft}px` }">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
role="menuitem"
|
||||
@click="emit('update:activeSort', s.value); sortOpen = false"
|
||||
class="menu-item justify-between"
|
||||
:class="{ 'text-primary! bg-hover': activeSort === s.value }"
|
||||
>
|
||||
{{ s.label }}<Check v-if="activeSort === s.value" :size="14" class="text-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown, ListChecks } from 'lucide-vue-next'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Check, ChevronDown, Search, SquareCheck, X } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
search : string
|
||||
activeFilter : string
|
||||
activeSort : string
|
||||
posterSize : string
|
||||
posterSizes : Record<string, string>
|
||||
count : number
|
||||
hasActiveFilter : boolean
|
||||
filtersDisponibilite: { label: string; value: string }[]
|
||||
filtersImport : { label: string; value: string; color: string }[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
legend : { label: string; color: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
export interface LibraryFilter { value: string; label: string; count: number; attention?: boolean }
|
||||
|
||||
const props = defineProps<{
|
||||
search : string
|
||||
activeFilter : string
|
||||
activeSort : string
|
||||
posterSize : string
|
||||
posterSizes : Record<string, string>
|
||||
count : number
|
||||
filters : LibraryFilter[]
|
||||
moreFilters : LibraryFilter[]
|
||||
sortOptions : { label: string; value: string }[]
|
||||
selectable? : boolean
|
||||
selecting? : boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -161,8 +145,38 @@ const emit = defineEmits<{
|
||||
'update:selecting' : [value: boolean]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
const activeMore = computed(() => props.moreFilters.find(f => f.value === props.activeFilter) ?? null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
const moreOpen = ref(false)
|
||||
const sortOpen = ref(false)
|
||||
const rowRef = ref<HTMLElement | null>(null)
|
||||
const moreBtnRef = ref<HTMLElement | null>(null)
|
||||
const sortBtnRef = ref<HTMLElement | null>(null)
|
||||
const moreMenuRef = ref<HTMLElement | null>(null)
|
||||
const sortMenuRef = ref<HTMLElement | null>(null)
|
||||
const menuLeft = ref(0)
|
||||
|
||||
onClickOutside(moreMenuRef, () => { moreOpen.value = false }, { ignore: [moreBtnRef] })
|
||||
onClickOutside(sortMenuRef, () => { sortOpen.value = false }, { ignore: [sortBtnRef] })
|
||||
|
||||
// Menu sous son bouton, aligné à droite s'il déborde de la ligne
|
||||
function placeMenu(btn: HTMLElement | null, width: number) {
|
||||
if (!btn || !rowRef.value) return
|
||||
const row = rowRef.value.getBoundingClientRect()
|
||||
const b = btn.getBoundingClientRect()
|
||||
const left = b.left - row.left + width > row.width ? b.right - row.left - width : b.left - row.left
|
||||
menuLeft.value = Math.max(0, left)
|
||||
}
|
||||
|
||||
function toggleMore() {
|
||||
sortOpen.value = false
|
||||
moreOpen.value = !moreOpen.value
|
||||
if (moreOpen.value) placeMenu(moreBtnRef.value, 224)
|
||||
}
|
||||
|
||||
function toggleSort() {
|
||||
moreOpen.value = false
|
||||
sortOpen.value = !sortOpen.value
|
||||
if (sortOpen.value) placeMenu(sortBtnRef.value, 176)
|
||||
}
|
||||
</script>
|
||||
@@ -1,36 +1,39 @@
|
||||
<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 class="flex flex-col gap-4">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="jellyfin-url" class="field-label">Adresse du serveur</label>
|
||||
<input id="jellyfin-url" v-model="jellyfinUrl" type="url" class="field" placeholder="http://jellyfin:8096" />
|
||||
<p class="text-xs text-muted">Adresse joignable depuis FanKarr (réseau local ou Docker).</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="jellyfin-token" class="field-label">Clé API administrateur</label>
|
||||
<input id="jellyfin-token" v-model="jellyfinAdminToken" type="password" class="field" autocomplete="off"
|
||||
:placeholder="hasToken ? '•••••••••••••••••••• (configurée)' : 'Tableau de bord → Clés API'" />
|
||||
<p class="text-xs text-muted">Dans Jellyfin : Tableau de bord → Clés API → Créer une clé.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<button @click="save" :disabled="saving" class="btn-primary pointer-fine:h-[38px]">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button @click="testConnection" :disabled="testing" class="btn-secondary">
|
||||
<button @click="testConnection" :disabled="testing" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ 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 v-if="testResult" class="pill" :class="testResult.ok ? 'pill-ok' : 'pill-err'">
|
||||
<Check v-if="testResult.ok" :size="12" :stroke-width="2.5" />
|
||||
<TriangleAlert v-else :size="12" :stroke-width="2.25" />
|
||||
{{ testResult.ok ? `Connecté · Jellyfin ${testResult.version}` : testResult.error }}
|
||||
</span>
|
||||
<span v-if="saved" class="text-xs text-green-400">Enregistré.</span>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Check, TriangleAlert } from 'lucide-vue-next'
|
||||
|
||||
interface JellyfinTestResult { ok: boolean; version?: string; error?: string }
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="emit('close')">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-lg flex flex-col" style="max-height: 85vh">
|
||||
<div class="modal-backdrop" @click.self="emit('close')">
|
||||
<div class="bg-card border border-border rounded-card w-full max-w-lg flex flex-col max-h-[85vh] overflow-y-auto shadow-[0_24px_60px_rgb(0_0_0/0.55)]" role="dialog" aria-modal="true" aria-labelledby="plex-title">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 border-b border-hover shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Configuration Plex</h3>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
<h3 id="plex-title" class="font-display text-xl font-bold text-primary">Configuration Plex</h3>
|
||||
<p class="text-meta text-muted mt-0.5">
|
||||
{{ plexStep === 'auth' ? 'Connectez votre compte Plex' :
|
||||
plexStep === 'server' ? 'Choisissez votre serveur Plex' :
|
||||
plexStep === 'library' ? 'Créez la bibliothèque Fankai' :
|
||||
@@ -15,7 +15,7 @@
|
||||
'Erreur' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
|
||||
<button @click="emit('close')" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Auth -->
|
||||
@@ -23,32 +23,27 @@
|
||||
<div v-if="!plexAuthMethod" class="flex flex-col gap-3">
|
||||
<button
|
||||
@click="startOAuth" :disabled="plexLoading"
|
||||
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-accent hover:bg-accent-muted transition-colors text-left"
|
||||
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:border-accent/40 hover:bg-accent-muted transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#E5A50A" opacity=".2"/>
|
||||
<path d="M12 6v6l4 2" stroke="#E5A50A" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<div class="w-8 h-8 rounded-full bg-accent-muted text-accent flex items-center justify-center shrink-0">
|
||||
<Clock3 :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">{{ plexLoading ? 'Ouverture…' : 'Connexion via Plex' }}</p>
|
||||
<p class="text-xs text-muted">Google, Apple, ou compte Plex</p>
|
||||
<p class="text-meta text-muted">Google, Apple, ou compte Plex</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="plexAuthMethod = 'credentials'"
|
||||
class="flex items-center gap-3 p-4 rounded-lg border border-border hover:border-secondary transition-colors text-left"
|
||||
class="flex items-center gap-3 p-4 rounded-field border border-border-light hover:bg-hover transition-colors text-left"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-hover flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
<div class="w-8 h-8 rounded-full bg-hover text-secondary flex items-center justify-center shrink-0">
|
||||
<User :size="16" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Email / Mot de passe</p>
|
||||
<p class="text-xs text-muted">Connexion directe avec vos identifiants</p>
|
||||
<p class="text-sm font-medium text-primary">E-mail et mot de passe</p>
|
||||
<p class="text-meta text-muted">Connexion directe avec vos identifiants</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,26 +53,26 @@
|
||||
<div class="w-10 h-10 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">En attente de la connexion Plex…</p>
|
||||
<p class="text-xs text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
|
||||
<p class="text-meta text-muted mt-1">Connectez-vous sur la page Plex qui s'est ouverte dans votre navigateur</p>
|
||||
</div>
|
||||
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">Annuler</button>
|
||||
<button @click="cancelOAuth" class="btn-ghost btn-sm">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire credentials -->
|
||||
<template v-else-if="plexAuthMethod === 'credentials'">
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Adresse e-mail Plex</label>
|
||||
<input v-model="plexForm.username" type="email" class="settings-input w-full" placeholder="email@example.com" />
|
||||
<label class="field-label mb-1 block">Adresse e-mail Plex</label>
|
||||
<input v-model="plexForm.username" type="email" class="field w-full" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Mot de passe</label>
|
||||
<input v-model="plexForm.password" type="password" class="settings-input w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
|
||||
<label class="field-label mb-1 block">Mot de passe</label>
|
||||
<input v-model="plexForm.password" type="password" class="field w-full" placeholder="••••••••" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<div v-if="plexNeeds2FA">
|
||||
<label class="settings-label mb-1 block">Code 2FA</label>
|
||||
<input v-model="plexForm.code" type="text" class="settings-input w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
|
||||
<label class="field-label mb-1 block">Code 2FA</label>
|
||||
<input v-model="plexForm.code" type="text" class="field w-full" placeholder="123456" maxlength="6" @keyup.enter="plexConnect" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
|
||||
@@ -86,26 +81,26 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<p v-if="plexError && !plexAuthMethod" class="text-meta text-err">{{ plexError }}</p>
|
||||
<button v-if="!plexAuthMethod" @click="emit('close')" class="btn-secondary self-start">Annuler</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Choix serveur -->
|
||||
<div v-else-if="plexStep === 'server'" class="flex flex-col gap-3 px-6 py-5">
|
||||
<p class="text-xs text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
|
||||
<p class="text-meta text-muted">{{ plexServers.length }} serveur{{ plexServers.length > 1 ? 's' : '' }} trouvé{{ plexServers.length > 1 ? 's' : '' }}</p>
|
||||
<div
|
||||
v-for="(server, i) in plexServers" :key="i"
|
||||
@click="selectServer(server)"
|
||||
class="flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors"
|
||||
:class="plexSelectedServer === server ? 'border-accent bg-accent-muted' : 'border-border hover:border-secondary'"
|
||||
class="flex items-center justify-between p-3 rounded-field border cursor-pointer transition-colors"
|
||||
:class="plexSelectedServer === server ? 'border-accent/50 bg-accent-muted' : 'border-border-light hover:bg-hover'"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ server.name }}</p>
|
||||
<p class="text-xs text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
|
||||
<p class="text-meta text-muted">{{ server.owned ? 'Propriétaire' : 'Partagé' }} · {{ server.connections.length }} connexion{{ server.connections.length > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
<svg v-if="plexSelectedServer === server" width="14" height="14" viewBox="0 0 24 24" stroke="#e5a50a" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<Check v-if="plexSelectedServer === server" :size="15" :stroke-width="2.5" class="text-accent" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
|
||||
@@ -117,15 +112,15 @@
|
||||
<!-- Étape 3 : Bibliothèque -->
|
||||
<div v-else-if="plexStep === 'library'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Nom de la bibliothèque</label>
|
||||
<input v-model="plexForm.libraryName" type="text" class="settings-input w-full" placeholder="Fankai" />
|
||||
<label class="field-label mb-1 block">Nom de la bibliothèque</label>
|
||||
<input v-model="plexForm.libraryName" type="text" class="field w-full" placeholder="Fankai" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1 block">Chemin de la médiathèque</label>
|
||||
<label class="field-label mb-1 block">Chemin de la médiathèque</label>
|
||||
<p class="text-xs text-muted mb-1.5">Chemin tel que vu par votre serveur Plex</p>
|
||||
<input v-model="plexForm.libraryPath" type="text" class="settings-input w-full font-mono" placeholder="/data/media/fankai" />
|
||||
<input v-model="plexForm.libraryPath" type="text" class="field w-full" placeholder="/data/media/fankai" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<p v-if="plexError" class="text-meta text-err">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexSetup" :disabled="plexLoading || !plexForm.libraryName || !plexForm.libraryPath" class="btn-primary">
|
||||
{{ plexLoading ? 'Création…' : 'Créer la bibliothèque' }}
|
||||
@@ -139,16 +134,16 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="s in plexSteps" :key="s.step" class="flex items-center gap-2.5">
|
||||
<div class="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="s.ok ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'">
|
||||
<svg v-if="s.ok" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
|
||||
:class="s.ok ? 'bg-ok/15 text-ok' : 'bg-err/15 text-err'">
|
||||
<Check v-if="s.ok" :size="11" :stroke-width="3" />
|
||||
<X v-else :size="11" :stroke-width="3" />
|
||||
</div>
|
||||
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ s.message }}</p>
|
||||
<p class="text-meta" :class="s.ok ? 'text-primary' : 'text-err'">{{ s.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plexManualSetup" class="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3">
|
||||
<p class="text-xs text-yellow-400 font-medium mb-2">⚠ Configuration manuelle de l'agent requise (Plex < 1.43)</p>
|
||||
<div v-if="plexManualSetup" class="bg-accent/5 border border-accent/30 rounded-field p-3">
|
||||
<p class="text-meta text-accent font-bold mb-2">Configuration manuelle de l'agent requise (Plex < 1.43)</p>
|
||||
<ol class="text-xs text-muted space-y-1 list-decimal list-inside">
|
||||
<li>Rendez-vous sur la page <span class="text-primary">Metadata Agent</span> de Plex</li>
|
||||
<li>Dans <span class="text-primary">Metadata Provider</span>, ajoutez : <code class="bg-hover px-1 rounded">https://metadata.fankai.fr/plex</code></li>
|
||||
@@ -158,7 +153,7 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button @click="emit('close')" class="btn-primary">Fermer</button>
|
||||
<button @click="emit('close')" class="btn-primary self-end">Fermer</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -168,6 +163,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Check, Clock3, User, X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{ mediaPath: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<section class="card flex flex-col gap-3.5">
|
||||
<div v-if="title || $slots.actions" class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex flex-col gap-0.5 min-w-0 flex-1 basis-[280px]">
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
<p v-if="description || $slots.description" class="text-meta text-muted">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex items-center gap-2.5 flex-wrap shrink-0">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string; description?: string }>()
|
||||
</script>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-primary">{{ label }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ description }}</p>
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<p class="text-sm font-medium text-primary">{{ label }}</p>
|
||||
<p v-if="description" class="text-meta text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="label"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200 mt-0.5"
|
||||
:class="modelValue ? 'bg-accent' : 'bg-border'"
|
||||
class="switch"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="modelValue ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
<span class="switch-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -20,4 +20,4 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: boolean; label: string; description?: string }>()
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-3 gap-3.5" role="radiogroup" aria-label="Accent">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.id"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="current === theme.id"
|
||||
@click="setTheme(theme.id)"
|
||||
class="flex flex-col gap-2 text-left cursor-pointer group"
|
||||
>
|
||||
<span
|
||||
class="h-[74px] rounded-field border-2 bg-sidebar p-2.5 flex items-end gap-2 transition-colors"
|
||||
:class="current === theme.id ? '' : 'border-border-light group-hover:border-border'"
|
||||
:style="current === theme.id ? { borderColor: theme.color } : undefined"
|
||||
>
|
||||
<span class="w-[22px] h-[22px] rounded-full shrink-0" :style="{ background: theme.color }" />
|
||||
<span class="flex-1 h-2 rounded" :style="{ background: `color-mix(in srgb, ${theme.color} 35%, transparent)` }" />
|
||||
</span>
|
||||
<span class="text-meta" :class="current === theme.id ? 'font-bold text-primary' : 'font-medium text-secondary'">
|
||||
{{ theme.label }}<template v-if="current === theme.id"> · actif</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { themes, current, setTheme } = useTheme()
|
||||
</script>
|
||||
@@ -1,16 +1,18 @@
|
||||
<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 class="flex flex-col gap-3.5">
|
||||
<div class="grid sm:grid-cols-2 gap-3.5">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="client-name" class="field-label">Nom</label>
|
||||
<input id="client-name" v-model="name" placeholder="Mon client torrent" class="field" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="client-type" class="field-label">Type</label>
|
||||
<select id="client-type" v-model="type" @change="onTypeChange" class="field">
|
||||
<option value="">Choisir un type…</option>
|
||||
<option v-for="def in definitions" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs principaux -->
|
||||
@@ -23,18 +25,18 @@
|
||||
: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>
|
||||
<div v-else class="flex flex-col gap-[7px]">
|
||||
<label :for="`client-${field.key}`" class="field-label">
|
||||
{{ field.label }}<span v-if="field.required" class="text-err ml-0.5">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`client-${field.key}`"
|
||||
v-model="config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
class="field"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paramètres avancés -->
|
||||
@@ -42,17 +44,18 @@
|
||||
<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"
|
||||
class="flex items-center gap-1.5 text-meta text-secondary hover:text-primary transition-colors w-full py-1"
|
||||
:aria-expanded="advancedOpen"
|
||||
>
|
||||
<ChevronDown
|
||||
:size="13"
|
||||
:size="14"
|
||||
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-if="advancedOpen" class="flex flex-col gap-3.5 mt-2.5 pl-3.5 border-l border-hover">
|
||||
<div v-for="field in advancedFields" :key="field.key">
|
||||
<SettingsToggle
|
||||
v-if="field.type === 'boolean'"
|
||||
@@ -61,42 +64,35 @@
|
||||
: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>
|
||||
<div v-else class="flex flex-col gap-[7px]">
|
||||
<label :for="`client-${field.key}`" class="field-label">{{ field.label }}</label>
|
||||
<input
|
||||
:id="`client-${field.key}`"
|
||||
v-model="config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
class="field"
|
||||
/>
|
||||
</template>
|
||||
<p v-if="fieldTooltip(field.key)" class="text-xs text-muted">{{ fieldTooltip(field.key) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="!tested && type" class="text-xs text-muted">
|
||||
<p v-if="!tested && type" class="text-meta text-muted">
|
||||
Testez la connexion avant d'enregistrer.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<div class="flex items-center gap-2.5 pt-1 flex-wrap">
|
||||
<button @click="save" :disabled="saving || !tested" class="btn-primary">
|
||||
{{ saving ? '...' : 'Enregistrer' }}
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button @click="test" :disabled="testing || !type" class="btn-secondary">
|
||||
{{ testing ? '...' : tested ? 'Testé ✓' : 'Tester' }}
|
||||
<Loader v-if="testing" :size="14" class="animate-spin" />
|
||||
<Check v-else-if="tested" :size="14" class="text-ok" />
|
||||
{{ testing ? 'Test…' : tested ? 'Connexion testée' : 'Tester' }}
|
||||
</button>
|
||||
<button v-if="showCancel" @click="emit('cancel')" class="btn-ghost ml-auto">Annuler</button>
|
||||
</div>
|
||||
@@ -105,7 +101,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { Check, ChevronDown, Loader } 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'
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="settings-card flex items-center gap-4">
|
||||
<section class="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'"
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
|
||||
:class="status.empty ? 'bg-accent-muted text-accent' : 'bg-ok/10 text-ok'"
|
||||
>
|
||||
<Library :size="17" />
|
||||
</span>
|
||||
@@ -17,7 +17,7 @@
|
||||
<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">
|
||||
<p class="text-meta text-muted mt-0.5">
|
||||
{{ status.empty
|
||||
? 'Sans catalogue, la médiathèque reste vide.'
|
||||
: 'Le catalogue se met à jour automatiquement.' }}
|
||||
@@ -28,13 +28,13 @@
|
||||
</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">
|
||||
<section class="card flex items-center gap-4">
|
||||
<span class="w-9 h-9 rounded-full 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">
|
||||
<p class="text-meta 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>
|
||||
@@ -44,7 +44,7 @@
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<p v-if="warnEmpty" class="text-sm text-yellow-500" role="alert">
|
||||
<p v-if="warnEmpty" class="text-body text-accent" role="alert">
|
||||
Le catalogue est encore vide. Téléchargez-le, ou cliquez à nouveau sur « Suivant » pour continuer.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -8,25 +8,25 @@
|
||||
<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">
|
||||
<div v-for="client in clients" :key="client.uuid" class="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'"
|
||||
:class="health[client.uuid] === true ? 'bg-ok' : health[client.uuid] === false ? 'bg-err' : '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">
|
||||
<p class="text-meta 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>
|
||||
<template v-if="health[client.uuid] === false"> · <span class="text-err">injoignable</span></template>
|
||||
<template v-else-if="health[client.uuid] === true"> · <span class="text-ok">connecté</span></template>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="edit(client)" class="btn-ghost text-xs">Modifier</button>
|
||||
<button @click="edit(client)" class="btn-ghost btn-sm">Modifier</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<div v-if="formOpen" class="settings-card flex flex-col gap-4">
|
||||
<div v-if="formOpen" class="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"
|
||||
@@ -42,22 +42,22 @@
|
||||
</button>
|
||||
|
||||
<!-- Rappel chemins -->
|
||||
<div class="flex items-start gap-3 px-4 py-3 rounded-lg border border-border bg-card/50">
|
||||
<div class="flex items-start gap-3 px-4 py-3 rounded-field 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">
|
||||
<p class="text-meta 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>.
|
||||
<template v-if="settings.completePath">(<span class="text-secondary">{{ settings.completePath }}</span>)</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 v-if="warnSkip" class="flex items-start gap-3 px-4 py-3 rounded-field border border-accent/30 bg-accent/5" role="alert">
|
||||
<TriangleAlert :size="15" class="text-accent 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">
|
||||
<p class="text-body text-accent font-medium">Aucun client configuré</p>
|
||||
<p class="text-meta 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div class="card flex flex-col gap-5">
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
@@ -25,13 +25,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div class="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">
|
||||
<div class="flex items-start gap-2.5 px-3 py-2.5 rounded-field border border-border bg-main/50 text-meta 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> :
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-400" role="alert">{{ error }}</p>
|
||||
<p v-if="error" class="text-body text-err" role="alert">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,67 +2,68 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Jellyfin -->
|
||||
<section class="settings-card flex flex-col gap-4">
|
||||
<section class="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">
|
||||
<span class="w-9 h-9 rounded-full 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>
|
||||
<span v-if="jellyfin?.test?.ok" class="pill pill-ok h-5 px-2 text-[10.5px]">Connecté</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-0.5 leading-relaxed">
|
||||
<p class="text-meta 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">
|
||||
<button @click="jellyfinOpen = !jellyfinOpen" :aria-expanded="jellyfinOpen" class="btn-secondary btn-sm shrink-0">
|
||||
{{ jellyfinOpen ? 'Replier' : 'Configurer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="jellyfinOpen">
|
||||
<div class="h-px bg-hover" />
|
||||
<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">
|
||||
<button @click="syncUsers" :disabled="syncing" class="btn-secondary btn-sm">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
|
||||
</button>
|
||||
<span v-if="syncResult" class="text-xs text-green-400">
|
||||
<span v-if="syncResult" class="text-meta text-ok">
|
||||
{{ 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>
|
||||
<span v-if="syncError" class="text-meta text-err">{{ 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>
|
||||
<p class="text-meta 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">
|
||||
<section class="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">
|
||||
<span class="w-9 h-9 rounded-full 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>
|
||||
<span v-if="context.plexOpened" class="text-meta text-muted font-normal">assistant ouvert</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-0.5 leading-relaxed">
|
||||
<p class="text-meta 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">
|
||||
<button @click="openPlex" :disabled="!settings.mediaPath" class="btn-secondary btn-sm shrink-0">
|
||||
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>
|
||||
<p v-if="!settings.mediaPath" class="text-meta text-accent">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>
|
||||
<p class="text-meta text-muted">Pas de serveur multimédia pour l'instant ? Passez cette étape, tout se configure aussi plus tard.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div class="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">
|
||||
<label :for="`setup-${field.key}`" class="field-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
|
||||
<span v-if="field.required" class="text-err 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>
|
||||
<span v-if="!field.required" class="pill pill-neutral h-5 px-2 text-[10.5px]">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"
|
||||
class="field"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button @click="openPicker(field.key)" class="btn-secondary shrink-0 flex items-center gap-1.5" :title="`Parcourir — ${field.label}`">
|
||||
<button @click="openPicker(field.key)" class="btn-secondary shrink-0" :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">
|
||||
<p v-if="checking && form[field.key]" class="text-meta text-muted mt-1.5">Vérification…</p>
|
||||
<p v-else-if="statusOf(field.key)?.error" class="text-meta text-err 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">
|
||||
<p v-else-if="statusOf(field.key)?.writable" class="text-meta text-ok 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>
|
||||
<p v-else class="text-meta 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">
|
||||
<p v-if="result?.relation === 'same'" class="text-meta text-err 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">
|
||||
<p v-else-if="result?.relation === 'nested'" class="text-meta text-accent 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="card flex flex-col gap-3">
|
||||
<label class="field-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'"
|
||||
class="flex flex-col justify-start px-3.5 py-3 rounded-field border text-left transition-colors"
|
||||
:class="form.organizeMode === mode.id ? 'border-accent/50 bg-accent-muted' : 'border-border-light hover:bg-hover'"
|
||||
>
|
||||
<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>
|
||||
<span v-if="mode.id === 'hardlink'" class="text-meta font-normal text-muted ml-1">recommandé</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-1 leading-relaxed">{{ mode.text }}</p>
|
||||
<p class="text-meta 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">
|
||||
<div v-if="result && form.organizeMode === 'hardlink'" class="text-meta">
|
||||
<p v-if="result.hardlink.tested && result.hardlink.ok" class="text-ok 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">
|
||||
<div v-else-if="result.hardlink.tested" class="flex flex-col gap-2 px-3 py-2.5 rounded-field border border-accent/30 bg-accent/5">
|
||||
<p class="text-accent 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>
|
||||
<button @click="form.organizeMode = 'copy'" class="btn-secondary btn-sm 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>
|
||||
<p v-if="error" class="text-body text-err" role="alert">{{ error }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<ul class="settings-card p-0 divide-y divide-border">
|
||||
<ul class="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" />
|
||||
@@ -12,14 +12,14 @@
|
||||
</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>
|
||||
<p class="text-meta 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>
|
||||
<button @click="context.goto(row.step)" class="btn-ghost btn-sm 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">
|
||||
<button @click="done(true)" :disabled="finishing || !settings.mediaPath" class="btn-primary">
|
||||
<Compass :size="15" />
|
||||
{{ context.relaunch ? 'Revoir la visite guidée' : "Terminer et découvrir l'interface" }}
|
||||
</button>
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ context.relaunch ? 'Fermer' : 'Terminer sans la visite' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!context.relaunch" class="text-xs text-muted -mt-3">
|
||||
<p v-if="!context.relaunch" class="text-meta 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>
|
||||
|
||||
@@ -49,9 +49,9 @@ 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',
|
||||
ok : 'bg-ok/15 text-ok',
|
||||
warn : 'bg-accent-muted text-accent',
|
||||
error : 'bg-err/15 text-err',
|
||||
neutral: 'bg-hover text-muted',
|
||||
}
|
||||
|
||||
|
||||
@@ -3,42 +3,42 @@
|
||||
|
||||
<!-- 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">
|
||||
<li v-for="(item, i) in flow" :key="item.title" class="card flex gap-3">
|
||||
<span class="w-8 h-8 rounded-full 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>
|
||||
<p class="text-meta 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>
|
||||
<div class="card flex flex-col gap-3">
|
||||
<p class="field-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">
|
||||
<li v-for="item in checklist" :key="item.label" class="flex items-start gap-2.5 text-body">
|
||||
<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 v-if="item.optional" class="text-muted text-meta">(optionnel)</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-xs text-muted">Comptez environ 5 minutes.</p>
|
||||
<p class="text-meta 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">
|
||||
<div v-if="isDocker" class="flex items-start gap-3 px-4 py-3 rounded-field 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">
|
||||
<p class="text-meta 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>).
|
||||
et la médiathèque (ex. <span class="text-secondary">/data/downloads</span> et
|
||||
<span class="text-secondary">/data/medias</span>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,36 +13,36 @@
|
||||
|
||||
<div
|
||||
v-if="spot"
|
||||
class="absolute rounded-lg pointer-events-none transition-all duration-200 motion-reduce:transition-none"
|
||||
class="absolute rounded-card 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)',
|
||||
boxShadow: '0 0 0 2px var(--accent), 0 0 0 9999px rgb(14 10 8 / 0.72)',
|
||||
}"
|
||||
/>
|
||||
<div v-else class="absolute inset-0 bg-black/60 pointer-events-none" />
|
||||
<div v-else class="absolute inset-0 bg-sidebar/72 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"
|
||||
class="absolute bg-card border border-border rounded-card px-5 py-4 w-[340px] max-w-[calc(100vw-24px)] shadow-[0_24px_60px_rgb(0_0_0/0.55)] 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>
|
||||
<p class="tag-label text-accent tabular-nums">{{ tour.index + 1 }} / {{ tour.steps.length }}</p>
|
||||
<h2 id="tour-title" class="font-display text-lg font-bold text-primary mt-1 leading-snug">{{ 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">
|
||||
<button @click="tour.finish()" aria-label="Fermer la visite" class="w-8 h-8 -mr-2 -mt-1 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors">
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="tour-body" class="text-xs text-secondary leading-relaxed" aria-live="polite">{{ step.body }}</p>
|
||||
<p id="tour-body" class="text-body 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>
|
||||
<button @click="tour.finish()" class="text-meta text-muted hover:text-primary transition-colors px-1">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">
|
||||
<button v-if="tour.index > 0" @click="tour.prev()" class="btn-ghost btn-sm">Précédent</button>
|
||||
<button ref="primaryRef" @click="tour.next()" class="btn-primary btn-sm px-4">
|
||||
{{ tour.isLast ? 'Terminer' : 'Suivant' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const query = ref('')
|
||||
const pending = ref(false)
|
||||
|
||||
export function useLibrarySearch() {
|
||||
const router = useRouter()
|
||||
|
||||
function open(text = '') {
|
||||
query.value = text
|
||||
pending.value = true
|
||||
router.push('/series')
|
||||
}
|
||||
|
||||
function consume(): string | null {
|
||||
if (!pending.value) return null
|
||||
pending.value = false
|
||||
return query.value
|
||||
}
|
||||
|
||||
return { pending, open, consume }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
|
||||
export const themes = [
|
||||
{ id: 'or', label: 'Or', color: '#f2c44a' },
|
||||
{ id: 'vermillon', label: 'Vermillon', color: '#e8734f' },
|
||||
{ id: 'jade', label: 'Jade', color: '#6fc79a' },
|
||||
] as const
|
||||
|
||||
export type ThemeId = typeof themes[number]['id']
|
||||
|
||||
const LEGACY: Record<string, ThemeId> = { indigo: 'or', crimson: 'vermillon', teal: 'jade' }
|
||||
|
||||
const current = ref<ThemeId>('or')
|
||||
|
||||
function setTheme(id: ThemeId) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) ?? 'or'
|
||||
setTheme(themes.some(t => t.id === saved) ? saved as ThemeId : LEGACY[saved] ?? 'or')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return { themes, current, setTheme }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
|
||||
const currentVersion = ref(import.meta.env.DEV ? 'dev' : '')
|
||||
const latestVersion = ref('')
|
||||
const latestReleaseUrl = ref(`https://github.com/${GITHUB_REPO}/releases/latest`)
|
||||
const updateAvailable = ref(false)
|
||||
|
||||
let users = 0
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function parseVersion(v: string): number[] {
|
||||
return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
function isNewer(latest: string, current: string): boolean {
|
||||
if (current === 'dev') return false
|
||||
const l = parseVersion(latest)
|
||||
const c = parseVersion(current)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
const lv = l[i] ?? 0
|
||||
const cv = c[i] ?? 0
|
||||
if (lv > cv) return true
|
||||
if (lv < cv) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
// Version locale
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) {
|
||||
const { version } = await vRes.json()
|
||||
currentVersion.value = version
|
||||
}
|
||||
|
||||
if (currentVersion.value === 'dev') return
|
||||
|
||||
// Dernière release GitHub
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (!gRes.ok) return
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
latestReleaseUrl.value = release.html_url ?? latestReleaseUrl.value
|
||||
|
||||
if (isNewer(latestVersion.value, currentVersion.value)) {
|
||||
updateAvailable.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function useUpdateCheck() {
|
||||
onMounted(() => {
|
||||
if (users++ > 0) return
|
||||
checkForUpdates()
|
||||
// Revérifier toutes les 6h
|
||||
timer = setInterval(checkForUpdates, 6 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (--users > 0 || !timer) return
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
})
|
||||
|
||||
return { currentVersion, latestVersion, latestReleaseUrl, updateAvailable }
|
||||
}
|
||||
+64
-75
@@ -1,27 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-screen bg-shell overflow-hidden">
|
||||
<div class="flex h-dvh bg-main overflow-hidden">
|
||||
|
||||
<div class="hidden md:flex w-56 shrink-0">
|
||||
<div class="hidden md:flex w-[232px] shrink-0">
|
||||
<SidebarNav :items="navItems" class="w-full" />
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="mobileOpen" class="fixed inset-0 bg-black/60 z-40 md:hidden" @click="mobileOpen = false" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-left">
|
||||
<div v-if="mobileOpen" class="fixed top-0 left-0 h-full w-56 z-50 md:hidden">
|
||||
<SidebarNav :items="navItems" class="w-full" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
|
||||
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<span class="text-sm font-medium text-primary">FanKarr</span>
|
||||
</header>
|
||||
<MobileHeader v-if="showMobileHeader" class="md:hidden" />
|
||||
|
||||
<main id="main-scroll" class="flex-1 overflow-y-auto bg-main">
|
||||
<RouterView v-slot="{ Component }">
|
||||
@@ -30,6 +15,8 @@
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
</main>
|
||||
|
||||
<MobileTabBar :items="tabItems" class="md:hidden" />
|
||||
</div>
|
||||
|
||||
<TourOverlay />
|
||||
@@ -37,19 +24,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import { computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { Activity, House, Inbox, Settings, Tv, User } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import MobileHeader from '@/components/nav/MobileHeader.vue'
|
||||
import MobileTabBar from '@/components/nav/MobileTabBar.vue'
|
||||
import TourOverlay from '@/components/tour/TourOverlay.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useRequestsStore } from '@/stores/requests'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
import type { NavItem, NavChild } from '@/types/nav'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const route = useRoute()
|
||||
const dlStore = useDownloadsStore()
|
||||
const reqStore = useRequestsStore()
|
||||
const auth = useAuthStore()
|
||||
const tour = useTourStore()
|
||||
const librarySearch = useLibrarySearch()
|
||||
|
||||
// Ctrl K : El recherchor
|
||||
useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k' && !tour.active) {
|
||||
e.preventDefault()
|
||||
librarySearch.open()
|
||||
}
|
||||
})
|
||||
|
||||
// Première visite : une seule fois par session, après l'assistant pour un admin
|
||||
let tourAutoStarted = false
|
||||
@@ -61,76 +64,62 @@ watch(() => [auth.loggedIn, auth.tourSeen, auth.onboardingDone], () => {
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => tour.active, (active) => { if (active) mobileOpen.value = false })
|
||||
// Compteurs de pour la barre latérale
|
||||
let pendingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let downloadsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
reqStore.refreshPending()
|
||||
pendingTimer = setInterval(reqStore.refreshPending, 30_000)
|
||||
if (auth.isAdmin) {
|
||||
dlStore.refresh()
|
||||
downloadsTimer = setInterval(dlStore.refresh, 60_000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pendingTimer) clearInterval(pendingTimer)
|
||||
if (downloadsTimer) clearInterval(downloadsTimer)
|
||||
})
|
||||
|
||||
watch(() => route.path, () => reqStore.refreshPending())
|
||||
|
||||
// Mobile
|
||||
const showMobileHeader = computed(() => route.path === '/dashboard' || route.path.startsWith('/settings'))
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
const items: NavItem[] = [
|
||||
{ label: 'Accueil', icon: House, to: '/dashboard', tour: 'nav-dashboard' },
|
||||
{ label: 'Médiathèque', icon: Tv, to: '/series', tour: 'nav-series' },
|
||||
{
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Demandes',
|
||||
icon : 'ClipboardList',
|
||||
label: auth.isAdmin ? 'Demandes' : 'Mes demandes',
|
||||
icon : Inbox,
|
||||
to : '/requests',
|
||||
tour : 'nav-requests',
|
||||
badge: reqStore.pendingCount || undefined,
|
||||
},
|
||||
]
|
||||
|
||||
if (auth.isAdmin) {
|
||||
items.push({
|
||||
label: 'Activité',
|
||||
icon : 'Activity',
|
||||
to : '/activity',
|
||||
tour : 'nav-activity',
|
||||
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
|
||||
label : 'Activité',
|
||||
icon : Activity,
|
||||
to : '/activity',
|
||||
tour : 'nav-activity',
|
||||
badge : dlStore.activeCount || undefined,
|
||||
badgeStyle: 'dot',
|
||||
})
|
||||
}
|
||||
|
||||
items.push({ separator: true })
|
||||
|
||||
const settingsChildren: NavChild[] = [
|
||||
{ label: 'Mon profil', to: '/settings/profile' },
|
||||
]
|
||||
if (auth.isAdmin) {
|
||||
settingsChildren.push(
|
||||
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
|
||||
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
||||
{ label: 'Management des séries', to: '/settings/import-management' },
|
||||
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
||||
{ label: 'Journaux', to: '/settings/logs' },
|
||||
{ label: 'Avancé', to: '/settings/advanced' },
|
||||
{ label: 'Utilisateurs', to: '/settings/users' },
|
||||
{ label: 'Jellyfin & API', to: '/settings/jellyfin' },
|
||||
)
|
||||
}
|
||||
|
||||
items.push({
|
||||
label : 'Paramètres',
|
||||
icon : 'Settings',
|
||||
to : '/settings',
|
||||
tour : 'nav-settings',
|
||||
children: settingsChildren,
|
||||
})
|
||||
items.push(auth.isAdmin
|
||||
? { label: 'Paramètres', icon: Settings, to: '/settings', tour: 'nav-settings' }
|
||||
: { label: 'Mon profil', icon: User, to: '/settings/profile', tour: 'nav-settings' })
|
||||
|
||||
return items
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.slide-left-enter-active, .slide-left-leave-active { transition: transform 0.25s ease; }
|
||||
.slide-left-enter-from, .slide-left-leave-to { transform: translateX(-100%); }
|
||||
</style>
|
||||
// Mobile 2
|
||||
const tabItems = computed(() => navItems.value.filter(i => !i.separator && !(auth.isAdmin && i.to === '/settings')))
|
||||
</script>
|
||||
+4
-1
@@ -2,9 +2,12 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from '@/App.vue'
|
||||
import { initTheme } from '@/composables/useTheme'
|
||||
import '@/assets/main.css'
|
||||
|
||||
initTheme()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
+7
-8
@@ -35,11 +35,10 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
// Redirections des anciennes URLs
|
||||
{ path: '', redirect: '/series' },
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'downloads', redirect: '/activity' },
|
||||
|
||||
// Dashboard
|
||||
// Accueil
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
@@ -58,14 +57,14 @@ const router = createRouter({
|
||||
component: () => import('@/views/SerieView.vue'),
|
||||
},
|
||||
|
||||
// Activité (ex-downloads)
|
||||
// Activité
|
||||
{
|
||||
path: 'activity',
|
||||
name: 'activity',
|
||||
component: () => import('@/views/DownloadsView.vue'),
|
||||
},
|
||||
|
||||
// Paramètres — layout imbriqué avec sous-routes
|
||||
// Paramètres
|
||||
{
|
||||
path: 'settings',
|
||||
component: () => import('@/views/settings/SettingsLayout.vue'),
|
||||
@@ -149,11 +148,11 @@ router.beforeEach(async (to) => {
|
||||
// Redirige vers /auth si non connecté
|
||||
if (!to.meta.public && !auth.loggedIn) return '/auth'
|
||||
|
||||
// Redirige vers /series si déjà connecté et tente d'accéder à /auth
|
||||
if (to.path === '/auth' && auth.loggedIn) return '/series'
|
||||
// Redirige vers l'accueil si déjà connecté et tente d'accéder à /auth
|
||||
if (to.path === '/auth' && auth.loggedIn) return '/dashboard'
|
||||
|
||||
// Redirige les non-admins hors des pages admin
|
||||
if (to.meta.adminOnly && !auth.isAdmin) return '/series'
|
||||
if (to.meta.adminOnly && !auth.isAdmin) return '/dashboard'
|
||||
|
||||
// Premier lancement : l'assistant de configuration est obligatoire
|
||||
if (auth.loggedIn && auth.isAdmin && !auth.onboardingDone
|
||||
|
||||
@@ -3,17 +3,18 @@ import { ref } from 'vue'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const activeCount = ref(0)
|
||||
const torrents = ref<any[]>([])
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const torrents = await res.json()
|
||||
activeCount.value = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
torrents.value = await res.json()
|
||||
activeCount.value = torrents.value.filter((t: any) => t.state === 'downloading').length
|
||||
} catch {
|
||||
activeCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
return { activeCount, refresh }
|
||||
})
|
||||
return { activeCount, torrents, refresh }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Demandes en attente : toutes pour un admin, les siennes pour un utilisateur
|
||||
export const useRequestsStore = defineStore('requests', () => {
|
||||
const pendingCount = ref(0)
|
||||
|
||||
async function refreshPending() {
|
||||
try {
|
||||
const res = await fetch('/api/requests/pending-count', { credentials: 'include' })
|
||||
if (res.ok) pendingCount.value = (await res.json()).count ?? 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { pendingCount, refreshPending }
|
||||
})
|
||||
@@ -59,6 +59,9 @@ export interface Serie {
|
||||
torrent_count: number
|
||||
has_torrents: boolean
|
||||
download_state: 'none' | 'downloading' | 'partial' | 'complete'
|
||||
episode_count: number
|
||||
organized_count: number
|
||||
last_imported_at: string | null
|
||||
in_client: boolean
|
||||
has_files: boolean
|
||||
rss_synced: boolean
|
||||
|
||||
+25
-30
@@ -19,36 +19,31 @@ 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).',
|
||||
? 'Ce bouton relance la visite ou l\'assistant de configuration à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.'
|
||||
: 'Ce bouton relance la visite à tout moment. Sur mobile, ouvrez le menu de votre avatar en haut de l\'Accueil.',
|
||||
})
|
||||
|
||||
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', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
|
||||
title: 'Accueil',
|
||||
body: 'Le dernier import en grand, les séries ajoutées récemment, et les chiffres du catalogue : séries dans la médiathèque et séries surveillées.',
|
||||
},
|
||||
{
|
||||
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: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
|
||||
title: 'À traiter',
|
||||
body: 'Demandes en attente, imports en erreur et dossiers à renommer : chaque ligne porte son action. Les demandes arrivent 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.',
|
||||
body: 'Tout le catalogue Fankai. L\'état est écrit sur l\'affiche : importée, partielle, en téléchargement, erreur, dossier à renommer ou sans torrent. Cliquez pour ouvrir la fiche.',
|
||||
},
|
||||
{
|
||||
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à.',
|
||||
body: 'Chaque filtre affiche son nombre de séries ; « Plus » garde les filtres secondaires. Triez, et changez la taille des affiches.',
|
||||
},
|
||||
{
|
||||
id: 'series-select', route: '/series', target: ['series-select'], placement: 'bottom',
|
||||
@@ -58,17 +53,17 @@ const adminSteps: TourStep[] = [
|
||||
{
|
||||
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.',
|
||||
body: '« Approuver et télécharger » lance le téléchargement ; une demande sans torrent est signalée avant d\'être approuvée. Les demandes avec torrent s\'approuvent aussi en une fois.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
body: 'Une ligne par torrent, du téléchargement à l\'import. Les onglets séparent ce qui est en cours, à importer ou en erreur ; une erreur d\'import s\'affiche dans sa ligne, avec de quoi relancer.',
|
||||
},
|
||||
{
|
||||
id: 'settings', route: '/settings/download-client', target: ['nav-settings'], placement: 'right',
|
||||
id: 'settings', route: '/settings/download-client', target: ['settings-menu', 'nav-settings'], placement: 'right',
|
||||
title: 'Paramètres',
|
||||
body: 'Clients torrent, dossiers et mode d\'import, gestion des séries, catalogue, journaux, utilisateurs et Jellyfin.',
|
||||
body: 'Les réglages sont rangés en quatre groupes : Compte (profil, utilisateurs), Médias (dossiers, séries, catalogue), Services (clients torrent, Jellyfin) et Système (journaux, avancé).',
|
||||
},
|
||||
help(true),
|
||||
]
|
||||
@@ -76,34 +71,34 @@ const adminSteps: TourStep[] = [
|
||||
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', route: '/dashboard', target: ['dashboard-stats'], placement: 'top',
|
||||
title: 'Accueil',
|
||||
body: 'Les nouveautés de la médiathèque et le nombre de séries prêtes à regarder.',
|
||||
},
|
||||
{
|
||||
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'top',
|
||||
id: 'dashboard-requests', route: '/dashboard', target: ['dashboard-requests'], placement: 'left',
|
||||
title: 'Mes demandes',
|
||||
body: 'L\'état de vos dernières demandes.',
|
||||
body: 'L\'état de vos dernières demandes. Juste en dessous, « Pas encore là » propose des séries à demander en un clic.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
body: 'Toutes les séries du catalogue. Celles qui ne sont pas encore là sont assombries : ouvrez la fiche pour demander la série, une saison ou un épisode.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
body: 'Retrouvez une série et filtrez : prêtes à regarder, bientôt là, pas encore là ou vos demandes.',
|
||||
},
|
||||
{
|
||||
id: 'requests', route: '/requests', target: ['requests-header'], placement: 'bottom',
|
||||
title: 'Demandes',
|
||||
body: 'Suivez vos demandes : en attente, approuvée, disponible ou refusée.',
|
||||
title: 'Mes demandes',
|
||||
body: 'Suivez vos demandes : en attente, bientôt là, disponible ou refusée. Une demande en attente peut encore être annulé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.',
|
||||
title: 'Jeton d’API',
|
||||
body: 'Ce jeton relie le plugin Jellyfin FanKarr Search à votre compte pour faire vos demandes depuis Jellyfin. Le choix de l’accent de couleur se fait juste à côté.',
|
||||
},
|
||||
help(false),
|
||||
]
|
||||
|
||||
+9
-12
@@ -1,14 +1,11 @@
|
||||
export interface NavChild {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export interface NavItem {
|
||||
label? : string
|
||||
icon? : string
|
||||
to? : string
|
||||
children? : NavChild[]
|
||||
separator?: true
|
||||
badge? : string | number
|
||||
tour? : string
|
||||
}
|
||||
label? : string
|
||||
icon? : Component
|
||||
to? : string
|
||||
separator? : true
|
||||
badge? : number
|
||||
badgeStyle?: 'count' | 'dot'
|
||||
tour? : string
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const UNITS = ['o', 'Ko', 'Mo', 'Go', 'To']
|
||||
|
||||
// 936 Ko, 501,8 Mo, 20,3 Go
|
||||
export function formatSize(bytes: number): string {
|
||||
if (!bytes || bytes < 0) return '0 o'
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1)
|
||||
const v = bytes / 1024 ** i
|
||||
const digits = i <= 1 ? 0 : 1
|
||||
return `${v.toLocaleString('fr-FR', { minimumFractionDigits: digits, maximumFractionDigits: digits })} ${UNITS[i]}`
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSecond: number): string {
|
||||
return `${formatSize(bytesPerSecond)}/s`
|
||||
}
|
||||
|
||||
// 26 s, 5 min 41 s, 1 h 41 min
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h} h ${m} min`
|
||||
if (m > 0) return `${m} min ${s} s`
|
||||
return `${s} s`
|
||||
}
|
||||
|
||||
// à l'instant, il y a 3 h, hier, il y a 2 j, il y a 1 sem.
|
||||
export function formatRelative(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60_000), h = Math.floor(diff / 3_600_000), d = Math.floor(diff / 86_400_000)
|
||||
if (m < 1) return "à l'instant"
|
||||
if (m < 60) return `il y a ${m} min`
|
||||
if (h < 24) return `il y a ${h} h`
|
||||
if (d === 1) return 'hier'
|
||||
if (d < 7) return `il y a ${d} j`
|
||||
if (d < 35) return `il y a ${Math.floor(d / 7)} sem.`
|
||||
return `il y a ${Math.max(1, Math.floor(d / 30))} mois`
|
||||
}
|
||||
|
||||
// vendredi 11 septembre
|
||||
export function formatToday(date = new Date()): string {
|
||||
return date.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
// 1, 2 et 3
|
||||
export function listFr(items: string[]): string {
|
||||
if (items.length <= 1) return items[0] ?? ''
|
||||
return `${items.slice(0, -1).join(', ')} et ${items[items.length - 1]}`
|
||||
}
|
||||
|
||||
export function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
||||
return `${n} ${n > 1 ? pluralForm : singular}`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { listFr, plural } from '@/utils/format'
|
||||
|
||||
export type RequestStatus = 'pending' | 'approved' | 'rejected' | 'completed'
|
||||
|
||||
export interface Requester { userId: string; username: string; seasons: number[]; episodes: number[]; requestedAt: string }
|
||||
|
||||
export interface SerieRequest {
|
||||
id: string; serieId: number; serieName: string
|
||||
requesters: Requester[]; status: RequestStatus
|
||||
rejectionMessage?: string; createdAt: string; updatedAt: string
|
||||
episodeCount?: number | null
|
||||
hasTorrents?: boolean
|
||||
}
|
||||
|
||||
export function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && (r.episodes?.length ?? 0) === 0)) return []
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export function mergedEpisodes(req: SerieRequest): number[] {
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
export function seasonsLabel(seasons: number[]): string {
|
||||
if (seasons.length === 0) return 'Toutes les saisons'
|
||||
const nums = seasons.filter(s => s > 0).map(String)
|
||||
const specials = seasons.includes(0)
|
||||
if (nums.length === 0) return 'Spéciaux'
|
||||
const label = `${nums.length > 1 ? 'Saisons' : 'Saison'} ${listFr(nums)}`
|
||||
return specials ? `${label}, spéciaux` : label
|
||||
}
|
||||
|
||||
// « Saisons 3 et 4 », « 2 épisodes », « Toutes les saisons »
|
||||
export function scopeLabel(req: SerieRequest): string {
|
||||
const episodes = mergedEpisodes(req)
|
||||
if (episodes.length > 0) return plural(episodes.length, 'épisode')
|
||||
return seasonsLabel(mergedSeasons(req))
|
||||
}
|
||||
|
||||
export function requesterNames(req: SerieRequest): string {
|
||||
return req.requesters.map(r => r.username).join(', ')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const STATUS: Record<string, string> = {
|
||||
continuing: 'En diffusion',
|
||||
ended : 'Terminée',
|
||||
canceled : 'Annulée',
|
||||
}
|
||||
|
||||
export function statusLabel(status: string | null | undefined): string {
|
||||
if (!status) return ''
|
||||
return STATUS[status.toLowerCase()] ?? status
|
||||
}
|
||||
|
||||
export function splitTitle(title: string): { name: string; suffix: string } {
|
||||
const m = title.match(/^(.+?)\s+((?:Henshū|Henshu|Kaï|Kai|Yabai|Recut|Fan-Cut)(?:\s+\(\d{4}\))?)$/)
|
||||
return m ? { name: m[1]!, suffix: m[2]! } : { name: title, suffix: '' }
|
||||
}
|
||||
|
||||
// Nouveauté : si importe il y a moins de 7 jours
|
||||
export function isNew(lastImportedAt: string | null | undefined): boolean {
|
||||
return !!lastImportedAt && Date.now() - new Date(lastImportedAt).getTime() < 7 * 86_400_000
|
||||
}
|
||||
+21
-20
@@ -1,42 +1,43 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="relative min-h-screen bg-main flex items-center justify-center px-4 py-10 overflow-hidden">
|
||||
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
|
||||
<div class="relative w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-2.5 mb-8">
|
||||
<FankarrLogo class="w-8 h-8" />
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<FankarrLogo class="w-9 h-9" />
|
||||
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="settings-card p-8 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">
|
||||
<div class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="tag-label text-accent">
|
||||
{{ isSetup ? 'Première connexion' : 'Connexion' }}
|
||||
</p>
|
||||
<h1 class="text-lg font-semibold text-primary">
|
||||
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="auth-username" class="field-label">Identifiant</label>
|
||||
<input id="auth-username" v-model="username" type="text" placeholder="admin" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe</label>
|
||||
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="auth-password" class="field-label">Mot de passe</label>
|
||||
<input id="auth-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div v-if="isSetup">
|
||||
<label class="settings-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
<label class="field-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
|
||||
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
|
||||
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
|
||||
{{ submitting ? '…' : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+582
-232
@@ -1,192 +1,482 @@
|
||||
<template>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
<div class="px-4 md:px-10 pt-2 md:pt-[26px] pb-12">
|
||||
<div class="max-w-[1400px] flex flex-col gap-[22px]">
|
||||
|
||||
<!-- Stats -->
|
||||
<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>
|
||||
<span class="text-xs text-muted">séries disponibles</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Importées</span>
|
||||
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
|
||||
</div>
|
||||
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">En cours</span>
|
||||
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
|
||||
<span class="text-xs text-muted">téléchargements</span>
|
||||
</div>
|
||||
<div v-if="auth.isAdmin" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Erreurs</span>
|
||||
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="grid md:grid-cols-2 gap-4 mb-6">
|
||||
|
||||
<!-- Téléchargements actifs -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
|
||||
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
<header class="hidden md:flex items-center justify-between gap-6 min-h-11">
|
||||
<div class="flex items-baseline gap-3.5 min-w-0">
|
||||
<h1 class="page-title tracking-[0.01em]">Accueil</h1>
|
||||
<span class="text-[13px] text-muted truncate">{{ today }}</span>
|
||||
</div>
|
||||
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary truncate">{{ t.name }}</span>
|
||||
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="hidden md:flex items-center gap-2.5 w-[360px] h-10 rounded-full bg-card border border-border-light pl-4 pr-2 cursor-text focus-within:border-accent transition-colors">
|
||||
<Search :size="16" :stroke-width="1.75" class="text-muted shrink-0" />
|
||||
<input
|
||||
v-model="searchText"
|
||||
type="search"
|
||||
placeholder="Rechercher une série"
|
||||
aria-label="Rechercher une série"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-sm text-primary placeholder:text-muted"
|
||||
@input="openSearchSoon"
|
||||
@keydown.enter="librarySearch.open(searchText)"
|
||||
/>
|
||||
<kbd class="h-6 px-[9px] rounded-full bg-hover text-secondary text-[11.5px] font-sans flex items-center">Ctrl K</kbd>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<!-- Derniers imports -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
|
||||
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
|
||||
<div v-else class="flex flex-col divide-y divide-border/50">
|
||||
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
|
||||
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
|
||||
<div class="grid lg:grid-cols-[minmax(0,1fr)_356px] gap-7 lg:gap-8">
|
||||
|
||||
<!-- ── Colonne principale-->
|
||||
<div class="flex flex-col gap-[30px] min-w-0 max-lg:contents">
|
||||
|
||||
<!-- Dernier import -->
|
||||
<section v-if="hero" class="relative sm:min-h-[344px] rounded-card overflow-hidden bg-card max-lg:order-1">
|
||||
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" alt="" class="absolute inset-0 w-full h-full object-cover blur-[34px] saturate-[1.3] scale-[1.35] opacity-60" />
|
||||
<div class="absolute inset-0 bg-linear-to-r from-main/97 via-main/84 via-48% to-main/35" />
|
||||
<div class="dot-grid" />
|
||||
<div class="relative sm:hidden flex flex-col gap-5 p-4">
|
||||
<div class="flex gap-4">
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="relative shrink-0">
|
||||
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" :alt="hero.serie.title" class="w-[88px] h-[132px] object-cover rounded-poster shadow-[0_16px_32px_rgb(0_0_0/0.5),0_0_0_1px_rgb(239_233_221/0.08)]" />
|
||||
<span v-else class="w-[88px] h-[132px] rounded-poster bg-hover flex items-center justify-center text-muted"><Tv :size="22" /></span>
|
||||
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-2 top-3">NOUVEAU</span>
|
||||
</RouterLink>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
|
||||
<span class="text-[10.5px] tracking-[0.16em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Importé' : 'Ajouté' }} {{ formatRelative(hero.at) }}</span>
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col">
|
||||
<span class="font-display text-[28px] leading-[1.05] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
|
||||
<span v-if="heroTitle.suffix" class="font-display text-xl leading-snug font-semibold text-accent">{{ heroTitle.suffix }}</span>
|
||||
</RouterLink>
|
||||
<p class="text-meta text-secondary">
|
||||
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>{{ plural(hero.episodes, 'épisode') }} {{ auth.isAdmin ? (hero.episodes > 1 ? 'ajoutés' : 'ajouté') : 'à regarder' }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="btn-primary w-full">
|
||||
Voir la série <ArrowRight :size="16" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary truncate">{{ n.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
{{ n.done }} fichier{{ n.done > 1 ? 's' : '' }} importé{{ n.done > 1 ? 's' : '' }}
|
||||
<span v-if="n.errors > 0" class="text-red-400"> · {{ n.errors }} erreur{{ n.errors > 1 ? 's' : '' }}</span>
|
||||
<div class="relative min-h-[344px] hidden sm:flex items-center gap-9 pl-10 pr-11 py-8">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[11.5px] tracking-[0.18em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Dernier import' : 'Nouveau dans la médiathèque' }}</span>
|
||||
<span class="w-[26px] h-px bg-accent/50" />
|
||||
<span class="text-[13px] text-secondary">{{ formatRelative(hero.at) }}</span>
|
||||
</div>
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="flex flex-col gap-0.5 w-fit">
|
||||
<span class="font-display text-[40px] sm:text-[56px] leading-[1.02] font-extrabold text-primary line-clamp-2">{{ heroTitle.name }}</span>
|
||||
<span v-if="heroTitle.suffix" class="font-display text-2xl sm:text-[30px] leading-[1.15] font-semibold text-accent">{{ heroTitle.suffix }}</span>
|
||||
</RouterLink>
|
||||
<p class="text-[15px] leading-[1.6] text-secondary max-w-[420px] text-pretty">
|
||||
<template v-for="part in heroMeta" :key="part">{{ part }} · </template>
|
||||
<span class="text-primary">{{ plural(hero.episodes, 'épisode') }}{{ auth.isAdmin ? (hero.episodes > 1 ? ' ajoutés' : ' ajouté') : '' }}</span>
|
||||
{{ auth.isAdmin ? 'à la médiathèque.' : 'à regarder.' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-5 mt-1.5 flex-wrap">
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="btn-primary h-[42px] text-sm pl-[22px]">
|
||||
Voir la série <ArrowRight :size="16" />
|
||||
</RouterLink>
|
||||
<RouterLink :to="auth.isAdmin ? '/activity' : '/series'" class="text-body text-secondary hover:text-primary transition-colors">
|
||||
{{ auth.isAdmin ? 'Tous les imports récents' : 'Toutes les nouveautés' }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink :to="`/series/${hero.serie.id}`" class="relative shrink-0 rotate-2">
|
||||
<img v-if="hero.serie.poster_image" :src="hero.serie.poster_image" :alt="hero.serie.title" class="w-[180px] h-[270px] object-cover rounded-poster shadow-[0_28px_60px_rgb(0_0_0/0.55),0_0_0_1px_rgb(239_233_221/0.08)]" />
|
||||
<span v-if="isNew(hero.at)" class="stamp-new absolute -left-7 top-4 text-[15px] px-[11px] pt-1 pb-[3px] tracking-[0.14em] -rotate-[9deg]">NOUVEAU</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!loadingRecent" class="relative rounded-card overflow-hidden bg-card max-lg:order-1">
|
||||
<div class="dot-grid" />
|
||||
<div class="relative flex flex-col gap-3 px-6 sm:px-10 py-10">
|
||||
<span class="text-[11.5px] tracking-[0.18em] font-bold uppercase text-accent">{{ auth.isAdmin ? 'Dernier import' : 'Nouveautés' }}</span>
|
||||
<p class="font-display text-[28px] font-bold text-primary">Rien de nouveau pour l'instant</p>
|
||||
<p class="text-body text-secondary max-w-[460px]">
|
||||
{{ auth.isAdmin
|
||||
? 'Les séries importées apparaîtront ici dès la fin de leur téléchargement.'
|
||||
: 'Les séries ajoutées à la médiathèque apparaîtront ici. En attendant, parcourez le catalogue et demandez ce qui vous manque.' }}
|
||||
</p>
|
||||
<RouterLink to="/series" class="btn-secondary w-fit mt-1">Parcourir la médiathèque</RouterLink>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted shrink-0">{{ formatRelative(n.at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<!-- Récemment ajoutés -->
|
||||
<section v-if="recentList.length > 0" class="flex flex-col gap-3.5 max-lg:order-4">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="section-title">Récemment ajoutés</h2>
|
||||
<RouterLink to="/series" class="text-[13px] text-secondary hover:text-primary transition-colors flex items-center gap-1">
|
||||
Médiathèque <ChevronRight :size="14" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3.5">
|
||||
<RouterLink v-for="item in recentList" :key="item.serie.id" :to="`/series/${item.serie.id}`" class="group flex flex-col gap-2 min-w-0">
|
||||
<img v-if="item.serie.poster_image" :src="item.serie.poster_image" :alt="item.serie.title" loading="lazy" class="w-full aspect-[2/3] object-cover rounded-[5px] shadow-[0_0_0_1px_rgb(239_233_221/0.06)] group-hover:-translate-y-0.5 transition-transform" />
|
||||
<div v-else class="w-full aspect-[2/3] rounded-[5px] bg-card flex items-center justify-center text-muted"><Tv :size="22" /></div>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-[13px] font-medium text-primary truncate">{{ item.serie.title }}</span>
|
||||
<span class="text-xs text-muted">{{ item.episodes }} ép. · {{ formatRelative(item.at) }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Widget demandes -->
|
||||
<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' }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="auth.isAdmin && pendingRequests.length > 0" class="text-xs px-2 py-0.5 rounded-full bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{{ pendingRequests.length }} en attente
|
||||
</span>
|
||||
<RouterLink to="/requests" class="text-xs text-muted hover:text-accent transition">Voir tout →</RouterLink>
|
||||
<!-- Chiffres -->
|
||||
<section data-tour="dashboard-stats" class="border-t border-card pt-[18px] max-lg:order-5">
|
||||
<div v-if="auth.isAdmin" class="flex flex-col sm:flex-row gap-3 sm:gap-0">
|
||||
<div class="flex-1 flex items-baseline gap-2.5">
|
||||
<span class="font-display text-[34px] font-bold text-primary">{{ stats.catalogue }}</span>
|
||||
<span class="text-[13px] text-muted">séries au catalogue</span>
|
||||
</div>
|
||||
<div class="hidden sm:block w-px bg-card mx-6" />
|
||||
<div class="flex-1 flex items-baseline gap-2.5">
|
||||
<span class="font-display text-[34px] font-bold text-primary">{{ stats.imported }}</span>
|
||||
<span class="text-[13px] text-muted">dans la médiathèque · {{ importedPercent }} %</span>
|
||||
</div>
|
||||
<div class="hidden sm:block w-px bg-card mx-6" />
|
||||
<div class="flex-1 flex items-baseline gap-2.5">
|
||||
<span class="font-display text-[34px] font-bold text-primary">{{ stats.watched }}</span>
|
||||
<span class="text-[13px] text-muted">{{ stats.watched > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-baseline gap-2.5 flex-wrap">
|
||||
<span class="font-display text-[34px] font-bold text-primary">{{ stats.imported }}</span>
|
||||
<span class="text-[13px] text-muted">
|
||||
{{ stats.imported > 1 ? 'séries disponibles' : 'série disponible' }} dans la médiathèque<template v-if="toRequestCount > 0"> · {{ toRequestCount }} {{ toRequestCount > 1 ? 'autres' : 'autre' }} au catalogue, à demander</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Panneau debug -->
|
||||
<section v-if="devMode" class="flex flex-col gap-3 max-lg:order-6">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h2 class="section-title">Diagnostic</h2>
|
||||
<span class="pill pill-wait">DEV</span>
|
||||
<button @click="fetchDebug" class="ml-auto btn-ghost btn-sm"><RefreshCw :size="13" /> Rafraîchir</button>
|
||||
</div>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div v-for="d in debugCards" :key="d.label" class="card flex flex-col gap-1">
|
||||
<span class="tag-label">{{ d.label }}</span>
|
||||
<span class="font-display text-2xl font-bold" :class="d.accent ? 'text-accent' : 'text-primary'">{{ d.value }}</span>
|
||||
<span class="text-meta text-muted">{{ d.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card text-meta text-muted py-4 text-center">Chargement des stats…</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingRequests" class="flex items-center gap-2 text-muted text-xs py-2">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-else-if="pendingRequests.length === 0" class="text-xs text-muted py-2">
|
||||
{{ auth.isAdmin ? 'Aucune demande en attente' : 'Aucune demande' }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-border/50">
|
||||
<div v-for="req in pendingRequests" :key="req.id" class="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-xs text-primary truncate hover:text-accent transition block">{{ req.serieName }}</RouterLink>
|
||||
<p v-if="auth.isAdmin" class="text-[11px] text-muted mt-0.5">
|
||||
{{ req.requesters.length }} demandeur{{ req.requesters.length > 1 ? 's' : '' }}
|
||||
· {{ req.requesters.map((r: any) => r.username).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded border shrink-0"
|
||||
:class="{
|
||||
'bg-yellow-500/10 text-yellow-400 border-yellow-500/20': req.status === 'pending',
|
||||
'bg-blue-500/10 text-blue-400 border-blue-500/20' : req.status === 'approved',
|
||||
'bg-green-500/10 text-green-400 border-green-500/20' : req.status === 'completed',
|
||||
'bg-red-500/10 text-red-400 border-red-500/20' : req.status === 'rejected',
|
||||
}">
|
||||
{{ { pending: 'Attente', approved: 'Approuvée', completed: 'Dispo', rejected: 'Refusée' }[req.status as string] }}
|
||||
</span>
|
||||
|
||||
<!-- ── Admin --- -->
|
||||
<div v-if="auth.isAdmin" class="flex flex-col gap-7 min-w-0 max-lg:contents">
|
||||
|
||||
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h2 class="section-title">À traiter</h2>
|
||||
<span v-if="todoCount > 0" class="h-[22px] min-w-[22px] px-[7px] rounded-full bg-accent text-on-accent text-xs font-bold flex items-center justify-center">{{ todoCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-card rounded-card flex flex-col">
|
||||
<div v-if="loadingTodo" class="flex items-center gap-2 text-muted text-meta px-[18px] py-5">
|
||||
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
|
||||
</div>
|
||||
<p v-else-if="todoCount === 0" class="text-meta text-muted px-[18px] py-5">Rien à traiter : aucune demande en attente, aucun import en erreur.</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Demandes -->
|
||||
<div v-for="req in visibleRequests" :key="req.id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
|
||||
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px]" />
|
||||
<span v-else class="w-10 h-[60px] rounded-[4px] bg-hover flex items-center justify-center text-muted"><Tv :size="16" /></span>
|
||||
</RouterLink>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="tag-label">Demande</span>
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ titleOf(req.serieId, req.serieName) }}</RouterLink>
|
||||
<span class="text-meta text-secondary">{{ scopeLabel(req) }} · {{ requesterNames(req) }} · {{ formatRelative(req.createdAt) }}</span>
|
||||
<span v-if="req.hasTorrents === false" class="text-meta text-err flex items-center gap-1.5"><TriangleAlert :size="13" /> Aucun torrent disponible</span>
|
||||
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
|
||||
<button @click="approve(req)" :disabled="busy[req.id]" class="btn-sm max-sm:flex-1" :class="req.hasTorrents === false ? 'btn-secondary' : 'btn-primary'">
|
||||
{{ req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
|
||||
</button>
|
||||
<button @click="rejectTarget = req" class="text-meta text-secondary hover:text-err transition-colors max-sm:hidden">Refuser</button>
|
||||
<button @click="rejectTarget = req" class="btn-icon btn-sm sm:hidden hover:text-err" title="Refuser" aria-label="Refuser la demande"><X :size="15" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imports en erreur -->
|
||||
<div v-for="err in visibleErrors" :key="err.hash" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<span class="w-10 h-10 rounded-field bg-err/12 text-err flex items-center justify-center shrink-0"><TriangleAlert :size="18" :stroke-width="1.75" /></span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="tag-label text-err">Import en erreur</span>
|
||||
<span class="text-[14.5px] font-bold text-primary truncate" :title="err.name">{{ err.title }}</span>
|
||||
<span class="text-meta text-secondary line-clamp-2" :title="err.detail">{{ err.detail }}</span>
|
||||
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] flex-wrap max-sm:-ml-[54px]">
|
||||
<button v-if="err.torrent" @click="retryImport(err)" :disabled="busy[err.hash]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
|
||||
{{ busy[err.hash] ? 'Import…' : 'Réessayer' }}
|
||||
</button>
|
||||
<button v-if="err.serieId" @click="openManualImport(err)" class="text-meta text-secondary hover:text-primary transition-colors max-sm:h-10 max-sm:px-2">Import manuel</button>
|
||||
<RouterLink v-if="!err.torrent" to="/activity" class="text-meta text-secondary hover:text-primary transition-colors">Voir dans Activité</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dossiers à renommer -->
|
||||
<div v-for="f in visibleFolders" :key="f.serie_id" class="flex gap-3.5 px-[18px] py-4 border-b border-hover">
|
||||
<span class="w-10 h-10 rounded-field bg-accent/10 text-accent flex items-center justify-center shrink-0"><Folder :size="18" :stroke-width="1.75" /></span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="tag-label">Dossier à renommer</span>
|
||||
<RouterLink :to="`/series/${f.serie_id}`" class="text-[14.5px] font-bold text-primary hover:text-accent transition-colors truncate">{{ f.serie_title }}</RouterLink>
|
||||
<span class="text-meta text-secondary break-words">{{ f.current.map(basename).join(', ') }} → {{ basename(f.expected) }}</span>
|
||||
<div class="flex items-center gap-x-3.5 gap-y-2 mt-[9px] max-sm:-ml-[54px]">
|
||||
<button @click="renameFolder(f)" :disabled="busy[`folder-${f.serie_id}`]" class="btn-secondary btn-sm font-medium max-sm:flex-1">
|
||||
{{ busy[`folder-${f.serie_id}`] ? 'Renommage…' : 'Renommer le dossier' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink v-for="more in overflowLinks" :key="more.to + more.label" :to="more.to" class="flex items-center justify-between px-[18px] py-3.5 text-[13px] text-secondary hover:text-primary transition-colors border-b border-hover last:border-b-0">
|
||||
<span>{{ more.label }}</span>
|
||||
<ChevronRight :size="14" />
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4 max-lg:order-3">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<RouterLink to="/activity" class="section-title hover:text-accent transition-colors">Téléchargements</RouterLink>
|
||||
<span class="text-meta text-muted">{{ plural(stats.downloading, 'actif') }}<template v-if="totalSpeed > 0"> · {{ formatSpeed(totalSpeed) }}</template></span>
|
||||
</div>
|
||||
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-meta">
|
||||
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
|
||||
</div>
|
||||
<p v-else-if="activeTorrents.length === 0" class="text-meta text-muted">Aucun téléchargement en cours.</p>
|
||||
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-[7px]">
|
||||
<div class="flex justify-between gap-3">
|
||||
<span class="text-body font-medium text-primary truncate" :title="t.name">{{ t.name }}</span>
|
||||
<span class="text-[13px] tabular-nums shrink-0" :class="t.state === 'error' ? 'text-err' : 'text-primary'">{{ Math.round(t.progress) }} %</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" :class="{ 'bg-muted': t.state === 'paused', 'bg-err': t.state === 'error' }" :style="{ width: `${Math.min(100, t.progress)}%` }" />
|
||||
</div>
|
||||
<span class="text-xs text-muted">
|
||||
{{ formatSize(t.downloaded) }} sur {{ formatSize(t.size) }}<template v-if="t.state === 'downloading' && t.eta > 0"> · reste {{ formatDuration(t.eta) }}</template><template v-else-if="t.state !== 'downloading'"> · {{ torrentStateLabel(t.state) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ── Colonne droite : invité ─────────────────────────── -->
|
||||
<div v-else class="flex flex-col gap-7 min-w-0 max-lg:contents">
|
||||
|
||||
<section data-tour="dashboard-requests" class="flex flex-col gap-3 max-lg:order-2">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h2 class="section-title">Mes demandes</h2>
|
||||
<span v-if="myPendingCount > 0" class="h-[22px] min-w-[22px] px-[7px] rounded-full bg-accent-muted text-accent text-xs font-bold flex items-center justify-center">{{ myPendingCount }}</span>
|
||||
</div>
|
||||
<div class="bg-card rounded-card flex flex-col">
|
||||
<div v-if="loadingTodo" class="flex items-center gap-2 text-muted text-meta px-[18px] py-5">
|
||||
<span class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" /> Chargement…
|
||||
</div>
|
||||
<p v-else-if="requests.length === 0" class="text-meta text-muted px-[18px] py-5">Aucune demande pour l'instant. Ouvrez une fiche série pour demander ce qui vous manque.</p>
|
||||
<RouterLink v-for="req in requests.slice(0, 3)" :key="req.id" :to="`/series/${req.serieId}`" class="flex gap-3.5 px-[18px] py-3.5 border-b border-hover hover:bg-hover/40 transition-colors">
|
||||
<img v-if="posterOf(req.serieId)" :src="posterOf(req.serieId)!" alt="" class="w-10 h-[60px] object-cover rounded-[4px] shrink-0" />
|
||||
<span v-else class="w-10 h-[60px] rounded-[4px] bg-hover flex items-center justify-center text-muted shrink-0"><Tv :size="16" /></span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<span class="text-[14.5px] font-bold text-primary truncate">{{ titleOf(req.serieId, req.serieName) }}</span>
|
||||
<span class="text-meta text-secondary">{{ scopeLabel(req) }} · {{ req.status === 'completed' ? 'ajoutée' : 'demandée' }} {{ formatRelative(req.status === 'completed' ? req.updatedAt : req.createdAt) }}</span>
|
||||
<span v-if="req.status === 'completed'" class="flex items-center gap-[7px] text-xs font-bold text-ok"><Check :size="13" :stroke-width="2.5" /> Disponible</span>
|
||||
<span v-else class="pill h-5 px-[9px] text-[11px] w-fit" :class="guestStatus[req.status]?.class">{{ guestStatus[req.status]?.label }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<RouterLink to="/requests" class="flex items-center justify-between px-[18px] py-3.5 text-[13px] text-secondary hover:text-primary transition-colors">
|
||||
<span>Toutes mes demandes</span>
|
||||
<ChevronRight :size="14" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="notYetHere.length > 0" class="flex flex-col gap-3.5 max-lg:order-3">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<h2 class="section-title">Pas encore là</h2>
|
||||
<span class="text-meta text-muted">au catalogue Fankai</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div v-for="s in notYetHere" :key="s.id" class="flex flex-col gap-2 min-w-0">
|
||||
<RouterLink :to="`/series/${s.id}`">
|
||||
<img v-if="s.poster_image" :src="s.poster_image" :alt="s.title" loading="lazy" class="w-full aspect-[2/3] object-cover rounded-[5px] shadow-[0_0_0_1px_rgb(239_233_221/0.06)]" />
|
||||
<span v-else class="w-full aspect-[2/3] rounded-[5px] bg-card flex items-center justify-center text-muted"><Tv :size="20" /></span>
|
||||
</RouterLink>
|
||||
<span class="text-meta font-medium text-primary truncate">{{ s.title }}</span>
|
||||
<button @click="requestSerie(s)" :disabled="busy[`req-${s.id}`]" class="btn-secondary btn-sm px-2 w-full">
|
||||
{{ busy[`req-${s.id}`] ? 'Envoi…' : 'Demander' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panneau debug -->
|
||||
<div v-if="devMode" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-medium text-secondary">Debug</h2>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
|
||||
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
|
||||
</div>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Heap utilisé</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
|
||||
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">RSS (process)</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
|
||||
<span class="text-xs text-muted">mémoire système</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Uptime</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
|
||||
<span class="text-xs text-muted">depuis le démarrage</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Cache GitHub</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
|
||||
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Fichiers trackés</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
|
||||
<span class="text-xs text-muted">dans organized.json</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Worker organize</span>
|
||||
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
|
||||
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
|
||||
</div>
|
||||
<RejectRequestModal
|
||||
v-if="rejectTarget"
|
||||
:serie-name="titleOf(rejectTarget.serieId, rejectTarget.serieName)"
|
||||
@close="rejectTarget = null"
|
||||
@confirm="confirmReject"
|
||||
/>
|
||||
|
||||
<ManualImportModal
|
||||
v-if="manualImport"
|
||||
:serie-id="manualImport.serieId"
|
||||
:serie-name="manualImport.serieName"
|
||||
:seasons="manualImport.seasons"
|
||||
:organized="manualImport.organized"
|
||||
:initial-path="manualImport.mediaPath"
|
||||
@close="manualImport = null"
|
||||
@imported="fetchTorrents(); fetchRecent()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { ArrowRight, Check, ChevronRight, Folder, RefreshCw, Search, TriangleAlert, Tv, X } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSeriesStore, type Serie } from '@/stores/series'
|
||||
import { useRequestsStore } from '@/stores/requests'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import { formatDuration, formatRelative, formatSize, formatSpeed, formatToday, plural } from '@/utils/format'
|
||||
import { isNew, splitTitle, statusLabel } from '@/utils/series'
|
||||
import { requesterNames, scopeLabel, type SerieRequest } from '@/utils/requests'
|
||||
import RejectRequestModal from '@/components/requests/RejectRequestModal.vue'
|
||||
import ManualImportModal from '@/components/ManualImportModal.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const seriesStore = useSeriesStore()
|
||||
const reqStore = useRequestsStore()
|
||||
const librarySearch = useLibrarySearch()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const today = formatToday()
|
||||
const searchText = ref('')
|
||||
const openSearchSoon = useDebounceFn(() => { if (searchText.value.trim()) librarySearch.open(searchText.value) }, 450)
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loadingTorrents = ref(true)
|
||||
const loadingNotifs = ref(true)
|
||||
const loadingRecent = ref(true)
|
||||
const loadingTodo = ref(true)
|
||||
const devMode = ref(false)
|
||||
const debug = ref<any>(null)
|
||||
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
||||
const activeTorrents = ref<any[]>([])
|
||||
const recentNotifs = ref<any[]>([])
|
||||
const pendingRequests = ref<any[]>([])
|
||||
const loadingRequests = ref(true)
|
||||
const catalogueCount = ref(0)
|
||||
const torrents = ref<any[]>([])
|
||||
const recent = ref<{ serieId: number; episodes: number; at: string }[]>([])
|
||||
const requests = ref<SerieRequest[]>([])
|
||||
const errorNotifs = ref<any[]>([])
|
||||
const staleFolders = ref<{ serie_id: number; serie_title: string; expected: string; current: string[] }[]>([])
|
||||
const busy = reactive<Record<string, boolean>>({})
|
||||
const rejectTarget = ref<SerieRequest | null>(null)
|
||||
const manualImport = ref<{ serieId: number; serieName: string; seasons: any[]; organized: Record<string, any>; mediaPath: string } | null>(null)
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Séries et utilitaires ─────────────────────────────
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
|
||||
const titleOf = (id: number, fallback: string) => seriesById.value.get(id)?.title ?? fallback
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
|
||||
|
||||
const recentWithSerie = computed(() => recent.value
|
||||
.map(r => ({ ...r, serie: seriesById.value.get(r.serieId) }))
|
||||
.filter((r): r is typeof r & { serie: Serie } => !!r.serie))
|
||||
|
||||
const hero = computed(() => recentWithSerie.value[0] ?? null)
|
||||
const recentList = computed(() => recentWithSerie.value.slice(1, 7))
|
||||
const heroTitle = computed(() => splitTitle(hero.value?.serie.title ?? ''))
|
||||
const heroMeta = computed(() => [hero.value?.serie.year, statusLabel(hero.value?.serie.status)].filter(Boolean).map(String))
|
||||
|
||||
// ── Chiffres
|
||||
const stats = computed(() => ({
|
||||
catalogue : catalogueCount.value || seriesStore.series.length,
|
||||
imported : seriesStore.series.filter(s => s.download_state === 'complete').length,
|
||||
watched : seriesStore.series.filter(s => s.rss_synced).length,
|
||||
downloading: torrents.value.filter(t => t.state === 'downloading').length,
|
||||
}))
|
||||
const importedPercent = computed(() => stats.value.catalogue > 0 ? Math.round(stats.value.imported / stats.value.catalogue * 100) : 0)
|
||||
const toRequestCount = computed(() => Math.max(0, stats.value.catalogue - stats.value.imported))
|
||||
|
||||
const activeTorrents = computed(() => torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5))
|
||||
const totalSpeed = computed(() => torrents.value.filter(t => t.state === 'downloading').reduce((sum, t) => sum + (t.speed ?? 0), 0))
|
||||
|
||||
function torrentStateLabel(state: string) {
|
||||
return ({ paused: 'en pause', checking: 'vérification', error: 'erreur du client' } as Record<string, string>)[state] ?? state
|
||||
}
|
||||
|
||||
// ── À traiter (admin)
|
||||
// Les plus anciennes d'abord
|
||||
const pendingRequests = computed(() => requests.value.filter(r => r.status === 'pending').sort((a, b) => a.createdAt.localeCompare(b.createdAt)))
|
||||
|
||||
|
||||
function errorDetail(e: { file: string; error: string }) {
|
||||
const ep = basename(e.file).match(/S\d+E(\d+)/i)
|
||||
return `${ep ? `E${ep[1]}` : basename(e.file)} · ${e.error}`
|
||||
}
|
||||
|
||||
// Dernier passage d'import de chaque torrent, s'il a laissé des erreurs
|
||||
const importErrors = computed(() => {
|
||||
const seen = new Set<string>()
|
||||
const list: { hash: string; name: string; title: string; detail: string; serieId: number | null; torrent: any }[] = []
|
||||
for (const n of errorNotifs.value) {
|
||||
if (seen.has(n.hash)) continue
|
||||
seen.add(n.hash)
|
||||
if (!n.errors) continue
|
||||
const torrent = torrents.value.find(t => t.hash === n.hash) ?? null
|
||||
const serieId = n.serieId ?? torrent?.serieId ?? null
|
||||
const first = n.errorFiles?.[0]
|
||||
const detail = first ? errorDetail(first) : plural(n.errors, 'erreur')
|
||||
list.push({
|
||||
hash: n.hash, name: n.name, serieId, torrent,
|
||||
title : serieId ? titleOf(serieId, n.name) : (torrent?.serieName ?? n.name),
|
||||
detail: n.errors > 1 ? `${detail} (+${n.errors - 1})` : detail,
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const visibleRequests = computed(() => pendingRequests.value.slice(0, 2))
|
||||
const visibleErrors = computed(() => importErrors.value.slice(0, 3))
|
||||
const visibleFolders = computed(() => staleFolders.value.slice(0, 3))
|
||||
const todoCount = computed(() => pendingRequests.value.length + importErrors.value.length + staleFolders.value.length)
|
||||
|
||||
const overflowLinks = computed(() => {
|
||||
const links: { to: string; label: string }[] = []
|
||||
const moreRequests = pendingRequests.value.length - visibleRequests.value.length
|
||||
const moreErrors = importErrors.value.length - visibleErrors.value.length
|
||||
const moreFolders = staleFolders.value.length - visibleFolders.value.length
|
||||
if (moreRequests > 0) links.push({ to: '/requests', label: `${moreRequests} ${moreRequests > 1 ? 'autres demandes' : 'autre demande'} en attente` })
|
||||
if (moreErrors > 0) links.push({ to: '/activity', label: `${moreErrors} ${moreErrors > 1 ? 'autres imports' : 'autre import'} en erreur` })
|
||||
if (moreFolders > 0) links.push({ to: '/settings/import-management', label: `${moreFolders} ${moreFolders > 1 ? 'autres dossiers' : 'autre dossier'} à renommer` })
|
||||
return links
|
||||
})
|
||||
|
||||
// ── Invité ────────────────────────────────────────────────────
|
||||
const myPendingCount = computed(() => requests.value.filter(r => r.status === 'pending').length)
|
||||
|
||||
const guestStatus: Record<string, { label: string; class: string }> = {
|
||||
pending : { label: 'En attente', class: 'pill-wait' },
|
||||
approved: { label: 'Bientôt là', class: 'pill-active' },
|
||||
rejected: { label: 'Refusée', class: 'pill-err' },
|
||||
}
|
||||
|
||||
const notYetHere = computed(() => {
|
||||
const requested = new Set(requests.value.filter(r => r.status !== 'rejected').map(r => r.serieId))
|
||||
return seriesStore.series
|
||||
.filter(s => s.download_state === 'none' && !requested.has(s.id) && !isNew(s.last_imported_at))
|
||||
.sort((a, b) => Number(b.has_torrents) - Number(a.has_torrents) || (b.year ?? 0) - (a.year ?? 0))
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
async function fetchSettings() {
|
||||
if (!auth.isAdmin) return
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
@@ -202,96 +492,156 @@ async function fetchDebug() {
|
||||
if (res.ok) debug.value = await res.json()
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
const debugCards = computed(() => debug.value ? [
|
||||
{ label: 'Heap utilisé', value: `${debug.value.memory.heapUsed} Mo`, hint: `sur ${debug.value.memory.heapTotal} Mo` },
|
||||
{ label: 'RSS', value: `${debug.value.memory.rss} Mo`, hint: 'mémoire système' },
|
||||
{ label: 'Uptime', value: debug.value.uptime, hint: 'depuis le démarrage' },
|
||||
{ label: 'Cache GitHub', value: debug.value.cache.entries, hint: `entrées (TTL ${debug.value.cache.ttlHours} h)` },
|
||||
{ label: 'Fichiers suivis', value: debug.value.organized.trackedFiles, hint: 'dans organized.json' },
|
||||
{ label: 'Worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', hint: `${debug.value.requests.notifs} import(s) en mémoire`, accent: debug.value.worker.running },
|
||||
] : [])
|
||||
|
||||
async function fetchCatalogue() {
|
||||
try {
|
||||
const [statusRes, seriesRes] = await Promise.all([
|
||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||
fetch('/api/series', { credentials: 'include' }),
|
||||
])
|
||||
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
|
||||
if (seriesRes.ok) {
|
||||
const s = await seriesRes.json()
|
||||
const list = Array.isArray(s) ? s : (s.series ?? [])
|
||||
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
|
||||
}
|
||||
// Stats téléchargements : admin uniquement
|
||||
if (auth.isAdmin) {
|
||||
const downloadsRes = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (downloadsRes.ok) {
|
||||
const torrents = await downloadsRes.json()
|
||||
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
|
||||
}
|
||||
}
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) catalogueCount.value = (await res.json()).count ?? 0
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchRecent() {
|
||||
try {
|
||||
const res = await fetch('/api/library/recent?limit=12', { credentials: 'include' })
|
||||
if (res.ok) recent.value = await res.json()
|
||||
} catch {} finally { loadingRecent.value = false }
|
||||
}
|
||||
|
||||
async function fetchTorrents() {
|
||||
if (!auth.isAdmin) { loadingTorrents.value = false; return }
|
||||
const isFirst = loadingTorrents.value
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const list = await res.json()
|
||||
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
|
||||
}
|
||||
} catch {} finally {
|
||||
if (isFirst) loadingTorrents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifs() {
|
||||
if (!auth.isAdmin) { loadingNotifs.value = false; return }
|
||||
loadingNotifs.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
|
||||
} catch {} finally {
|
||||
loadingNotifs.value = false
|
||||
}
|
||||
const [dl, notifs] = await Promise.all([
|
||||
fetch('/api/downloads', { credentials: 'include' }),
|
||||
fetch('/api/organize/recent', { credentials: 'include' }),
|
||||
])
|
||||
if (dl.ok) torrents.value = await dl.json()
|
||||
if (notifs.ok) errorNotifs.value = await notifs.json()
|
||||
} catch {} finally { loadingTorrents.value = false }
|
||||
}
|
||||
|
||||
async function fetchRequests() {
|
||||
loadingRequests.value = true
|
||||
try {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const all = await res.json()
|
||||
pendingRequests.value = auth.isAdmin
|
||||
? all.filter((r: any) => r.status === 'pending').slice(0, 5)
|
||||
: all.slice(0, 5)
|
||||
const all: SerieRequest[] = await res.json()
|
||||
requests.value = all.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
}
|
||||
} catch {} finally {
|
||||
loadingRequests.value = false
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
async function fetchFolders() {
|
||||
if (!auth.isAdmin) return
|
||||
try {
|
||||
const res = await fetch('/api/organized-folders', { credentials: 'include' })
|
||||
if (res.ok) staleFolders.value = await res.json()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
// ── Actions
|
||||
async function patchRequest(req: SerieRequest, body: object) {
|
||||
busy[req.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
|
||||
await fetchRequests()
|
||||
reqStore.refreshPending()
|
||||
return true
|
||||
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
|
||||
finally { busy[req.id] = false }
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
|
||||
if (m < 1) return "à l'instant"
|
||||
if (m < 60) return `il y a ${m}min`
|
||||
if (h < 24) return `il y a ${h}h`
|
||||
return `il y a ${d}j`
|
||||
async function approve(req: SerieRequest) {
|
||||
if (await patchRequest(req, { action: 'approve' }))
|
||||
toast(req.hasTorrents === false ? 'Demande approuvée, aucun torrent à télécharger' : 'Demande approuvée, téléchargement lancé', 'success')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings(), fetchRequests()])
|
||||
async function confirmReject(message: string) {
|
||||
const req = rejectTarget.value
|
||||
rejectTarget.value = null
|
||||
if (req && await patchRequest(req, { action: 'reject', rejectionMessage: message || undefined })) toast('Demande refusée', 'success')
|
||||
}
|
||||
|
||||
async function retryImport(err: { hash: string; torrent: any }) {
|
||||
busy[err.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ hash: err.torrent.hash, save_path: err.torrent.save_path, name: err.torrent.name }),
|
||||
})
|
||||
const d = await res.json()
|
||||
if (!res.ok) toast(d.error ?? "Erreur lors de l'import", 'error')
|
||||
else if (d.errors?.length) toast(`${d.done} importé(s), ${d.errors.length} erreur(s)`, 'error')
|
||||
else toast(`${d.done} fichier(s) importé(s) ✓`, 'success')
|
||||
await Promise.all([fetchTorrents(), fetchRecent()])
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[err.hash] = false }
|
||||
}
|
||||
|
||||
async function openManualImport(err: { serieId: number | null; title: string }) {
|
||||
if (!err.serieId) return
|
||||
try {
|
||||
const [detail, organized, settings] = await Promise.all([
|
||||
fetch(`/api/series/${err.serieId}`, { credentials: 'include' }),
|
||||
fetch(`/api/organized/${err.serieId}`, { credentials: 'include' }),
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
])
|
||||
if (!detail.ok) { toast('Série introuvable', 'error'); return }
|
||||
const d = await detail.json()
|
||||
manualImport.value = {
|
||||
serieId : err.serieId,
|
||||
serieName: d.serie?.title ?? err.title,
|
||||
seasons : d.seasons ?? [],
|
||||
organized: organized.ok ? await organized.json() : {},
|
||||
mediaPath: settings.ok ? ((await settings.json()).mediaPath || '/') : '/',
|
||||
}
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
async function renameFolder(f: { serie_id: number; expected: string }) {
|
||||
if (!confirm(`Déplacer le contenu vers « ${basename(f.expected)} » ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
const key = `folder-${f.serie_id}`
|
||||
busy[key] = true
|
||||
try {
|
||||
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
|
||||
toast(`Dossier renommé ✓ (${plural(d.moved, 'fichier')} ${d.moved > 1 ? 'déplacés' : 'déplacé'})`, 'success')
|
||||
await fetchFolders()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[key] = false }
|
||||
}
|
||||
|
||||
async function requestSerie(s: Serie) {
|
||||
const key = `req-${s.id}`
|
||||
busy[key] = true
|
||||
try {
|
||||
const res = await fetch('/api/requests', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieId: s.id, serieName: s.title, seasons: [] }),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
toast(`${s.title} demandée ✓`, 'success')
|
||||
await fetchRequests()
|
||||
reqStore.refreshPending()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[key] = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
|
||||
await Promise.all([fetchCatalogue(), fetchRecent(), fetchTorrents(), fetchSettings(), fetchRequests(), fetchFolders()])
|
||||
loadingTodo.value = false
|
||||
interval = setInterval(() => {
|
||||
fetchTorrents()
|
||||
if (devMode.value) fetchDebug()
|
||||
@@ -299,4 +649,4 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (interval) clearInterval(interval) })
|
||||
</script>
|
||||
</script>
|
||||
+128
-70
@@ -1,37 +1,32 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
<div class="px-4 md:px-10 pt-[26px] pb-12">
|
||||
<div class="max-w-[1400px] flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<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">
|
||||
<span class="w-1.5 h-1.5 rounded-full transition-colors" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'" />
|
||||
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
|
||||
</div>
|
||||
<!-- En-tête -->
|
||||
<header data-tour="activity-header" class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5 flex-wrap">
|
||||
<h1 class="page-title tracking-[0.01em]">Activité</h1>
|
||||
<span class="flex items-center gap-[7px] text-meta text-secondary">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-ok animate-pulse' : 'bg-muted'" />
|
||||
{{ polling ? 'En direct' : 'En pause' }}<template v-if="lastUpdate"> · mise à jour {{ sinceUpdate }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="importAll" :disabled="importingAll" class="btn-primary">{{ importingAll ? '...' : 'Importer tout' }}</button>
|
||||
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="loading ? 'animate-spin' : ''">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
<polyline points="16 5 21 5 21 10"/>
|
||||
</svg>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button @click="importAll" :disabled="importingAll || toImportCount === 0" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Loader v-if="importingAll" :size="15" class="animate-spin" />
|
||||
<Upload v-else :size="15" :stroke-width="2.25" />
|
||||
Importer tout<template v-if="toImportCount > 0"> · {{ toImportCount }}</template>
|
||||
</button>
|
||||
<button @click="fetchTorrents" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Rafraîchir" aria-label="Rafraîchir">
|
||||
<RefreshCw :size="16" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :title="polling ? 'Mettre en pause' : 'Reprendre'" :aria-label="polling ? 'Mettre en pause' : 'Reprendre'">
|
||||
<Pause v-if="polling" :size="16" />
|
||||
<Play v-else :size="16" />
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-secondary">{{ polling ? 'Pause' : 'Reprendre' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">{{ stat.label }}</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar + Tabs -->
|
||||
<DownloadsToolbar
|
||||
v-model:search="search"
|
||||
v-model:active-tab="activeTab"
|
||||
@@ -40,8 +35,7 @@
|
||||
:active-sort="activeSort"
|
||||
:sort-dir="sortDir"
|
||||
:columns="columns"
|
||||
:active-count="activeTorrents.length"
|
||||
:done-count="doneTorrents.length"
|
||||
:tabs="tabs"
|
||||
:column-options="columnOptions"
|
||||
:sort-options="sortOptions"
|
||||
@sort="setSort"
|
||||
@@ -49,31 +43,34 @@
|
||||
/>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucun client torrent configuré</p>
|
||||
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">Configurer un client →</RouterLink>
|
||||
<div v-if="noClients" class="card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">Aucun client torrent configuré</p>
|
||||
<p class="text-body text-muted">Ajoutez un client pour suivre les téléchargements FanKarr.</p>
|
||||
<RouterLink to="/settings/download-client" class="btn-secondary btn-sm mt-1">Configurer un client</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}</p>
|
||||
<p class="text-xs text-muted">{{ activeTab === 'active' ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' : hideImported ? 'Tous les torrents terminés sont importés' : '' }}</p>
|
||||
<div v-else-if="visibleTorrents.length === 0" class="card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">{{ emptyState.title }}</p>
|
||||
<p class="text-body text-muted">{{ emptyState.hint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- Une ligne par torrent -->
|
||||
<div v-else class="flex flex-col gap-2.5">
|
||||
<TorrentCard
|
||||
v-for="t in visibleTorrents"
|
||||
:key="t.hash"
|
||||
:torrent="t"
|
||||
:tab="activeTab"
|
||||
:columns="columns"
|
||||
:poster="posterOf(t.serieId)"
|
||||
:auto-import="schedule?.autoImport ?? true"
|
||||
:auto-import-in="autoImportIn"
|
||||
:importing="!!importing[t.hash]"
|
||||
:deleting="!!deleting[t.hash]"
|
||||
:show-confirm-delete="confirmDelete === t.hash"
|
||||
@@ -90,11 +87,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, Pause, Play, RefreshCw, Upload } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import TorrentCard from '@/components/downloads/TorrentCard.vue'
|
||||
import DownloadsToolbar from '@/components/downloads/DownloadsToolbar.vue'
|
||||
import DownloadsToolbar, { type ActivityTab } from '@/components/downloads/DownloadsToolbar.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
const seriesStore = useSeriesStore()
|
||||
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -104,20 +104,23 @@ const importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const confirmDelete = ref<string | null>(null)
|
||||
const deleting = ref<Record<string, boolean>>({})
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const activeTab = ref<string>('all')
|
||||
const hideImported = ref(false)
|
||||
const seedingOnly = ref(false)
|
||||
const search = ref('')
|
||||
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state' | 'ratio'>('none')
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
const lastUpdate = ref<number | null>(null)
|
||||
const now = ref(Date.now())
|
||||
const schedule = ref<{ autoImport: boolean; nextRunAt: string | null } | null>(null)
|
||||
|
||||
const columns = ref({ client: true, size: true, ratio: true, uploaded: true, upspeed: true })
|
||||
const columnOptions = [
|
||||
{ key: 'client', label: 'Client' },
|
||||
{ key: 'size', label: 'Taille / Téléchargé' },
|
||||
{ key: 'size', label: 'Taille / téléchargé' },
|
||||
{ key: 'ratio', label: 'Ratio' },
|
||||
{ key: 'uploaded', label: 'Uploadé (total)' },
|
||||
{ key: 'upspeed', label: 'Vitesse upload' },
|
||||
{ key: 'uploaded', label: 'Envoyé (total)' },
|
||||
{ key: 'upspeed', label: 'Vitesse d’envoi' },
|
||||
]
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
@@ -128,22 +131,39 @@ const sortOptions = [
|
||||
]
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
let clockInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Onglets ───────────────────────────────────────────────────
|
||||
const isActive = (t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)
|
||||
const isDone = (t: any) => t.state === 'seeding' || t.state === 'unknown'
|
||||
const isToImport = (t: any) => isDone(t) && t.organizeState !== 'done'
|
||||
const hasErrors = (t: any) => t.state === 'error' || (t.errorFiles?.length ?? 0) > 0
|
||||
|
||||
const tabPredicates: Record<string, (t: any) => boolean> = {
|
||||
'all' : () => true,
|
||||
'active' : isActive,
|
||||
'done' : isDone,
|
||||
'to-import': isToImport,
|
||||
'errors' : hasErrors,
|
||||
}
|
||||
|
||||
const tabs = computed<ActivityTab[]>(() => [
|
||||
{ value: 'all', label: 'Tout', count: torrents.value.length },
|
||||
{ value: 'active', label: 'En cours', count: torrents.value.filter(isActive).length, tone: 'accent' },
|
||||
{ value: 'done', label: 'Terminés', count: torrents.value.filter(isDone).length },
|
||||
{ value: 'to-import', label: 'À importer', count: torrents.value.filter(isToImport).length },
|
||||
{ value: 'errors', label: 'Erreurs', count: torrents.value.filter(hasErrors).length, tone: 'err' },
|
||||
])
|
||||
|
||||
const toImportCount = computed(() => torrents.value.filter(isToImport).length)
|
||||
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
|
||||
)
|
||||
const doneTorrents = computed(() =>
|
||||
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
|
||||
)
|
||||
const visibleTorrents = computed(() => {
|
||||
let list = activeTab.value === 'active' ? activeTorrents.value : doneTorrents.value
|
||||
if (activeTab.value === 'done') {
|
||||
if (seedingOnly.value) list = list.filter(t => t.state === 'seeding')
|
||||
if (hideImported.value) list = list.filter(t => t.organizeState !== 'done')
|
||||
}
|
||||
let list = torrents.value.filter(tabPredicates[activeTab.value] ?? tabPredicates.all!)
|
||||
if (seedingOnly.value) list = list.filter(t => !isDone(t) || t.state === 'seeding')
|
||||
if (hideImported.value) list = list.filter(t => !isDone(t) || t.organizeState !== 'done')
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(t => t.name.toLowerCase().includes(q))
|
||||
list = list.filter(t => t.name.toLowerCase().includes(q) || (t.serieName ?? '').toLowerCase().includes(q))
|
||||
}
|
||||
if (activeSort.value !== 'none') {
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
@@ -157,35 +177,66 @@ const visibleTorrents = computed(() => {
|
||||
default : return 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const rank = (t: any) => isActive(t) ? 0 : isToImport(t) ? 1 : 2
|
||||
list = [...list].sort((a, b) => rank(a) - rank(b))
|
||||
}
|
||||
return list
|
||||
})
|
||||
const stats = computed(() => [
|
||||
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
const emptyState = computed(() => ({
|
||||
'active' : { title: 'Aucun téléchargement en cours', hint: 'Les torrents de la catégorie « fankai » apparaîtront ici.' },
|
||||
'done' : { title: 'Aucun torrent terminé', hint: hideImported.value ? 'Tous les torrents terminés sont importés.' : '' },
|
||||
'to-import': { title: 'Rien à importer', hint: 'Tous les torrents terminés sont dans la médiathèque.' },
|
||||
'errors' : { title: 'Aucune erreur', hint: 'Ni le client ni l’import ne signalent de problème.' },
|
||||
} as Record<string, { title: string; hint: string }>)[activeTab.value] ?? { title: 'Aucun torrent', hint: search.value ? 'Aucun torrent ne correspond au filtre.' : 'Les torrents de la catégorie « fankai » apparaîtront ici.' })
|
||||
|
||||
// ── Présentation ──────────────────────────────────────────────
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number | undefined) => (id != null ? seriesById.value.get(id)?.poster_image : null) ?? null
|
||||
|
||||
const sinceUpdate = computed(() => {
|
||||
const s = Math.max(0, Math.round((now.value - (lastUpdate.value ?? now.value)) / 1000))
|
||||
return s < 60 ? `il y a ${s} s` : `il y a ${Math.floor(s / 60)} min`
|
||||
})
|
||||
|
||||
const autoImportIn = computed(() => {
|
||||
if (!schedule.value?.autoImport || !schedule.value.nextRunAt) return ''
|
||||
const min = Math.ceil((new Date(schedule.value.nextRunAt).getTime() - now.value) / 60_000)
|
||||
return min <= 0 ? 'imminent' : `dans ${min} min`
|
||||
})
|
||||
|
||||
function setSort(val: string) {
|
||||
if (activeSort.value === val) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
else { activeSort.value = val as typeof activeSort.value; sortDir.value = 'asc' }
|
||||
}
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (res.status === 503) { noClients.value = true; return }
|
||||
if (!res.ok) return
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
lastUpdate.value = Date.now()
|
||||
} catch {} finally { loading.value = false }
|
||||
fetchSchedule()
|
||||
}
|
||||
|
||||
async function fetchSchedule() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/schedule', { credentials: 'include' })
|
||||
if (res.ok) schedule.value = await res.json()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function startPolling() { fetchTorrents(); pollInterval = setInterval(fetchTorrents, 60_000) }
|
||||
function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null } }
|
||||
function togglePolling() { polling.value = !polling.value; polling.value ? startPolling() : stopPolling() }
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
@@ -200,7 +251,7 @@ async function importTorrent(torrent: any) {
|
||||
}
|
||||
|
||||
async function importAll() {
|
||||
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
const toImport = torrents.value.filter(isToImport)
|
||||
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
|
||||
importingAll.value = true
|
||||
let done = 0, errors = 0
|
||||
@@ -228,6 +279,13 @@ async function deleteTorrent(torrent: any, withFiles: boolean) {
|
||||
finally { deleting.value[torrent.hash] = false }
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
onMounted(() => {
|
||||
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
|
||||
startPolling()
|
||||
clockInterval = setInterval(() => { now.value = Date.now() }, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
</script>
|
||||
+35
-33
@@ -1,59 +1,59 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="relative min-h-screen bg-main flex items-center justify-center px-4 py-10 overflow-hidden">
|
||||
<div class="dot-grid [--dot-grid-angle:160deg] [--dot-grid-fade:45%]" />
|
||||
<div class="relative w-full max-w-sm">
|
||||
|
||||
<!-- 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>
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
<div class="flex items-center justify-center gap-3 mb-8">
|
||||
<FankarrLogo class="w-9 h-9" />
|
||||
<span class="font-display text-[30px] font-bold text-primary tracking-[0.01em]">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="status === 'loading'" class="settings-card p-8 text-center text-sm text-muted">
|
||||
<div v-if="status === 'loading'" class="card px-7 py-8 text-center text-body text-muted">
|
||||
Vérification du lien…
|
||||
</div>
|
||||
|
||||
<!-- Lien invalide -->
|
||||
<div v-else-if="status === 'invalid'" class="settings-card p-8 flex flex-col gap-3 text-center">
|
||||
<p class="text-sm font-semibold text-red-400">Lien invalide</p>
|
||||
<p class="text-sm text-muted">{{ invalidReason }}</p>
|
||||
<div v-else-if="status === 'invalid'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
|
||||
<span class="w-11 h-11 rounded-full bg-err/10 text-err flex items-center justify-center"><TriangleAlert :size="20" /></span>
|
||||
<p class="font-display text-xl font-bold text-primary">Lien invalide</p>
|
||||
<p class="text-body text-muted">{{ invalidReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Succès -->
|
||||
<div v-else-if="status === 'done'" class="settings-card p-8 flex flex-col gap-4 text-center">
|
||||
<p class="text-sm font-semibold text-green-400">Compte créé !</p>
|
||||
<p class="text-sm text-muted">Tu peux maintenant te connecter.</p>
|
||||
<RouterLink to="/auth" class="btn-primary w-full py-2.5 text-center">Se connecter</RouterLink>
|
||||
<div v-else-if="status === 'done'" class="card px-7 py-8 flex flex-col items-center gap-3 text-center">
|
||||
<span class="w-11 h-11 rounded-full bg-ok/10 text-ok flex items-center justify-center"><Check :size="20" :stroke-width="2.5" /></span>
|
||||
<p class="font-display text-xl font-bold text-primary">Compte créé</p>
|
||||
<p class="text-body text-muted">Tu peux maintenant te connecter.</p>
|
||||
<RouterLink to="/auth" class="btn-primary w-full h-11 mt-1">Se connecter</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'inscription -->
|
||||
<div v-else class="settings-card p-8 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Invitation FanKarr</p>
|
||||
<h1 class="text-lg font-semibold text-primary">Créer un compte</h1>
|
||||
<p v-if="note" class="text-xs text-muted mt-1">{{ note }}</p>
|
||||
<div v-else class="card px-7 py-7 flex flex-col gap-5 shadow-[0_24px_60px_rgb(0_0_0/0.4)]">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="tag-label text-accent">Invitation FanKarr</p>
|
||||
<h1 class="font-display text-[26px] font-bold text-primary leading-tight">Créer un compte</h1>
|
||||
<p v-if="note" class="text-meta text-muted">{{ note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="username" type="text" placeholder="username" class="settings-input" @keyup.enter="submit" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-username" class="field-label">Identifiant</label>
|
||||
<input id="invite-username" v-model="username" type="text" placeholder="username" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe</label>
|
||||
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-password" class="field-label">Mot de passe</label>
|
||||
<input id="invite-password" v-model="password" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-confirm" class="field-label">Confirmer</label>
|
||||
<input id="invite-confirm" v-model="confirm" type="password" placeholder="••••••••" class="field" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<p v-if="error" class="text-meta text-err" role="alert">{{ error }}</p>
|
||||
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full h-11 mt-1">
|
||||
{{ submitting ? '…' : 'Créer le compte' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +65,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { Check, TriangleAlert } from 'lucide-vue-next'
|
||||
import FankarrLogo from '@/components/FankarrLogo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const code = route.params.code as string
|
||||
|
||||
+339
-203
@@ -1,184 +1,249 @@
|
||||
<template>
|
||||
<div class="px-4 md:px-8 py-8 max-w-4xl">
|
||||
<div class="px-4 md:px-10 pt-[26px] pb-12">
|
||||
<div class="max-w-[1100px] flex flex-col gap-[18px]">
|
||||
|
||||
<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">
|
||||
{{ auth.isAdmin ? 'Toutes les demandes des utilisateurs.' : 'Vos demandes de séries.' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Actions admin -->
|
||||
<div v-if="auth.isAdmin" class="flex items-center gap-2">
|
||||
<header data-tour="requests-header" class="flex items-center justify-between gap-x-4 gap-y-3 flex-wrap min-h-11">
|
||||
<div class="flex items-baseline gap-3.5 flex-wrap">
|
||||
<h1 class="page-title tracking-[0.01em]">{{ auth.isAdmin ? 'Demandes' : 'Mes demandes' }}</h1>
|
||||
<span class="text-[13px] text-muted">{{ counts.all }} au total · {{ counts.pending }} en attente</span>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin" class="flex items-center gap-2.5">
|
||||
<button
|
||||
v-if="approvableCount > 0"
|
||||
@click="approveAllWithTorrents"
|
||||
:disabled="bulkBusy"
|
||||
class="h-[38px] pointer-coarse:h-11 px-4 rounded-full border border-accent/35 text-accent text-body font-bold flex items-center gap-2 hover:bg-accent-muted transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Loader v-if="bulkBusy" :size="15" class="animate-spin" />
|
||||
<Check v-else :size="15" :stroke-width="2.25" />
|
||||
Approuver {{ approvableCount > 1 ? `les ${approvableCount}` : 'la demande' }}<span v-if="approvableCount > 1" class="max-sm:hidden"> demandes</span> avec torrent
|
||||
</button>
|
||||
<div ref="headerMenuRef" class="relative">
|
||||
<button @click="headerMenuOpen = !headerMenuOpen" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="headerMenuOpen">
|
||||
<Ellipsis :size="17" />
|
||||
</button>
|
||||
<div v-if="headerMenuOpen" class="menu absolute right-0 top-full mt-1.5 w-52 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item text-err hover:text-err" :disabled="requests.length === 0" @click="headerMenuOpen = false; removeAll()">
|
||||
<Trash2 :size="14" /> Tout supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink v-else to="/series" class="h-[38px] pointer-coarse:h-11 px-[18px] rounded-full border border-border text-primary text-body font-semibold flex items-center gap-[9px] hover:bg-hover transition-colors">
|
||||
<Plus :size="15" /> Demander une série
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<!-- Cmpteurs -->
|
||||
<div class="chip-row">
|
||||
<button
|
||||
@click="removeAll"
|
||||
class="px-3 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border border-red-500/20 transition"
|
||||
>Tout supprimer</button>
|
||||
</div>
|
||||
<!-- Filtres statuts -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="f in filters" :key="f.value"
|
||||
v-for="f in visibleFilters" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-accent text-white'
|
||||
: 'text-muted hover:text-primary hover:bg-hover'"
|
||||
class="chip"
|
||||
:class="{ 'is-active': activeFilter === f.value }"
|
||||
:aria-pressed="activeFilter === f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="counts[f.value]" class="ml-1 opacity-70">({{ counts[f.value] }})</span>
|
||||
{{ f.label }}<span class="chip-count" :class="{ 'text-accent! font-bold': f.value === 'pending' && counts.pending > 0 && activeFilter !== 'pending' }">{{ counts[f.value] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistiques (admin) -->
|
||||
<div v-if="auth.isAdmin" class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">En attente</span>
|
||||
<span class="text-2xl font-semibold text-yellow-400">{{ counts.pending }}</span>
|
||||
<!-- Liste -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-muted text-body py-8">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Approuvées</span>
|
||||
<span class="text-2xl font-semibold text-blue-400">{{ counts.approved }}</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Disponibles</span>
|
||||
<span class="text-2xl font-semibold text-green-400">{{ counts.completed }}</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Refusées</span>
|
||||
<span class="text-2xl font-semibold" :class="counts.rejected > 0 ? 'text-red-400' : 'text-muted'">{{ counts.rejected }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-muted text-sm py-8">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="card flex flex-col items-center gap-2 py-12 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">Aucune demande{{ activeFilter !== 'all' ? ' avec ce statut' : '' }}</p>
|
||||
<p class="text-body text-muted">
|
||||
{{ auth.isAdmin ? 'Les demandes arrivent de FanKarr ou du plugin Jellyfin.' : 'Ouvrez une fiche série pour demander ce qui vous manque.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="settings-card text-sm text-muted py-8 text-center">
|
||||
Aucune demande {{ activeFilter !== 'all' ? `avec ce statut` : '' }}.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3.5">
|
||||
<article
|
||||
v-for="req in filtered" :key="req.id"
|
||||
class="card px-[18px] py-4 flex flex-col sm:flex-row gap-4"
|
||||
>
|
||||
<div class="flex gap-4 flex-1 min-w-0">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="shrink-0">
|
||||
<img
|
||||
v-if="posterOf(req.serieId)"
|
||||
:src="posterOf(req.serieId)!"
|
||||
:alt="titleOf(req)"
|
||||
class="w-14 h-[84px] object-cover rounded-[5px]"
|
||||
:class="{ 'grayscale-[0.7] brightness-[0.8]': req.status === 'rejected' }"
|
||||
/>
|
||||
<span v-else class="w-14 h-[84px] rounded-[5px] bg-hover flex items-center justify-center text-muted"><Tv :size="18" /></span>
|
||||
</RouterLink>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="req in filtered" :key="req.id"
|
||||
class="settings-card flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<!-- Infos série -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-sm font-semibold text-primary hover:text-accent transition truncate">
|
||||
{{ req.serieName }}
|
||||
</RouterLink>
|
||||
<span class="text-xs px-2 py-0.5 rounded border shrink-0" :class="statusClass(req.status)">
|
||||
{{ statusLabel(req.status) }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[9px]">
|
||||
<div class="flex items-center gap-x-2.5 gap-y-1 flex-wrap">
|
||||
<RouterLink :to="`/series/${req.serieId}`" class="text-base font-bold hover:text-accent transition-colors truncate" :class="req.status === 'rejected' ? 'text-secondary' : 'text-primary'">
|
||||
{{ titleOf(req) }}
|
||||
</RouterLink>
|
||||
<span class="pill h-[22px] px-2.5" :class="statusPill(req.status).class">{{ statusPill(req.status).label }}</span>
|
||||
<span class="sm:ml-auto text-meta text-muted">{{ dateLabel(req) }}</span>
|
||||
</div>
|
||||
|
||||
<span class="text-body" :class="req.status === 'rejected' ? 'text-muted' : 'text-secondary'">{{ scopeLine(req) }}</span>
|
||||
|
||||
<!-- Admin : demandeurs et avertissement torrent -->
|
||||
<div v-if="auth.isAdmin && (req.status === 'pending' || req.status === 'approved')" class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-for="r in req.requesters" :key="r.userId"
|
||||
class="flex items-center gap-[7px] h-[26px] pl-1 pr-2.5 rounded-full bg-hover text-meta text-secondary"
|
||||
:title="`Demandé ${formatRelative(r.requestedAt)}`"
|
||||
>
|
||||
<span class="w-[18px] h-[18px] rounded-full bg-border text-primary text-[10px] font-bold flex items-center justify-center">{{ r.username.charAt(0).toUpperCase() }}</span>
|
||||
{{ r.username }}<span v-if="req.requesters.length > 1 && requesterScope(r)" class="text-muted">· {{ requesterScope(r) }}</span>
|
||||
</span>
|
||||
<span v-if="req.status === 'pending' && req.hasTorrents === false" class="flex items-center gap-[7px] text-meta text-err">
|
||||
<TriangleAlert :size="14" :stroke-width="2" /> Aucun torrent disponible — approuver ne lancera aucun téléchargement.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="auth.isAdmin && req.status === 'approved'" class="text-meta text-secondary">
|
||||
Téléchargement lancé à l'approbation.
|
||||
<RouterLink to="/activity" class="text-muted hover:text-primary transition-colors">Voir dans Activité</RouterLink>
|
||||
</p>
|
||||
|
||||
<!-- Invité : où en est la demande -->
|
||||
<p v-if="!auth.isAdmin && req.status !== 'rejected'" class="text-meta text-muted">{{ guestStatusLine[req.status] }}</p>
|
||||
|
||||
<p v-if="req.status === 'rejected'" class="text-meta text-err">
|
||||
{{ req.rejectionMessage ? `Motif : ${req.rejectionMessage}` : 'Refusée sans motif.' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Saisons / épisodes fusionnés -->
|
||||
<p class="text-xs text-muted mt-1">
|
||||
<template v-if="mergedEpisodes(req).length > 0">
|
||||
{{ mergedEpisodes(req).length }} épisode{{ mergedEpisodes(req).length > 1 ? 's' : '' }} spécifique{{ mergedEpisodes(req).length > 1 ? 's' : '' }}
|
||||
<template v-if="mergedSeasons(req).length > 0"> · Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</template>
|
||||
<template v-else-if="mergedSeasons(req).length === 0">Toutes les saisons</template>
|
||||
<template v-else>Saisons : {{ mergedSeasons(req).map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }}</template>
|
||||
</p>
|
||||
<!-- Message de refus -->
|
||||
<p v-if="req.rejectionMessage" class="text-xs text-red-400 mt-1">
|
||||
Refus : {{ req.rejectionMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions admin -->
|
||||
<div v-if="auth.isAdmin" class="flex items-center gap-1 shrink-0">
|
||||
<div v-if="auth.isAdmin" class="flex max-sm:items-center flex-col max-sm:flex-row gap-2 shrink-0 sm:w-52">
|
||||
<button
|
||||
v-if="req.status === 'pending' || req.status === 'rejected'"
|
||||
@click="approve(req)"
|
||||
class="btn-ghost text-xs text-blue-400 hover:text-blue-300"
|
||||
>Approuver</button>
|
||||
:disabled="busy[req.id]"
|
||||
class="h-9 max-sm:h-11 max-sm:flex-1 max-sm:min-w-0 px-3 rounded-full text-[13px] flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
|
||||
:class="req.status === 'pending' && req.hasTorrents !== false
|
||||
? 'bg-accent text-on-accent font-bold hover:bg-accent-hover'
|
||||
: 'border border-border text-primary font-semibold hover:bg-hover'"
|
||||
>
|
||||
<Check v-if="req.status === 'pending' && req.hasTorrents !== false" :size="15" :stroke-width="2.25" />
|
||||
{{ req.status === 'rejected' ? 'Approuver malgré tout' : req.hasTorrents === false ? 'Approuver quand même' : 'Approuver et télécharger' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="req.status === 'approved'"
|
||||
v-else-if="req.status === 'approved'"
|
||||
@click="complete(req)"
|
||||
class="btn-ghost text-xs text-green-400 hover:text-green-300"
|
||||
:disabled="busy[req.id]"
|
||||
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors disabled:opacity-50"
|
||||
>Marquer disponible</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="`/series/${req.serieId}`"
|
||||
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors flex items-center justify-center"
|
||||
>Voir la série</RouterLink>
|
||||
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button
|
||||
v-if="req.status === 'pending' || req.status === 'approved'"
|
||||
@click="rejectTarget = req"
|
||||
class="flex-1 h-[34px] max-sm:flex-none max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors flex items-center justify-center"
|
||||
:aria-label="req.status === 'approved' ? 'Annuler la demande' : 'Refuser la demande'"
|
||||
><X :size="16" class="sm:hidden" /><span class="max-sm:hidden">{{ req.status === 'approved' ? 'Annuler' : 'Refuser' }}</span></button>
|
||||
<button
|
||||
v-else
|
||||
@click="remove(req)"
|
||||
class="flex-1 h-[34px] max-sm:flex-none max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors flex items-center justify-center"
|
||||
aria-label="Supprimer la demande"
|
||||
><Trash2 :size="15" class="sm:hidden" /><span class="max-sm:hidden">Supprimer</span></button>
|
||||
|
||||
<div class="relative" @click.stop>
|
||||
<button
|
||||
@click="cardMenu = cardMenu === req.id ? null : req.id"
|
||||
class="w-[34px] h-[34px] max-sm:w-11 max-sm:h-11 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-primary hover:bg-hover transition-colors"
|
||||
aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="cardMenu === req.id"
|
||||
>
|
||||
<Ellipsis :size="15" />
|
||||
</button>
|
||||
<div v-if="cardMenu === req.id" class="menu absolute right-0 top-full mt-1.5 w-52 z-20" role="menu">
|
||||
<RouterLink v-if="req.status !== 'completed'" :to="`/series/${req.serieId}`" role="menuitem" class="menu-item">
|
||||
<Tv :size="14" /> Voir la série
|
||||
</RouterLink>
|
||||
<button v-if="req.status === 'completed'" role="menuitem" class="menu-item" @click="cardMenu = null; rejectTarget = req">
|
||||
<X :size="14" /> Refuser
|
||||
</button>
|
||||
<button v-if="req.status === 'pending' || req.status === 'approved'" role="menuitem" class="menu-item text-err hover:text-err" @click="cardMenu = null; remove(req)">
|
||||
<Trash2 :size="14" /> Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions invité -->
|
||||
<div v-else class="flex flex-col max-sm:flex-row gap-2 shrink-0 sm:w-[190px]">
|
||||
<RouterLink
|
||||
:to="`/series/${req.serieId}`"
|
||||
class="h-9 max-sm:h-11 max-sm:flex-1 rounded-full border border-border text-primary text-[13px] font-semibold hover:bg-hover transition-colors flex items-center justify-center"
|
||||
>Voir la série</RouterLink>
|
||||
<button
|
||||
v-if="req.status !== 'rejected'"
|
||||
@click="openRejectModal(req)"
|
||||
class="btn-ghost text-xs text-red-400 hover:text-red-300"
|
||||
>Refuser</button>
|
||||
<button @click="remove(req)" class="btn-ghost text-xs text-muted hover:text-red-400">Supprimer</button>
|
||||
v-if="req.status === 'pending'"
|
||||
@click="withdraw(req)"
|
||||
:disabled="busy[req.id]"
|
||||
class="h-[34px] max-sm:h-11 max-sm:flex-1 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-err hover:border-err/30 transition-colors disabled:opacity-50"
|
||||
>Annuler ma demande</button>
|
||||
<button
|
||||
v-else-if="req.status === 'rejected'"
|
||||
@click="requestAgain(req)"
|
||||
:disabled="busy[req.id]"
|
||||
class="h-[34px] max-sm:h-11 max-sm:flex-1 rounded-full border border-border-light text-secondary text-meta font-medium hover:text-primary hover:bg-hover transition-colors disabled:opacity-50"
|
||||
>Redemander</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requesters (admin) -->
|
||||
<div v-if="auth.isAdmin && req.requesters.length > 0" class="flex flex-wrap gap-2">
|
||||
<div v-for="r in req.requesters" :key="r.userId"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
|
||||
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
|
||||
{{ r.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-muted">{{ r.username }}</span>
|
||||
<span v-if="r.episodes?.length > 0" class="text-muted/60">
|
||||
({{ r.episodes.length }} ep.)
|
||||
</span>
|
||||
<span v-else-if="r.seasons.length > 0" class="text-muted/60">
|
||||
({{ r.seasons.map((s: number) => s === 0 ? 'SP' : `S${s}`).join(', ') }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted">Demandé {{ formatRelative(req.createdAt) }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal refus -->
|
||||
<Teleport to="body">
|
||||
<div v-if="rejectModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="rejectModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-4 p-6">
|
||||
<p class="text-sm font-semibold text-primary">Refuser la demande</p>
|
||||
<p class="text-xs text-muted truncate">{{ rejectModal.serieName }}</p>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Message de refus (optionnel)</label>
|
||||
<input v-model="rejectMessage" type="text" class="settings-input" placeholder="Raison du refus…" @keyup.enter="confirmReject" />
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button @click="rejectModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmReject" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition text-xs">
|
||||
Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<RejectRequestModal
|
||||
v-if="rejectTarget"
|
||||
:serie-name="titleOf(rejectTarget)"
|
||||
@close="rejectTarget = null"
|
||||
@confirm="confirmReject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Check, Ellipsis, Loader, Plus, Trash2, TriangleAlert, Tv, X } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useRequestsStore } from '@/stores/requests'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import { mergedEpisodes, mergedSeasons, seasonsLabel, type Requester, type SerieRequest } from '@/utils/requests'
|
||||
import RejectRequestModal from '@/components/requests/RejectRequestModal.vue'
|
||||
|
||||
interface Requester { userId: string; username: string; seasons: number[]; episodes: number[]; requestedAt: string }
|
||||
interface SerieRequest {
|
||||
id: string; serieId: number; serieName: string
|
||||
requesters: Requester[]; status: string
|
||||
rejectionMessage?: string; createdAt: string; updatedAt: string
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
const seriesStore = useSeriesStore()
|
||||
const reqStore = useRequestsStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const loading = ref(true)
|
||||
const requests = ref<SerieRequest[]>([])
|
||||
const busy = reactive<Record<string, boolean>>({})
|
||||
const bulkBusy = ref(false)
|
||||
|
||||
const activeFilter = ref<string>('all')
|
||||
const activeFilter = ref<'all' | 'pending' | 'approved' | 'completed' | 'rejected'>('all')
|
||||
const filters = [
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'pending', label: 'En attente' },
|
||||
{ value: 'approved', label: 'Approuvées' },
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'pending', label: 'En attente' },
|
||||
{ value: 'approved', label: 'Approuvées' },
|
||||
{ value: 'completed', label: 'Disponibles' },
|
||||
{ value: 'rejected', label: 'Refusées' },
|
||||
{ value: 'rejected', label: 'Refusées' },
|
||||
] as const
|
||||
|
||||
const counts = computed(() => ({
|
||||
@@ -189,40 +254,124 @@ const counts = computed(() => ({
|
||||
rejected : requests.value.filter(r => r.status === 'rejected').length,
|
||||
}))
|
||||
|
||||
const filtered = computed(() =>
|
||||
activeFilter.value === 'all'
|
||||
? requests.value
|
||||
: requests.value.filter(r => r.status === activeFilter.value)
|
||||
)
|
||||
// L'invité ne voit « Approuvées » que s'il en a
|
||||
const visibleFilters = computed(() => filters.filter(f => auth.isAdmin || f.value !== 'approved' || counts.value.approved > 0))
|
||||
|
||||
// En attente d'abord, puis les plus récentes
|
||||
const filtered = computed(() => {
|
||||
const order: Record<string, number> = { pending: 0, approved: 1, completed: 2, rejected: 3 }
|
||||
const list = activeFilter.value === 'all' ? [...requests.value] : requests.value.filter(r => r.status === activeFilter.value)
|
||||
return list.sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9) || b.updatedAt.localeCompare(a.updatedAt))
|
||||
})
|
||||
|
||||
const approvableCount = computed(() => requests.value.filter(r => r.status === 'pending' && r.hasTorrents).length)
|
||||
|
||||
// ── Présentation ──────────────────────────────────────────────
|
||||
const seriesById = computed(() => new Map(seriesStore.series.map(s => [s.id, s])))
|
||||
const posterOf = (id: number) => seriesById.value.get(id)?.poster_image ?? null
|
||||
const titleOf = (req: SerieRequest) => seriesById.value.get(req.serieId)?.title ?? req.serieName
|
||||
|
||||
function statusPill(status: string) {
|
||||
if (status === 'approved' && !auth.isAdmin) return { label: 'Bientôt là', class: 'pill-active' }
|
||||
return ({
|
||||
pending : { label: 'En attente', class: 'pill-wait' },
|
||||
approved : { label: 'Approuvée', class: 'pill-neutral' },
|
||||
completed: { label: 'Disponible', class: 'pill-ok' },
|
||||
rejected : { label: 'Refusée', class: 'pill-err' },
|
||||
} as Record<string, { label: string; class: string }>)[status] ?? { label: status, class: 'pill-neutral' }
|
||||
}
|
||||
|
||||
function dateLabel(req: SerieRequest) {
|
||||
if (req.status === 'pending') return `demandée ${formatRelative(req.createdAt)}`
|
||||
if (req.status === 'approved') return `approuvée ${formatRelative(req.updatedAt)}`
|
||||
if (req.status === 'rejected') return `refusée ${formatRelative(req.updatedAt)}`
|
||||
return `ajoutée ${formatRelative(req.updatedAt)}`
|
||||
}
|
||||
|
||||
// « Saisons 3 et 4 · 8 épisodes concernés », « 2 épisodes ciblés », « Saison 3 · demandée par marco »
|
||||
function scopeLine(req: SerieRequest) {
|
||||
const episodes = mergedEpisodes(req)
|
||||
const parts = [episodes.length > 0 ? `${plural(episodes.length, 'épisode')} ${episodes.length > 1 ? 'ciblés' : 'ciblé'}` : seasonsLabel(mergedSeasons(req))]
|
||||
if (episodes.length === 0 && req.episodeCount && req.status === 'pending') parts.push(`${plural(req.episodeCount, 'épisode')} ${req.episodeCount > 1 ? 'concernés' : 'concerné'}`)
|
||||
if (auth.isAdmin) {
|
||||
if (req.status !== 'pending') parts.push(`demandée par ${req.requesters.map(r => r.username).join(', ')}`)
|
||||
} else if (req.requesters.length > 1) {
|
||||
parts.push('demandée aussi par quelqu’un d’autre')
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function requesterScope(r: Requester) {
|
||||
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'ép.', 'ép.')
|
||||
return r.seasons.map(s => s === 0 ? 'SP' : `S${s}`).join(', ')
|
||||
}
|
||||
|
||||
const guestStatusLine: Record<string, string> = {
|
||||
pending : 'L’administrateur n’a pas encore répondu.',
|
||||
approved : 'Demande approuvée : la série arrive bientôt.',
|
||||
completed: 'Prête à regarder dans la médiathèque.',
|
||||
}
|
||||
|
||||
// ── Chargement ────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (res.ok) requests.value = await res.json()
|
||||
loading.value = false
|
||||
reqStore.refreshPending()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
|
||||
load()
|
||||
document.addEventListener('click', closeCardMenu)
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', closeCardMenu))
|
||||
|
||||
// ── Menus ─────────────────────────────────────────────────────
|
||||
const cardMenu = ref<string | null>(null)
|
||||
const headerMenuOpen = ref(false)
|
||||
const headerMenuRef = ref<HTMLElement | null>(null)
|
||||
onClickOutside(headerMenuRef, () => { headerMenuOpen.value = false })
|
||||
function closeCardMenu() { cardMenu.value = null }
|
||||
|
||||
// ── Actions admin ─────────────────────────────────────────────
|
||||
async function patch(req: SerieRequest, body: object): Promise<boolean> {
|
||||
busy[req.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return false }
|
||||
return true
|
||||
} catch { toast('Impossible de contacter le serveur', 'error'); return false }
|
||||
finally { busy[req.id] = false }
|
||||
}
|
||||
|
||||
async function approve(req: SerieRequest) {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'approve' }),
|
||||
})
|
||||
if (res.ok) load()
|
||||
if (await patch(req, { action: 'approve' })) {
|
||||
toast(req.hasTorrents === false ? 'Demande approuvée, aucun torrent à télécharger' : 'Demande approuvée, téléchargement lancé', 'success')
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
async function approveAllWithTorrents() {
|
||||
const targets = requests.value.filter(r => r.status === 'pending' && r.hasTorrents)
|
||||
if (!targets.length || !confirm(`Approuver ${plural(targets.length, 'demande')} et lancer les téléchargements ?`)) return
|
||||
bulkBusy.value = true
|
||||
let done = 0
|
||||
for (const req of targets) if (await patch(req, { action: 'approve' })) done++
|
||||
bulkBusy.value = false
|
||||
toast(`${plural(done, 'demande approuvée', 'demandes approuvées')}, téléchargements lancés`, done === targets.length ? 'success' : 'error')
|
||||
load()
|
||||
}
|
||||
|
||||
async function complete(req: SerieRequest) {
|
||||
const res = await fetch(`/api/requests/${req.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'complete' }),
|
||||
})
|
||||
if (res.ok) load()
|
||||
if (await patch(req, { action: 'complete' })) load()
|
||||
}
|
||||
|
||||
async function remove(req: SerieRequest) {
|
||||
if (!confirm(`Supprimer la demande pour "${req.serieName}" ?`)) return
|
||||
if (!confirm(`Supprimer la demande pour "${titleOf(req)}" ?`)) return
|
||||
await fetch(`/api/requests/${req.id}`, { method: 'DELETE', credentials: 'include' })
|
||||
load()
|
||||
}
|
||||
@@ -235,51 +384,38 @@ async function removeAll() {
|
||||
load()
|
||||
}
|
||||
|
||||
const rejectModal = ref<SerieRequest | null>(null)
|
||||
const rejectMessage = ref('')
|
||||
function openRejectModal(req: SerieRequest) { rejectModal.value = req; rejectMessage.value = '' }
|
||||
async function confirmReject() {
|
||||
if (!rejectModal.value) return
|
||||
await fetch(`/api/requests/${rejectModal.value.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ action: 'reject', rejectionMessage: rejectMessage.value || undefined }),
|
||||
})
|
||||
rejectModal.value = null
|
||||
load()
|
||||
const rejectTarget = ref<SerieRequest | null>(null)
|
||||
async function confirmReject(message: string) {
|
||||
const req = rejectTarget.value
|
||||
rejectTarget.value = null
|
||||
if (req && await patch(req, { action: 'reject', rejectionMessage: message || undefined })) load()
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function mergedSeasons(req: SerieRequest): number[] {
|
||||
if (req.requesters.some(r => r.seasons.length === 0 && (r.episodes?.length ?? 0) === 0)) return []
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => r.seasons.forEach(s => all.add(s)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
// ── Actions invité ────────────────────────────────────────────
|
||||
async function withdraw(req: SerieRequest) {
|
||||
if (!confirm(`Annuler votre demande pour "${titleOf(req)}" ?`)) return
|
||||
busy[req.id] = true
|
||||
try {
|
||||
const res = await fetch(`/api/requests/${req.id}/mine`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
toast('Demande annulée', 'success')
|
||||
load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[req.id] = false }
|
||||
}
|
||||
|
||||
function mergedEpisodes(req: SerieRequest): number[] {
|
||||
const all = new Set<number>()
|
||||
req.requesters.forEach(r => (r.episodes ?? []).forEach(e => all.add(e)))
|
||||
return [...all].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function statusLabel(s: string) {
|
||||
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[s] ?? s
|
||||
}
|
||||
function statusClass(s: string) {
|
||||
return {
|
||||
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
}[s] ?? ''
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
|
||||
if (m < 1) return "à l'instant"
|
||||
if (m < 60) return `il y a ${m}min`
|
||||
if (h < 24) return `il y a ${h}h`
|
||||
return `il y a ${d}j`
|
||||
async function requestAgain(req: SerieRequest) {
|
||||
const mine = req.requesters.find(r => r.userId === auth.userId)
|
||||
busy[req.id] = true
|
||||
try {
|
||||
const res = await fetch('/api/requests', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ serieId: req.serieId, serieName: req.serieName, seasons: mine?.seasons ?? [], episodes: mine?.episodes ?? [] }),
|
||||
})
|
||||
if (!res.ok) { toast((await res.json()).error ?? 'Erreur', 'error'); return }
|
||||
toast('Nouvelle demande envoyée', 'success')
|
||||
load()
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
finally { busy[req.id] = false }
|
||||
}
|
||||
</script>
|
||||
+151
-163
@@ -4,155 +4,130 @@
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingDetail" 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" />
|
||||
<p class="text-sm">Chargement…</p>
|
||||
<p class="text-body">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-sm text-red-400">{{ store.error }}</p>
|
||||
<p class="text-body text-err">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="load">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero — poster, titre, métadonnées, synopsis -->
|
||||
<SerieHero :serie="data.serie" />
|
||||
<!-- Bandeau : affiche, titre, progression et actions -->
|
||||
<SerieHero
|
||||
:serie="data.serie"
|
||||
:season-count="seasonCount"
|
||||
:episode-count="episodeCount"
|
||||
:organized-count="organizedCount"
|
||||
:organized-label="auth.isAdmin ? 'importé' : 'disponible'"
|
||||
:watched="auth.isAdmin && rssSync"
|
||||
>
|
||||
<template #actions>
|
||||
<!-- Admin -->
|
||||
<template v-if="auth.isAdmin">
|
||||
<div v-if="hasSomethingToDownload" class="relative max-sm:flex-1 max-sm:min-w-0" @click.stop>
|
||||
<button
|
||||
:disabled="downloadingAll"
|
||||
class="btn-primary max-sm:w-full"
|
||||
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
|
||||
>
|
||||
<Loader v-if="downloadingAll" :size="16" class="animate-spin" />
|
||||
<Download v-else :size="16" :stroke-width="2.25" />
|
||||
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
|
||||
<ChevronDown v-if="data.torrents_integrale.length > 1" :size="13" :stroke-width="2.5" />
|
||||
</button>
|
||||
|
||||
<!-- Barre d'actions admin -->
|
||||
<div v-if="auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<!-- Le choix -->
|
||||
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1" class="menu absolute left-0 top-full mt-1.5 w-80 max-w-[90vw] z-20" role="menu">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
role="menuitem"
|
||||
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
|
||||
class="menu-item"
|
||||
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:title="t.raw ?? ''"
|
||||
@click="downloadAll(i); downloadMenuOpen = false"
|
||||
>
|
||||
<Download :size="14" class="shrink-0" />
|
||||
<span class="truncate">{{ integraleGroupLabels()[i] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gauche : gestion bibliothèque -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Import manuel -->
|
||||
<button
|
||||
@click="openManualImport"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import manuel
|
||||
</button>
|
||||
|
||||
<!-- Supprimer série -->
|
||||
<button
|
||||
v-if="Object.keys(organizedByEpisode).length > 0"
|
||||
@click="openUnimportSerieModal"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 transition"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Droite : téléchargements -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<!-- Bouton RSS sync -->
|
||||
<button
|
||||
@click="toggleRssSync"
|
||||
:title="rssSync ? 'Surveillance activée — cliquer pour désactiver' : 'Surveiller les nouveaux épisodes'"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="rssSync
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20 hover:bg-green-500/20'
|
||||
: 'bg-shell text-muted border-border hover:text-primary hover:bg-hover'"
|
||||
>
|
||||
<!-- Icône antenne RSS -->
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
{{ rssSync ? 'Surveillé' : 'Surveiller' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton unique "Tout télécharger" — dropdown si plusieurs intégrales -->
|
||||
<div v-if="hasSomethingToDownload" class="relative" @click.stop>
|
||||
<button
|
||||
:disabled="downloadingAll"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
@click="data.torrents_integrale.length > 1 ? (downloadMenuOpen = !downloadMenuOpen) : downloadAll()"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" :class="downloadingAll ? 'animate-spin' : ''">
|
||||
<path v-if="!downloadingAll" d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline v-if="!downloadingAll" points="7 10 12 15 17 10"/><line v-if="!downloadingAll" x1="12" y1="15" x2="12" y2="3"/>
|
||||
<path v-else d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
</svg>
|
||||
{{ downloadingAll ? 'Envoi…' : 'Tout télécharger' }}
|
||||
<span v-if="data.torrents_integrale.length > 1" class="opacity-70">▾</span>
|
||||
<button @click="openManualImport" class="btn-secondary font-medium max-sm:w-11 max-sm:px-0 max-sm:shrink-0" title="Import manuel" aria-label="Import manuel">
|
||||
<Upload :size="15" :stroke-width="2" /> <span class="max-sm:hidden">Import manuel</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown choix d'intégrale -->
|
||||
<div v-if="downloadMenuOpen && data.torrents_integrale.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-56 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
:disabled="isAlreadyQueued(t) && !hasUncoveredByIntegrale(i)"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
:class="(isDownloaded(`integrale-${i}`) && !hasUncoveredByIntegrale(i)) ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="downloadAll(i); downloadMenuOpen = false"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
{{ integraleGroupLabels()[i] }}
|
||||
<div class="relative" @click.stop>
|
||||
<button @click="moreMenuOpen = !moreMenuOpen" class="btn-icon" aria-label="Plus d'actions" aria-haspopup="menu" :aria-expanded="moreMenuOpen">
|
||||
<Ellipsis :size="16" />
|
||||
</button>
|
||||
<div v-if="moreMenuOpen" class="menu absolute right-0 sm:right-auto sm:left-0 top-full mt-1.5 w-64 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item" @click="toggleRssSync(); moreMenuOpen = false">
|
||||
<Rss :size="14" class="shrink-0" />
|
||||
{{ rssSync ? 'Ne plus surveiller' : 'Surveiller les nouveaux épisodes' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="Object.keys(organizedByEpisode).length > 0"
|
||||
role="menuitem"
|
||||
class="menu-item text-err hover:text-err"
|
||||
@click="openUnimportSerieModal(); moreMenuOpen = false"
|
||||
>
|
||||
<Trash2 :size="14" class="shrink-0" /> Supprimer la série…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Série absente du scraper -->
|
||||
<div v-if="auth.isAdmin && !data.scraper_synced" class="mx-4 md:mx-8 mt-4 flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Série absente du scraper</p>
|
||||
<p class="text-xs text-muted">
|
||||
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Invité -->
|
||||
<template v-else>
|
||||
<button v-if="canRequestMore" @click="openRequestModal()" class="btn-primary max-sm:flex-1 max-sm:min-w-0">
|
||||
<Clock3 :size="16" :stroke-width="2.25" />
|
||||
{{ myRequest ? 'Demander les saisons manquantes' : 'Demander la série' }}
|
||||
</button>
|
||||
<span v-if="myRequest" class="min-h-10 py-2 px-4 rounded-full border border-accent/30 text-accent text-[13px] font-semibold flex items-center gap-[9px]">
|
||||
<Clock3 :size="15" :stroke-width="2" />
|
||||
{{ myRequest.status === 'approved' ? 'Demande approuvée · bientôt là' : 'Demande en attente' }}<template v-if="myScopeLabel"> · {{ myScopeLabel }}</template>
|
||||
</span>
|
||||
<span v-else-if="allOrganized" class="h-10 px-1 flex items-center gap-2 text-body font-semibold text-ok">
|
||||
<Check :size="16" :stroke-width="2.5" /> Toute la série est disponible
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</SerieHero>
|
||||
|
||||
<!-- Dossier série à renommer -->
|
||||
<div v-if="auth.isAdmin && staleFolders.length > 0" class="mx-4 md:mx-8 mt-4 flex items-center justify-between gap-3 flex-wrap px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<p class="text-sm text-yellow-500 font-medium">Dossier différent du titre de la série</p>
|
||||
<p class="text-xs text-muted break-all">
|
||||
<span class="font-mono text-primary">{{ staleFolders.map(f => basename(f.path)).join(', ') }}</span>
|
||||
→ <span class="font-mono text-primary">{{ folderTarget }}</span>
|
||||
<div class="px-4 md:px-10 pt-5 pb-16 flex flex-col gap-4">
|
||||
|
||||
<!-- Série absente du scraper -->
|
||||
<div v-if="auth.isAdmin && !data.scraper_synced" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Série absente du scraper</p>
|
||||
<p class="text-meta text-secondary">
|
||||
Téléchargements indisponibles pour le moment. L'import manuel et le renommage s'appuient sur l'API Fankai en attendant la prochaine synchronisation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary text-xs shrink-0">
|
||||
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre utilisateur (mode demande) -->
|
||||
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
|
||||
<div v-if="myRequest" class="flex items-center gap-3">
|
||||
<span class="text-xs px-2 py-1 rounded border" :class="requestStatusClass(myRequest.status)">
|
||||
{{ requestStatusLabel(myRequest.status) }}
|
||||
</span>
|
||||
<span v-if="myRequest.status === 'rejected' && myRequest.rejectionMessage" class="text-xs text-muted">
|
||||
{{ myRequest.rejectionMessage }}
|
||||
</span>
|
||||
<!-- Dossier série à renommer -->
|
||||
<div v-if="auth.isAdmin && staleFolders.length > 0" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-start gap-3.5 min-w-0">
|
||||
<Folder :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<p class="card-title">Dossier différent du titre de la série</p>
|
||||
<p class="text-meta text-secondary break-all">
|
||||
{{ staleFolders.map(f => basename(f.path)).join(', ') }} → <span class="text-primary">{{ folderTarget }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary btn-sm shrink-0">
|
||||
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="text-xs text-muted">Cliquez sur une saison ou un épisode pour faire une demande</div>
|
||||
<button
|
||||
v-if="!myRequest || myRequest.status === 'rejected' || myRequest.status === 'completed'"
|
||||
@click="openRequestModal()"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
>
|
||||
Demander la série
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4 pt-4">
|
||||
<!-- Saisons -->
|
||||
<SerieSeasonCard
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
@@ -168,6 +143,7 @@
|
||||
:request-mode="!auth.isAdmin"
|
||||
:requested-seasons="requestedSeasonNumbers"
|
||||
:requested-episodes="requestedEpisodeIds"
|
||||
:request-status="myRequest?.status"
|
||||
@toggle="toggleSeason"
|
||||
@download="(key, url, magnet, fi, fp, ih) => download(key, url, magnet, fi, fp, ih)"
|
||||
@download-season="(s, h) => downloadSeason(s, h)"
|
||||
@@ -181,34 +157,34 @@
|
||||
|
||||
<!-- Modal demande (utilisateurs) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="requestModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="requestModal = false">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Demander une série</p>
|
||||
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
|
||||
<div v-if="requestModal" class="modal-backdrop" @click.self="requestModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="request-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="request-title" class="card-title">Demander une série</h2>
|
||||
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs text-muted font-medium">Saisons demandées</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="tag-label mb-1.5">Saisons demandées</p>
|
||||
<label
|
||||
v-for="season in data?.seasons"
|
||||
:key="season.id"
|
||||
class="flex items-center gap-3 cursor-pointer select-none"
|
||||
class="flex items-center gap-3 min-h-9 cursor-pointer select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="season.season_number"
|
||||
v-model="requestSeasons"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-primary">
|
||||
<span class="text-body text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted font-normal">— {{ season.title }}</span>
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-muted">— {{ season.title }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="requestModal = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary text-xs disabled:opacity-50">
|
||||
<div class="flex items-center justify-end gap-2.5">
|
||||
<button @click="requestModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitRequest" :disabled="requestSeasons.length === 0" class="btn-primary">
|
||||
Envoyer la demande
|
||||
</button>
|
||||
</div>
|
||||
@@ -219,19 +195,19 @@
|
||||
|
||||
<!-- Modal désimport série -->
|
||||
<Teleport to="body">
|
||||
<div v-if="unimportSerieModal" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="unimportSerieModal = false">
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-primary">Supprimer la série</p>
|
||||
<p class="text-xs text-muted mt-1 truncate">{{ data?.serie?.title }}</p>
|
||||
<div v-if="unimportSerieModal" class="modal-backdrop" @click.self="unimportSerieModal = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="unimport-serie-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 id="unimport-serie-title" class="card-title">Supprimer la série</h2>
|
||||
<p class="text-meta text-muted truncate">{{ data?.serie?.title }}</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded border-border accent-accent" />
|
||||
<span class="text-xs text-muted">Supprimer également les fichiers physiques du disque</span>
|
||||
<input type="checkbox" v-model="deleteSerieFiles" class="w-4 h-4 rounded" />
|
||||
<span class="text-body text-secondary">Supprimer également les fichiers physiques du disque</span>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="unimportSerieModal = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<button @click="confirmUnimportSerie" class="px-4 py-2 rounded-lg border border-red-500/30 text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-colors text-xs font-medium">
|
||||
<div class="flex items-center justify-end gap-2.5">
|
||||
<button @click="unimportSerieModal = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmUnimportSerie" class="btn-danger">
|
||||
{{ deleteSerieFiles ? 'Supprimer les fichiers' : 'Supprimer' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -260,6 +236,9 @@ import { useRoute } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { Check, ChevronDown, Clock3, Download, Ellipsis, Folder, Loader, Rss, Trash2, TriangleAlert, Upload } from 'lucide-vue-next'
|
||||
import { seasonsLabel } from '@/utils/requests'
|
||||
import { plural } from '@/utils/format'
|
||||
import ManualImportModal from '@/components/ManualImportModal.vue'
|
||||
import SerieHero from '@/components/serie/SerieHero.vue'
|
||||
import SerieSeasonCard from '@/components/serie/SerieSeasonCard.vue'
|
||||
@@ -379,17 +358,13 @@ async function submitRequest() {
|
||||
} catch { toast('Impossible de contacter le serveur', 'error') }
|
||||
}
|
||||
|
||||
function requestStatusLabel(status: string) {
|
||||
return { pending: 'En attente', approved: 'Approuvée', rejected: 'Refusée', completed: 'Disponible' }[status] ?? status
|
||||
}
|
||||
function requestStatusClass(status: string) {
|
||||
return {
|
||||
pending : 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
approved : 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
rejected : 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
}[status] ?? ''
|
||||
}
|
||||
// Portée de ma demande en cours : « saisons 3 et 4 », « 2 épisodes »
|
||||
const myScopeLabel = computed(() => {
|
||||
const r = myRequest.value?.requesters?.find((r: any) => r.userId === auth.userId)
|
||||
if (!r) return ''
|
||||
if ((r.episodes ?? []).length > 0) return plural(r.episodes.length, 'épisode')
|
||||
return seasonsLabel(r.seasons).toLowerCase()
|
||||
})
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string[]>([])
|
||||
@@ -402,6 +377,7 @@ const epActionLoading = ref<Record<number, boolean>>({})
|
||||
const unimportSerieModal = ref(false)
|
||||
const deleteSerieFiles = ref(false)
|
||||
const downloadMenuOpen = ref(false)
|
||||
const moreMenuOpen = ref(false)
|
||||
const downloadingAll = ref(false)
|
||||
const downloadingSeason = ref<Record<number, boolean>>({})
|
||||
const rssSync = ref(false)
|
||||
@@ -423,6 +399,18 @@ const _recentlyOrganized = new Set<string>() // hash:fileIndex ou has
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
const seasonCount = computed(() => data.value?.seasons.filter(s => s.season_number > 0).length ?? 0)
|
||||
const episodeCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.length, 0) ?? 0)
|
||||
const organizedCount = computed(() => data.value?.seasons.reduce((n, s) => n + s.episodes.filter(e => e.organized).length, 0) ?? 0)
|
||||
const allOrganized = computed(() => episodeCount.value > 0 && organizedCount.value >= episodeCount.value)
|
||||
|
||||
// Invité : reste-t-il des saisons à demander ?
|
||||
const canRequestMore = computed(() => {
|
||||
if (!data.value || allOrganized.value) return false
|
||||
if (!myRequest.value) return true
|
||||
return data.value.seasons.some(s => s.organized_state !== 'complete' && !requestedSeasonNumbers.value.includes(s.season_number))
|
||||
})
|
||||
|
||||
// ── Helpers intégrale ─────────────────────────────────────────
|
||||
function integraleGroupLabels(): string[] {
|
||||
const torrents = data.value?.torrents_integrale ?? []
|
||||
@@ -817,7 +805,7 @@ async function unimportEpisode(ep: any, _season: any, deleteFile: boolean) {
|
||||
finally { epActionLoading.value[ep.id] = false }
|
||||
}
|
||||
|
||||
const closeMenus = () => { downloadMenuOpen.value = false }
|
||||
const closeMenus = () => { downloadMenuOpen.value = false; moreMenuOpen.value = false }
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
|
||||
+333
-145
@@ -1,119 +1,157 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="px-4 md:px-10 pt-[26px] pb-12">
|
||||
<div class="flex flex-col gap-[18px]">
|
||||
|
||||
<SeriesToolbar
|
||||
v-model:search="search"
|
||||
v-model:active-filter="activeFilter"
|
||||
v-model:active-sort="activeSort"
|
||||
v-model:poster-size="posterSize"
|
||||
:poster-sizes="posterSizes"
|
||||
:count="filtered.length"
|
||||
:has-active-filter="hasActiveFilter"
|
||||
:filters-disponibilite="filtersDisponibilite"
|
||||
:filters-import="filtersImport"
|
||||
:sort-options="sortOptions"
|
||||
:legend="legend"
|
||||
:selectable="auth.isAdmin"
|
||||
v-model:selecting="selecting"
|
||||
/>
|
||||
<SeriesToolbar
|
||||
v-model:search="search"
|
||||
v-model:active-filter="activeFilter"
|
||||
v-model:active-sort="activeSort"
|
||||
v-model:poster-size="posterSize"
|
||||
v-model:selecting="selecting"
|
||||
:poster-sizes="posterSizes"
|
||||
:count="filtered.length"
|
||||
:filters="filters"
|
||||
:more-filters="moreFilters"
|
||||
:sort-options="sortOptions"
|
||||
:selectable="auth.isAdmin"
|
||||
/>
|
||||
|
||||
<!-- Grille -->
|
||||
<div data-tour="series-grid" class="px-4 md:px-6 py-6">
|
||||
<!-- Grille -->
|
||||
<div data-tour="series-grid" class="pt-1.5">
|
||||
|
||||
<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" />
|
||||
<p class="text-sm">Chargement du catalogue…</p>
|
||||
</div>
|
||||
<div v-if="store.loadingSeries && store.series.length === 0" 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" />
|
||||
<p class="text-body">Chargement du catalogue…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-sm text-red-400">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
|
||||
</div>
|
||||
<div v-else-if="store.error && store.series.length === 0" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-body text-err">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="flex items-center justify-center h-64 text-muted text-sm">
|
||||
Aucune série trouvée
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 h-64 text-center">
|
||||
<p class="font-display text-xl font-bold text-primary">Aucune série trouvée</p>
|
||||
<p class="text-body text-muted">Essayez une autre recherche ou un autre filtre.</p>
|
||||
<button v-if="search || activeFilter !== 'all'" @click="search = ''; activeFilter = 'all'" class="btn-ghost btn-sm">Tout afficher</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
|
||||
<component
|
||||
:is="selecting ? 'div' : RouterLink"
|
||||
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="{
|
||||
'opacity-40': !serie.has_torrents && serie.download_state === 'none' && !selected.has(serie.id),
|
||||
'cursor-pointer select-none': selecting,
|
||||
}"
|
||||
@click="selecting && toggleSelected(serie.id)"
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-x-3.5 gap-y-5 sm:grid-cols-[repeat(auto-fill,minmax(var(--poster-min),1fr))] sm:gap-x-5 sm:gap-y-[26px]"
|
||||
:style="{ '--poster-min': posterSizes[posterSize] }"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border"
|
||||
:class="selected.has(serie.id) ? 'border-accent ring-2 ring-accent' : 'border-border'"
|
||||
>
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
|
||||
<div v-if="!selecting" class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
|
||||
</div>
|
||||
<template v-for="item in gridItems" :key="item.key">
|
||||
|
||||
<!-- Tuile -->
|
||||
<div
|
||||
v-if="selecting"
|
||||
class="absolute top-2 left-2 w-5 h-5 rounded-md border flex items-center justify-center"
|
||||
:class="selected.has(serie.id) ? 'bg-accent border-accent text-white' : 'bg-black/40 border-white/60'"
|
||||
v-if="item.type === 'letter'"
|
||||
class="@container aspect-[2/3] rounded-poster border border-border-light bg-linear-to-b from-card to-main max-sm:hidden flex flex-col justify-between px-[11%] pt-[11%] pb-[10%] overflow-hidden select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Check v-if="selected.has(serie.id)" :size="13" />
|
||||
<span class="font-display text-[80cqw] leading-[0.9] font-extrabold text-accent">{{ item.letter }}</span>
|
||||
<span class="text-[clamp(11px,9cqw,12.5px)] text-secondary">{{ item.count }} série{{ item.count > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isAdmin && serie.rss_synced" class="absolute top-2 right-2 p-1 rounded-md bg-black/60 text-green-400" title="Surveillée">
|
||||
<Rss :size="11" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-1"
|
||||
:style="{ background: stateColor(serie.download_state) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium truncate leading-snug text-primary">{{ serie.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span v-if="serie.year" class="text-[11px] text-muted">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-hover text-muted'"
|
||||
<!-- Affiche -->
|
||||
<component
|
||||
v-else
|
||||
:is="selecting ? 'button' : RouterLink"
|
||||
v-bind="selecting ? { type: 'button', 'aria-pressed': selected.has(item.serie.id) } : { to: `/series/${item.serie.id}` }"
|
||||
:data-tour="item.first ? 'series-poster' : undefined"
|
||||
class="group flex flex-col gap-[9px] min-w-0 text-left"
|
||||
:class="{ 'cursor-pointer select-none': selecting }"
|
||||
@click="selecting && toggleSelected(item.serie.id)"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-[2/3] rounded-poster overflow-hidden bg-card shadow-[0_0_0_1px_rgb(239_233_221/0.06)] transition-transform duration-200 group-hover:-translate-y-0.5"
|
||||
:class="{ 'ring-2 ring-accent': selected.has(item.serie.id) }"
|
||||
>
|
||||
{{ serie.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
<img
|
||||
v-if="item.serie.poster_image"
|
||||
:src="item.serie.poster_image"
|
||||
:alt="item.serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
:class="{ 'grayscale brightness-[0.45]': item.state.dim && auth.isAdmin, 'brightness-[0.55] saturate-[0.7]': item.state.dim && !auth.isAdmin }"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
|
||||
<!-- Haut gauche : sélection ou importée -->
|
||||
<span
|
||||
v-if="selecting"
|
||||
class="absolute left-2 top-2 w-6 h-6 rounded-full border flex items-center justify-center"
|
||||
:class="selected.has(item.serie.id) ? 'bg-accent border-accent text-on-accent' : 'bg-sidebar/80 border-primary/50'"
|
||||
>
|
||||
<Check v-if="selected.has(item.serie.id)" :size="14" :stroke-width="3" />
|
||||
</span>
|
||||
<span v-else-if="item.state.imported" title="Importée" class="absolute left-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-ok flex items-center justify-center">
|
||||
<Check :size="13" :stroke-width="3" />
|
||||
</span>
|
||||
|
||||
<span v-if="item.state.watched" title="Surveillée" class="absolute right-2 top-2 w-6 h-6 rounded-full bg-sidebar/80 text-accent flex items-center justify-center">
|
||||
<Rss :size="12" :stroke-width="2.5" />
|
||||
</span>
|
||||
<span v-else-if="item.state.requested" class="absolute right-2 top-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-accent text-[11.5px] font-bold flex items-center gap-1.5">
|
||||
<Clock3 :size="12" :stroke-width="2.25" /> Demandée
|
||||
</span>
|
||||
|
||||
<!-- Bas : téléchargement en cours -->
|
||||
<div
|
||||
v-if="item.state.progress !== null"
|
||||
class="absolute inset-x-0 bottom-0 px-2.5 pt-[30px] pb-2.5 bg-linear-to-b from-sidebar/0 to-sidebar/92 to-65% flex flex-col gap-1.5"
|
||||
>
|
||||
<div class="flex justify-between text-[11.5px] font-bold text-primary">
|
||||
<span>Téléchargement</span><span v-if="item.state.progress >= 0">{{ item.state.progress }} %</span>
|
||||
</div>
|
||||
<div class="h-[3px] rounded-[2px] bg-primary/20 overflow-hidden">
|
||||
<div class="h-full rounded-[2px] bg-accent transition-[width] duration-500" :style="{ width: `${Math.max(0, item.state.progress)}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bas : état écrit -->
|
||||
<span
|
||||
v-else-if="item.state.chip"
|
||||
class="absolute left-2 bottom-2 h-6 px-[9px] rounded-full bg-sidebar/84 text-[11.5px] font-bold flex items-center gap-1.5 whitespace-nowrap max-w-[calc(100%-16px)]"
|
||||
:class="chipTone[item.state.chip.tone]"
|
||||
:title="item.state.chip.title"
|
||||
>
|
||||
<TriangleAlert v-if="item.state.chip.icon === 'error'" :size="12" :stroke-width="2.25" class="shrink-0" />
|
||||
<Folder v-else-if="item.state.chip.icon === 'folder'" :size="12" :stroke-width="2.25" class="shrink-0" />
|
||||
<Clock3 v-else-if="item.state.chip.icon === 'soon'" :size="12" :stroke-width="2.25" class="shrink-0" />
|
||||
<span class="truncate">{{ item.state.chip.label }}</span>
|
||||
<span v-if="item.state.chip.detail" class="text-secondary font-medium">{{ item.state.chip.detail }}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="item.state.isNew"
|
||||
class="stamp-new absolute left-2"
|
||||
:class="item.state.chip || item.state.progress !== null ? 'bottom-[42px]' : 'bottom-2.5'"
|
||||
>NOUVEAU</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<span class="text-body font-medium truncate" :class="item.state.dim ? 'text-secondary' : 'text-primary'">{{ item.serie.title }}</span>
|
||||
<span class="text-xs text-muted truncate">{{ [item.serie.year, statusLabel(item.serie.status)].filter(Boolean).join(' · ') }}</span>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions de masse -->
|
||||
<div v-if="selecting" class="sticky bottom-0 z-10 px-4 md:px-6 py-3 border-t border-border bg-shell flex items-center gap-3 flex-wrap">
|
||||
<span class="text-xs text-primary font-medium">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
|
||||
<button @click="selectAllFiltered" class="text-xs text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-xs text-muted hover:text-primary">Désélectionner</button>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Renommer</button>
|
||||
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Surveiller</button>
|
||||
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary">Ne plus surveiller</button>
|
||||
<button @click="selecting = false" class="btn-secondary" title="Quitter la sélection"><X :size="14" /></button>
|
||||
<div v-if="selecting" class="sticky bottom-4 z-10 mt-8 mx-auto w-fit max-w-full bg-card border border-border rounded-card px-4 py-3 flex items-center gap-x-3 gap-y-2 flex-wrap shadow-[0_16px_40px_rgb(0_0_0/0.5)]">
|
||||
<span class="text-body font-bold text-primary">{{ selected.size }} sélectionnée{{ selected.size > 1 ? 's' : '' }}</span>
|
||||
<button @click="selectAllFiltered" class="text-meta text-accent hover:underline">Tout sélectionner ({{ filtered.length }})</button>
|
||||
<button v-if="selected.size > 0" @click="clearSelection" class="text-meta text-secondary hover:text-primary">Désélectionner</button>
|
||||
<span class="hidden sm:block w-px h-6 bg-hover" />
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button @click="bulkRename" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Renommer</button>
|
||||
<button @click="bulkSync(true)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Surveiller</button>
|
||||
<button @click="bulkSync(false)" :disabled="bulkBusy || selected.size === 0" class="btn-secondary btn-sm">Ne plus surveiller</button>
|
||||
<button @click="selecting = false" class="btn-icon btn-sm" title="Quitter la sélection" aria-label="Quitter la sélection"><X :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,21 +159,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Tv, Check, Rss, X } from 'lucide-vue-next'
|
||||
import { Check, Clock3, Folder, Rss, TriangleAlert, Tv, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore, type Serie } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { usePosterSize } from '@/composables/usePosterSize'
|
||||
import SeriesToolbar from '@/components/series/SeriesToolbar.vue'
|
||||
import { useLibrarySearch } from '@/composables/useLibrarySearch'
|
||||
import { isNew, statusLabel } from '@/utils/series'
|
||||
import SeriesToolbar, { type LibraryFilter } from '@/components/series/SeriesToolbar.vue'
|
||||
|
||||
defineOptions({ name: 'SeriesView' })
|
||||
|
||||
const store = useSeriesStore()
|
||||
const dlStore = useDownloadsStore()
|
||||
const auth = useAuthStore()
|
||||
const librarySearch = useLibrarySearch()
|
||||
const { add: toast } = useToast()
|
||||
const { current: posterSize, sizes: posterSizes } = usePosterSize()
|
||||
|
||||
@@ -143,48 +184,65 @@ const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const activeSort = ref('alpha')
|
||||
|
||||
const filtersDisponibilite = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
{ label: 'Non suivies', value: 'untracked', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'A→Z', value: 'alpha' },
|
||||
{ label: 'Z→A', value: 'alpha-desc' },
|
||||
{ label: 'Récents', value: 'recent' },
|
||||
{ label: 'Importés', value: 'imported' },
|
||||
{ label: 'A → Z', value: 'alpha' },
|
||||
{ label: 'Z → A', value: 'alpha-desc' },
|
||||
{ label: 'Récentes', value: 'recent' },
|
||||
{ label: 'Importées d’abord', value: 'imported' },
|
||||
]
|
||||
|
||||
const legend = [
|
||||
{ label: 'Importé', color: '#22c55e' },
|
||||
{ label: 'En cours', color: '#3b82f6' },
|
||||
{ label: 'Partiel', color: '#9b59b6' },
|
||||
{ label: 'Non importé', color: 'var(--border)' },
|
||||
]
|
||||
// ── Filtres à compteurs ──────────────────────────────────────
|
||||
const myRequested = ref<Set<number>>(new Set())
|
||||
|
||||
const hasActiveFilter = computed(() => activeFilter.value !== 'all')
|
||||
const predicates: Record<string, (s: Serie) => boolean> = {
|
||||
'all' : () => true,
|
||||
'complete' : s => s.download_state === 'complete',
|
||||
'partial' : s => s.download_state === 'partial',
|
||||
'downloading': s => s.download_state === 'downloading',
|
||||
'to-download': s => s.download_state === 'none' && s.has_torrents,
|
||||
'no-torrent' : s => s.download_state === 'none' && !s.has_torrents,
|
||||
'not-here' : s => s.download_state === 'none',
|
||||
'mine' : s => myRequested.value.has(s.id),
|
||||
'available' : s => s.has_torrents,
|
||||
'untracked' : s => !s.in_client && !s.has_files,
|
||||
'watched' : s => s.rss_synced,
|
||||
}
|
||||
|
||||
const countOf = (value: string) => store.series.filter(predicates[value]!).length
|
||||
|
||||
const filters = computed<LibraryFilter[]>(() => (auth.isAdmin
|
||||
? [
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'complete', label: 'Importées' },
|
||||
{ value: 'partial', label: 'Partielles' },
|
||||
{ value: 'downloading', label: 'En téléchargement', attention: true },
|
||||
{ value: 'to-download', label: 'À télécharger' },
|
||||
{ value: 'no-torrent', label: 'Sans torrent' },
|
||||
]
|
||||
: [
|
||||
{ value: 'all', label: 'Toutes' },
|
||||
{ value: 'complete', label: 'Prêtes à regarder' },
|
||||
{ value: 'partial', label: 'Partielles' },
|
||||
{ value: 'downloading', label: 'Bientôt', attention: true },
|
||||
{ value: 'not-here', label: 'Pas encore là' },
|
||||
{ value: 'mine', label: 'Mes demandes', attention: true },
|
||||
]
|
||||
).map(f => ({ ...f, count: countOf(f.value) })))
|
||||
|
||||
const moreFilters = computed<LibraryFilter[]>(() => auth.isAdmin
|
||||
? [
|
||||
{ value: 'available', label: 'Avec torrent' },
|
||||
{ value: 'watched', label: 'Surveillées' },
|
||||
{ value: 'untracked', label: 'Absentes du client et du disque' },
|
||||
].map(f => ({ ...f, count: countOf(f.value) }))
|
||||
: [])
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = [...store.series]
|
||||
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
|
||||
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
|
||||
if (activeFilter.value === 'untracked') list = list.filter(s => !s.in_client && !s.has_files)
|
||||
let list = store.series.filter(predicates[activeFilter.value] ?? predicates.all!)
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
const q = normalize(search.value)
|
||||
list = list.filter(s => normalize(s.title).includes(q))
|
||||
}
|
||||
|
||||
switch (activeSort.value) {
|
||||
@@ -201,6 +259,100 @@ const filtered = computed(() => {
|
||||
return list
|
||||
})
|
||||
|
||||
function normalize(text: string) {
|
||||
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
||||
}
|
||||
|
||||
// ── État des séries ─────────────────────────────────────
|
||||
const errorsBySerie = ref<Map<number, number>>(new Map())
|
||||
const staleFolders = ref<Set<number>>(new Set())
|
||||
|
||||
const chipTone: Record<string, string> = {
|
||||
err : 'text-err',
|
||||
accent : 'text-accent',
|
||||
primary: 'text-primary',
|
||||
muted : 'text-secondary',
|
||||
}
|
||||
|
||||
interface PosterState {
|
||||
imported : boolean
|
||||
watched : boolean
|
||||
requested: boolean
|
||||
dim : boolean
|
||||
isNew : boolean
|
||||
progress : number | null
|
||||
chip : { label: string; detail?: string; tone: string; icon?: string; title?: string } | null
|
||||
}
|
||||
|
||||
function downloadProgress(s: Serie): number | null {
|
||||
if (s.download_state !== 'downloading') return null
|
||||
const active = dlStore.torrents.filter(t => t.serieId === s.id && t.state === 'downloading')
|
||||
if (active.length === 0) return -1
|
||||
const size = active.reduce((sum, t) => sum + (t.size ?? 0), 0)
|
||||
const done = active.reduce((sum, t) => sum + (t.downloaded ?? 0), 0)
|
||||
return Math.round(size > 0 ? done / size * 100 : active[0].progress ?? 0)
|
||||
}
|
||||
|
||||
function posterState(s: Serie): PosterState {
|
||||
const partial = s.download_state === 'partial'
|
||||
? { label: 'Partielle', detail: s.episode_count ? `${s.organized_count}/${s.episode_count}` : undefined, tone: 'primary' }
|
||||
: null
|
||||
|
||||
if (!auth.isAdmin) {
|
||||
const notHere = s.download_state === 'none'
|
||||
return {
|
||||
imported : false,
|
||||
watched : false,
|
||||
requested: myRequested.value.has(s.id),
|
||||
dim : notHere,
|
||||
isNew : s.download_state !== 'none' && isNew(s.last_imported_at),
|
||||
progress : null,
|
||||
chip : s.download_state === 'downloading' ? { label: 'Bientôt', tone: 'accent', icon: 'soon' }
|
||||
: partial ?? (notHere ? { label: 'Pas encore là', tone: 'muted' } : null),
|
||||
}
|
||||
}
|
||||
|
||||
const errors = errorsBySerie.value.get(s.id) ?? 0
|
||||
return {
|
||||
imported : s.download_state === 'complete',
|
||||
watched : s.rss_synced,
|
||||
requested: false,
|
||||
dim : s.download_state === 'none' && !s.has_torrents,
|
||||
isNew : isNew(s.last_imported_at),
|
||||
progress : errors ? null : downloadProgress(s),
|
||||
chip : errors ? { label: `${errors} erreur${errors > 1 ? 's' : ''}`, tone: 'err', icon: 'error', title: 'Import en erreur' }
|
||||
: staleFolders.value.has(s.id) ? { label: 'À renommer', tone: 'accent', icon: 'folder', title: 'Dossier différent du titre de la série' }
|
||||
: partial ?? (s.download_state === 'none' && !s.has_torrents ? { label: 'Sans torrent', tone: 'muted' } : null),
|
||||
}
|
||||
}
|
||||
|
||||
// Grille──
|
||||
function letterOf(title: string) {
|
||||
const c = normalize(title).charAt(0).toUpperCase()
|
||||
return /[A-Z]/.test(c) ? c : '#'
|
||||
}
|
||||
|
||||
type GridItem =
|
||||
| { type: 'letter'; key: string; letter: string; count: number }
|
||||
| { type: 'serie'; key: number; serie: Serie; state: PosterState; first: boolean }
|
||||
|
||||
const gridItems = computed<GridItem[]>(() => {
|
||||
const withLetters = activeSort.value === 'alpha' || activeSort.value === 'alpha-desc'
|
||||
const counts = new Map<string, number>()
|
||||
if (withLetters) for (const s of filtered.value) counts.set(letterOf(s.title), (counts.get(letterOf(s.title)) ?? 0) + 1)
|
||||
|
||||
const items: GridItem[] = []
|
||||
let letter = ''
|
||||
filtered.value.forEach((serie, i) => {
|
||||
if (withLetters && letterOf(serie.title) !== letter) {
|
||||
letter = letterOf(serie.title)
|
||||
items.push({ type: 'letter', key: `letter-${letter}`, letter, count: counts.get(letter) ?? 0 })
|
||||
}
|
||||
items.push({ type: 'serie', key: serie.id, serie, state: posterState(serie), first: i === 0 })
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// ── Sélection multiple ───────────────────────────────────────
|
||||
const selecting = ref(false)
|
||||
const selected = ref<Set<number>>(new Set())
|
||||
@@ -257,29 +409,35 @@ async function bulkSync(enabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function stateColor(state: string): string {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
downloading: '#3b82f6',
|
||||
partial : '#9b59b6',
|
||||
none : 'transparent',
|
||||
} as Record<string, string>)[state] ?? 'transparent'
|
||||
}
|
||||
|
||||
// ── Polling downloads + notifs import ────────────────────────
|
||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||
const seenNotifs = new Set<string>()
|
||||
let notifsPrimed = false
|
||||
|
||||
async function fetchOrganizeNotifs() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const notifs: any[] = await res.json()
|
||||
|
||||
// Erreurs du dernier passage de chaque torrent, par série
|
||||
const errors = new Map<number, number>()
|
||||
const latest = new Set<string>()
|
||||
for (const n of notifs) {
|
||||
if (latest.has(n.hash)) continue
|
||||
latest.add(n.hash)
|
||||
if (n.errors > 0 && n.serieId != null) errors.set(n.serieId, (errors.get(n.serieId) ?? 0) + n.errors)
|
||||
}
|
||||
errorsBySerie.value = errors
|
||||
|
||||
const primed = notifsPrimed
|
||||
notifsPrimed = true
|
||||
let hasNew = false
|
||||
for (const n of notifs) {
|
||||
const key = `${n.hash}-${n.at}`
|
||||
if (seenNotifs.has(key)) continue
|
||||
seenNotifs.add(key)
|
||||
if (!primed) continue
|
||||
hasNew = true
|
||||
if (n.done > 0) {
|
||||
const msg = n.errors > 0
|
||||
@@ -292,11 +450,28 @@ async function fetchOrganizeNotifs() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchStaleFolders() {
|
||||
try {
|
||||
const res = await fetch('/api/organized-folders', { credentials: 'include' })
|
||||
if (res.ok) staleFolders.value = new Set((await res.json()).map((f: any) => f.serie_id))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchMyRequests() {
|
||||
try {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const list: any[] = await res.json()
|
||||
myRequested.value = new Set(list.filter(r => r.status === 'pending' || r.status === 'approved').map(r => r.serieId))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
if (auth.isAdmin) {
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
fetchStaleFolders()
|
||||
dlInterval = setInterval(() => {
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
@@ -304,9 +479,22 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
let isActive = false
|
||||
function applyLibrarySearch() {
|
||||
const q = librarySearch.consume()
|
||||
if (q === null) return
|
||||
if (q) search.value = q
|
||||
nextTick(() => document.querySelector<HTMLInputElement>('[data-library-search]')?.focus())
|
||||
}
|
||||
watch(librarySearch.pending, (p) => { if (p && isActive) applyLibrarySearch() })
|
||||
onDeactivated(() => { isActive = false })
|
||||
|
||||
onActivated(async () => {
|
||||
isActive = true
|
||||
applyLibrarySearch()
|
||||
store.fetchSeries()
|
||||
if (auth.isAdmin) dlStore.refresh()
|
||||
if (auth.isAdmin) { dlStore.refresh(); fetchStaleFolders() }
|
||||
else fetchMyRequests()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
+39
-38
@@ -1,70 +1,71 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-shell flex flex-col md:flex-row">
|
||||
<div class="min-h-screen bg-main 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" />
|
||||
<aside class="md:w-[280px] md:h-screen md:sticky md:top-0 bg-sidebar border-b md:border-b-0 md:border-r border-card flex flex-col shrink-0">
|
||||
<div class="flex items-center gap-[11px] pl-5 pr-3 pt-[22px] pb-5">
|
||||
<FankarrLogo class="w-[30px] h-[31px] 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>
|
||||
<p class="font-display text-[23px] font-bold text-primary leading-tight tracking-[0.01em]">FanKarr</p>
|
||||
<p class="text-[11.5px] 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"
|
||||
aria-label="Fermer l'assistant"
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
|
||||
>
|
||||
<X :size="16" />
|
||||
<X :size="17" />
|
||||
</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"
|
||||
aria-label="Se déconnecter"
|
||||
class="w-9 h-9 rounded-full flex items-center justify-center text-muted hover:text-err hover:bg-hover transition-colors"
|
||||
>
|
||||
<LogOut :size="15" />
|
||||
<LogOut :size="16" :stroke-width="1.75" />
|
||||
</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 class="md:hidden px-5 pb-4">
|
||||
<div class="progress" role="progressbar" :aria-valuenow="index + 1" aria-valuemin="1" :aria-valuemax="steps.length">
|
||||
<div class="progress-bar" :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">
|
||||
<nav aria-label="Étapes" class="hidden md:block flex-1 overflow-y-auto px-3.5 pb-5">
|
||||
<ol class="flex flex-col gap-0.5">
|
||||
<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'"
|
||||
class="w-full flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-left transition-colors"
|
||||
:class="i === index ? 'bg-hover' : canVisit(i) ? 'hover:bg-hover/60' : 'cursor-default'"
|
||||
>
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-medium shrink-0 border"
|
||||
class="w-[26px] h-[26px] rounded-full flex items-center justify-center text-[11.5px] font-bold shrink-0 border tabular-nums"
|
||||
: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" />
|
||||
<Check v-else-if="i < reached && i !== index" :size="13" :stroke-width="2.5" />
|
||||
<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'">
|
||||
<span class="text-body truncate" :class="i === index ? 'text-primary font-semibold' : canVisit(i) ? 'text-secondary' : 'text-muted'">
|
||||
{{ step.label }}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted truncate">{{ step.hint }}</span>
|
||||
<span class="text-xs 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">
|
||||
<p class="hidden md:block px-5 py-4 text-xs text-muted border-t border-card leading-relaxed">
|
||||
Tout reste modifiable ensuite dans les Paramètres.
|
||||
</p>
|
||||
</aside>
|
||||
@@ -75,13 +76,13 @@
|
||||
<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">
|
||||
<div v-else class="max-w-[720px] mx-auto px-4 md:px-10 pt-7 md:pt-12 pb-16 flex flex-col gap-7">
|
||||
<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 class="tag-label text-accent">
|
||||
Étape {{ index + 1 }} sur {{ steps.length }}<template v-if="current.optional"> · facultative</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>
|
||||
<h1 ref="titleRef" tabindex="-1" class="page-title outline-none">{{ current.title }}</h1>
|
||||
<p class="text-body text-muted leading-relaxed">{{ current.description }}</p>
|
||||
</header>
|
||||
|
||||
<component
|
||||
@@ -93,17 +94,17 @@
|
||||
: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
|
||||
<footer v-if="current.id !== 'recap'" class="flex items-center gap-2.5 pt-6 border-t border-hover">
|
||||
<button v-if="index > 0" @click="visit(index - 1)" :disabled="busy" class="btn-ghost">
|
||||
<ArrowLeft :size="15" /> Précédent
|
||||
</button>
|
||||
<div class="flex-1" />
|
||||
<button v-if="current.optional" @click="next(false)" :disabled="busy" class="btn-ghost">
|
||||
<button v-if="current.optional" @click="next(false)" :disabled="busy" class="text-body text-muted hover:text-primary transition-colors px-2">
|
||||
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 @click="next(true)" :disabled="busy" class="btn-primary">
|
||||
{{ busy ? '…' : index === 0 ? 'Commencer' : 'Suivant' }}
|
||||
<ArrowRight v-if="!busy" :size="15" />
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -219,8 +220,8 @@ function canVisit(i: number) {
|
||||
|
||||
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'
|
||||
if (id === 'client' && context.clientSkipped) return 'border-accent/40 text-accent'
|
||||
if (i < reached.value) return 'border-ok/40 text-ok'
|
||||
return 'border-border text-muted'
|
||||
}
|
||||
|
||||
@@ -276,7 +277,7 @@ async function finish(withTour: boolean) {
|
||||
await router.push('/dashboard')
|
||||
} else {
|
||||
if (!auth.tourSeen) auth.markTourSeen()
|
||||
await router.push(context.relaunch ? '/settings/advanced' : '/series')
|
||||
await router.push(context.relaunch ? '/settings/advanced' : '/dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Avancé</h2>
|
||||
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="checkForUpdates" :disabled="checking" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': checking }" />
|
||||
{{ checking ? 'Vérification…' : 'Vérifier les mises à jour' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<!-- ── Version ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Version">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary flex-wrap">
|
||||
FanKarr {{ currentVersion || '…' }}
|
||||
<span v-if="latestVersion && updateAvailable" class="pill pill-wait h-5 px-[9px] text-[11px]">{{ latestVersion }} disponible</span>
|
||||
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
|
||||
</span>
|
||||
</div>
|
||||
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
Notes de version <ExternalLink :size="13" />
|
||||
</a>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Diag ─────────────────────────────────────────── -->
|
||||
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
|
||||
<template #actions>
|
||||
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
|
||||
</template>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-5 gap-x-6 gap-y-4">
|
||||
<div v-for="d in diagnostics" :key="d.label" class="flex flex-col gap-0.5">
|
||||
<span class="font-display text-2xl font-bold" :class="d.accent ? 'text-accent' : 'text-primary'">{{ d.value }}</span>
|
||||
<span class="text-meta text-muted">{{ d.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-meta text-muted">Chargement…</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Dev ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Développement">
|
||||
<template #actions>
|
||||
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
</template>
|
||||
<SettingsToggle
|
||||
v-model="devMode"
|
||||
label="Mode développeur"
|
||||
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
|
||||
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
|
||||
|
||||
<div class="settings-card flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-primary">Assistant de configuration</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Reprend pas à pas les dossiers, le client torrent, Jellyfin/Plex et le catalogue. La configuration actuelle est conservée.
|
||||
</p>
|
||||
<!-- ── Assistant et visite ────────────────────────────────── -->
|
||||
<SettingsSection title="Assistant et visite guidée">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Assistant de configuration</span>
|
||||
<span class="text-meta text-muted">Reprend pas à pas les dossiers, le client torrent, Jellyfin ou Plex et le catalogue. La configuration actuelle est conservée.</span>
|
||||
</div>
|
||||
<div class="flex gap-2.5 shrink-0 flex-wrap">
|
||||
<button @click="tour.start()" class="btn-ghost btn-sm pointer-fine:h-[34px]">Revoir la visite</button>
|
||||
<RouterLink to="/setup" class="btn-secondary btn-sm pointer-fine:h-[34px]">Relancer l'assistant</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button @click="tour.start()" class="btn-ghost">Revoir la visite</button>
|
||||
<RouterLink to="/setup" class="btn-secondary">Relancer l'assistant</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Check, ExternalLink, RefreshCw } from 'lucide-vue-next'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import { useTourStore } from '@/stores/tour'
|
||||
|
||||
const tour = useTourStore()
|
||||
@@ -44,7 +85,67 @@ const saved = ref(false)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loaded = false
|
||||
|
||||
// ── Version ───────────────────────────────────────────────────
|
||||
const GITHUB_REPO = 'masutayunikon/FanKarr'
|
||||
const currentVersion = ref('')
|
||||
const latestVersion = ref('')
|
||||
const releaseUrl = ref(`https://github.com/${GITHUB_REPO}/releases/latest`)
|
||||
const checking = ref(false)
|
||||
|
||||
const updateAvailable = computed(() => {
|
||||
if (!latestVersion.value || !currentVersion.value || currentVersion.value === 'dev') return false
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
|
||||
const l = parse(latestVersion.value), c = parse(currentVersion.value)
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
if ((l[i] ?? 0) !== (c[i] ?? 0)) return (l[i] ?? 0) > (c[i] ?? 0)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
checking.value = true
|
||||
try {
|
||||
const vRes = await fetch('/api/version', { credentials: 'include' })
|
||||
if (vRes.ok) currentVersion.value = (await vRes.json()).version
|
||||
const gRes = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
})
|
||||
if (gRes.ok) {
|
||||
const release = await gRes.json()
|
||||
latestVersion.value = release.tag_name ?? ''
|
||||
releaseUrl.value = release.html_url ?? releaseUrl.value
|
||||
}
|
||||
} catch {} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diag ────────────────────────────────────────────────
|
||||
const debug = ref<any>(null)
|
||||
|
||||
async function fetchDebug() {
|
||||
const res = await fetch('/api/debug/stats', { credentials: 'include' })
|
||||
if (res.ok) debug.value = await res.json()
|
||||
}
|
||||
|
||||
// « 4 h 37 min »
|
||||
const uptime = computed(() => {
|
||||
const s = debug.value?.uptimeSeconds ?? 0
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
return d > 0 ? `${d} j ${h} h` : h > 0 ? `${h} h ${m} min` : `${m} min`
|
||||
})
|
||||
|
||||
const diagnostics = computed(() => debug.value ? [
|
||||
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
|
||||
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
|
||||
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
|
||||
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
|
||||
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
|
||||
] : [])
|
||||
|
||||
onMounted(async () => {
|
||||
checkForUpdates()
|
||||
fetchDebug()
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
@@ -65,4 +166,4 @@ watch(devMode, async (val) => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saved.value = false }, 2000)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,99 +1,148 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
|
||||
</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="update" :disabled="updating" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': updating }" />
|
||||
{{ updating ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div
|
||||
v-if="status.empty"
|
||||
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
|
||||
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statut -->
|
||||
<div class="settings-card">
|
||||
<p class="text-xs text-muted mb-1">Statut</p>
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom).
|
||||
Réactivez la surveillance sur la nouvelle fiche si besoin.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
|
||||
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
|
||||
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
|
||||
<button
|
||||
@click="removeSyncOrphan(s.serieId)"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
<!-- Données manquantes -->
|
||||
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Données manquantes</p>
|
||||
<p class="text-meta text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
Scraper GitHub
|
||||
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">API Fankai</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
|
||||
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillance RSS -->
|
||||
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
|
||||
<template #actions>
|
||||
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
|
||||
<Loader v-if="runningRss" :size="14" class="animate-spin" />
|
||||
<RefreshCw v-else :size="14" />
|
||||
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="flex items-baseline gap-[9px]">
|
||||
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
|
||||
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
|
||||
<RouterLink
|
||||
v-for="s in visibleWatched" :key="s.serieId"
|
||||
:to="`/series/${s.serieId}`"
|
||||
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
|
||||
>{{ s.serieName }}</RouterLink>
|
||||
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
|
||||
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Surveillances orphelines -->
|
||||
<SettingsSection
|
||||
v-if="syncOrphans.length > 0"
|
||||
:title="`Surveillances orphelines · ${syncOrphans.length}`"
|
||||
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
|
||||
>
|
||||
<div class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
|
||||
<span class="flex-1 min-w-0 text-body text-primary truncate">{{ s.serieName }}</span>
|
||||
<span class="text-meta text-muted shrink-0 tabular-nums">#{{ s.serieId }}</span>
|
||||
<button @click="removeSyncOrphan(s.serieId)" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
const seriesStore = useSeriesStore()
|
||||
|
||||
const updating = ref(false)
|
||||
const loaded = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
const status = ref<{ exists: boolean; count: number; empty: boolean; syncedAt?: string | null }>({ exists: false, count: 0, empty: true })
|
||||
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
|
||||
const watched = ref<{ serieId: number; serieName: string }[]>([])
|
||||
const runningRss = ref(false)
|
||||
const showAllWatched = ref(false)
|
||||
const visibleWatched = computed(() => showAllWatched.value ? watched.value : watched.value.slice(0, 8))
|
||||
const apiOnlyCount = computed(() => Math.max(0, seriesStore.series.length - status.value.count))
|
||||
|
||||
onMounted(async () => {
|
||||
fetchSyncOrphans()
|
||||
fetchWatched()
|
||||
if (seriesStore.series.length === 0) seriesStore.fetchSeries()
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
async function fetchWatched() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync', { credentials: 'include' })
|
||||
if (res.ok) watched.value = (await res.json()).sort((a: any, b: any) => a.serieName.localeCompare(b.serieName, 'fr'))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchSyncOrphans() {
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
|
||||
@@ -106,19 +155,34 @@ async function removeSyncOrphan(serieId: number) {
|
||||
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
|
||||
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
|
||||
watched.value = watched.value.filter(s => s.serieId !== serieId)
|
||||
toast('Surveillance retirée ✓', 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function runRss() {
|
||||
runningRss.value = true
|
||||
try {
|
||||
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
|
||||
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
runningRss.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
status.value = { exists: true, count, empty: count === 0 }
|
||||
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
fetchSyncOrphans()
|
||||
} else {
|
||||
@@ -131,4 +195,4 @@ async function update() {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,125 +1,119 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="openAddModal" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Plus :size="15" :stroke-width="2.25" /> Ajouter un client
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Clients configurés -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Clients configurés</h3>
|
||||
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
|
||||
</div>
|
||||
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste vide -->
|
||||
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun client configuré.
|
||||
</div>
|
||||
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
|
||||
<p class="text-body text-muted">Aucun client configuré.</p>
|
||||
<button @click="openAddModal" class="btn-secondary btn-sm">Ajouter un client</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste clients -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.uuid"
|
||||
class="settings-card flex items-center justify-between"
|
||||
class="flex items-center gap-4 px-5 py-3.5 border-t border-hover flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
|
||||
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="text-sm font-medium text-primary">{{ client.name }}</span>
|
||||
<span
|
||||
class="pill h-5 px-[9px] text-[11px]"
|
||||
:class="healthStatus[client.uuid] === true ? 'pill-ok' : healthStatus[client.uuid] === false ? 'pill-err' : 'pill-muted'"
|
||||
>
|
||||
{{ healthStatus[client.uuid] === true ? 'Connecté' : healthStatus[client.uuid] === false ? 'Injoignable' : 'Vérification…' }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted truncate">
|
||||
{{ clientLabel(client.type) }}<template v-if="client.config?.url"> · {{ client.config.url }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="testClient(client.uuid)" :disabled="testingClient[client.uuid]" class="btn-secondary btn-sm pointer-fine:h-[34px]">
|
||||
{{ testingClient[client.uuid] ? 'Test…' : 'Tester' }}
|
||||
</button>
|
||||
<div class="relative" @click.stop>
|
||||
<button
|
||||
@click="clientMenu = clientMenu === client.uuid ? null : client.uuid"
|
||||
class="w-[34px] h-[34px] pointer-coarse:w-10 pointer-coarse:h-10 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-primary hover:bg-hover transition-colors"
|
||||
:aria-label="`Actions pour ${client.name}`" aria-haspopup="menu" :aria-expanded="clientMenu === client.uuid"
|
||||
>
|
||||
<Ellipsis :size="15" />
|
||||
</button>
|
||||
<div v-if="clientMenu === client.uuid" class="menu absolute right-0 top-full mt-1.5 w-44 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item" @click="clientMenu = null; editClient(client)"><Pencil :size="14" /> Modifier</button>
|
||||
<button role="menuitem" class="menu-item text-err hover:text-err" @click="clientMenu = null; deleteClient(client)"><Trash2 :size="14" /> Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
||||
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tableau comparatif -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Comparatif des clients</h3>
|
||||
<p class="text-xs text-muted mb-3">Fonctionnalités supportées par chaque client.</p>
|
||||
<div class="overflow-x-auto rounded-xl border border-border">
|
||||
<table class="w-full text-xs">
|
||||
<!-- Types pris en charge -->
|
||||
<section class="bg-card rounded-card overflow-hidden">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">Types pris en charge</h3>
|
||||
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
|
||||
</div>
|
||||
<div class="overflow-x-auto border-t border-hover">
|
||||
<table class="w-full min-w-[720px] text-meta">
|
||||
<thead>
|
||||
<tr class="border-b border-border bg-shell">
|
||||
<th class="text-left px-4 py-2.5 text-muted font-medium whitespace-nowrap">Client</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Auth</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Sél. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Prog. fichier</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Upload .torrent</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Catégories</th>
|
||||
<th class="text-center px-3 py-2.5 text-muted font-medium whitespace-nowrap">Suppr. fichiers</th>
|
||||
<tr class="border-b border-hover">
|
||||
<th class="text-left px-5 h-[34px] tag-label tracking-[0.12em] font-bold">Client</th>
|
||||
<th class="text-left px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Authentification</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Choix du fichier</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
|
||||
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/60">
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="hover:bg-hover/40 transition-colors">
|
||||
<!-- Client + version -->
|
||||
<td class="px-4 py-2.5">
|
||||
<tbody>
|
||||
<tr v-for="row in clientMatrix" :key="row.id" class="border-b border-hover last:border-b-0">
|
||||
<td class="px-5 py-2.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === true ? 'bg-green-500' :
|
||||
healthStatus[clients.find(c => c.type === row.id)?.uuid ?? ''] === false ? 'bg-red-500' : 'bg-transparent'" />
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
:class="matrixHealth(row.id) === true ? 'bg-ok' : matrixHealth(row.id) === false ? 'bg-err' : 'bg-transparent'"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-[10px] text-muted mt-0.5">{{ row.version }}</p>
|
||||
<p class="text-body text-primary font-medium">{{ row.label }}</p>
|
||||
<p class="text-xs text-muted">{{ row.version }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Auth -->
|
||||
<td class="text-center px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<!-- Sélection fichier -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.fileSelect ? 'text-yellow-400' : 'text-muted'">
|
||||
{{ row.fileSelect ? 'Async ✓' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Progression par fichier -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.fileProgress ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Upload .torrent (FanKarr proxy) -->
|
||||
<td class="text-center px-3 py-2.5">
|
||||
<span :class="row.torrentUpload === 'proxy' ? 'text-green-400' : row.torrentUpload === 'url' ? 'text-secondary' : 'text-muted'">
|
||||
{{ row.torrentUpload === 'proxy' ? '✓ Proxy' : row.torrentUpload === 'url' ? 'URL' : '✗' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Catégories -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.categories ? '✓' : '✗' }}
|
||||
</td>
|
||||
<!-- Suppression fichiers -->
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-green-400' : 'text-muted'">
|
||||
{{ row.deleteFiles ? '✓' : '✗' }}
|
||||
</td>
|
||||
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
|
||||
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[11px] text-muted">
|
||||
<span><span class="text-yellow-400">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé</span>
|
||||
<span><span class="text-green-400">✓ Proxy</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés supportés)</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'URL</span>
|
||||
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
|
||||
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
|
||||
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
|
||||
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
<!-- Modale ajout / modification -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="modal.open"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-primary">{{ modal.client ? 'Modifier un client' : 'Ajouter un client' }}</h3>
|
||||
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
||||
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
|
||||
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 id="client-modal-title" class="font-display text-xl font-bold text-primary">{{ modal.client ? 'Modifier le client' : 'Ajouter un client' }}</h3>
|
||||
<button @click="closeModal" class="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<TorrentClientForm
|
||||
@@ -128,7 +122,6 @@
|
||||
:definitions="availableClients"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -137,7 +130,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Ellipsis, Pencil, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import TorrentClientForm from '@/components/settings/TorrentClientForm.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { TorrentClientDefinition, SavedTorrentClient } from '@/types/torrent-client'
|
||||
@@ -147,6 +141,16 @@ const { add: toast } = useToast()
|
||||
const clients = ref<SavedTorrentClient[]>([])
|
||||
const availableClients = ref<TorrentClientDefinition[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
const testingClient = ref<Record<string, boolean>>({})
|
||||
const clientMenu = ref<string | null>(null)
|
||||
|
||||
const closeClientMenu = () => { clientMenu.value = null }
|
||||
onUnmounted(() => document.removeEventListener('click', closeClientMenu))
|
||||
|
||||
function matrixHealth(type: string) {
|
||||
const client = clients.value.find(c => c.type === type)
|
||||
return client ? healthStatus.value[client.uuid] : undefined
|
||||
}
|
||||
|
||||
const clientMatrix = [
|
||||
{
|
||||
@@ -189,6 +193,7 @@ onMounted(async () => {
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (availRes.ok) availableClients.value = await availRes.json()
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
document.addEventListener('click', closeClientMenu)
|
||||
})
|
||||
|
||||
async function refreshHealth(uuid: string) {
|
||||
@@ -202,13 +207,22 @@ async function refreshHealth(uuid: string) {
|
||||
}
|
||||
|
||||
async function testClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
testingClient.value[uuid] = true
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
testingClient.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(uuid: string) {
|
||||
async function deleteClient(client: SavedTorrentClient) {
|
||||
if (!confirm(`Supprimer le client « ${client.name} » ?`)) return
|
||||
const uuid = client.uuid
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
clients.value = clients.value.filter(c => c.uuid !== uuid)
|
||||
|
||||
@@ -1,212 +1,235 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-4 md:px-8 py-6 max-w-4xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Gestion des imports</h2>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Visualisez et renommez vos fichiers importés selon le mode actuel
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-[10px]"
|
||||
:class="nfoSupport ? 'bg-accent/10 text-accent' : 'bg-hover text-muted'">
|
||||
{{ nfoSupport ? 'Mode NFO' : 'Mode Formaté' }}
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
<ScanSearch v-else :size="15" />
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Nommage ────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Nommage">
|
||||
<template #description>
|
||||
FanKarr renomme les fichiers d’après le catalogue Fankai, en
|
||||
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="loading">Analyse des fichiers importés…</template>
|
||||
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
|
||||
<template v-else>Tous les fichiers portent le nom attendu.</template>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="renameAll()" :disabled="renamingAll || totalNeedsRename === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ renamingAll ? 'Renommage…' : totalNeedsRename > 0 ? `Renommer les ${totalNeedsRename}` : 'Renommer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="load" :disabled="loading" class="btn-secondary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
:class="loading ? 'animate-spin' : ''">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
|
||||
<polyline points="16 5 21 5 21 10"/>
|
||||
</svg>
|
||||
Actualiser
|
||||
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
|
||||
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
|
||||
</p>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
|
||||
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ renamingFolders ? 'Renommage…' : staleFolders.length > 1 ? `Renommer les ${staleFolders.length} dossiers` : 'Renommer le dossier' }}
|
||||
</button>
|
||||
<button
|
||||
@click="forceMetadataRefresh"
|
||||
:disabled="forcingRefresh"
|
||||
class="btn-secondary"
|
||||
title="Recharge les correspondances de renommage depuis le scraper Fankai en ignorant tous les caches, migre les IDs d'épisodes, puis renomme les fichiers importés"
|
||||
>
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer la MAJ des correspondances' }}
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
|
||||
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
|
||||
</div>
|
||||
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="purgeNfo"
|
||||
:disabled="purgingNfo"
|
||||
class="btn-secondary text-red-400 hover:text-red-300"
|
||||
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
|
||||
>
|
||||
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Métadonnées ────────────────────────────────────────── -->
|
||||
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
|
||||
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
|
||||
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
|
||||
</span>
|
||||
</div>
|
||||
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ checkingNfo ? 'Lancement…' : 'Vérifier' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-err">Purger les NFO</span>
|
||||
<span class="text-meta text-muted">Supprime tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de séries. Les vidéos ne sont pas touchées.</span>
|
||||
</div>
|
||||
<button @click="purgeNfo" :disabled="purgingNfo" class="btn-danger btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ purgingNfo ? 'Suppression…' : 'Purger' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
|
||||
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
|
||||
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
|
||||
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
|
||||
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
|
||||
</span>
|
||||
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
|
||||
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
|
||||
<ChevronDown :size="13" class="transition-transform" :class="{ 'rotate-180': orphansOpen }" />
|
||||
</button>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Nettoyer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
|
||||
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
|
||||
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
|
||||
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
|
||||
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
|
||||
</div>
|
||||
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
|
||||
{{ migrating ? 'Migration…' : 'Migrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Fichiers importés, série par série ──────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
|
||||
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="totalNeedsRename > 0"
|
||||
@click="renameAll()"
|
||||
:disabled="renamingAll"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ renamingAll ? 'Renommage…' : `Tout renommer (${totalNeedsRename})` }}
|
||||
</button>
|
||||
@click="showOnlyNeedsRename = !showOnlyNeedsRename"
|
||||
class="chip"
|
||||
:class="{ 'is-active': showOnlyNeedsRename }"
|
||||
:aria-pressed="showOnlyNeedsRename"
|
||||
>À renommer<span class="chip-count">{{ seriesNeedingRename }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-12 text-muted text-body border-t border-hover">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="series.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucune série importée</p>
|
||||
<p class="text-xs text-muted">Importez des séries depuis la page Activité ou via l'import manuel.</p>
|
||||
</div>
|
||||
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
|
||||
<p class="text-body text-primary">Aucune série importée</p>
|
||||
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultat rename all -->
|
||||
<div v-if="renameResult" class="settings-card flex items-center justify-between gap-3">
|
||||
<p class="text-xs" :class="renameResult.errors.length > 0 ? 'text-yellow-500' : 'text-green-400'">
|
||||
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}
|
||||
<span v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</span>
|
||||
</p>
|
||||
<button @click="renameResult = null" class="text-xs text-muted hover:text-primary">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste séries -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="serie in filteredSeries"
|
||||
:key="serie.serie_id"
|
||||
class="settings-card flex flex-col gap-0"
|
||||
>
|
||||
<!-- Header série -->
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<button @click="toggleSerie(serie.serie_id)" class="text-muted hover:text-primary transition-colors">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
|
||||
class="transition-transform" :class="collapsed.has(serie.serie_id) ? '' : 'rotate-180'">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="text-sm font-medium text-primary">{{ serie.serie_title }}</p>
|
||||
<span class="text-xs text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span v-if="serie.needs_rename > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20">
|
||||
{{ serie.needs_rename }} à renommer
|
||||
</span>
|
||||
<span v-if="serie.episodes.filter((e: any) => !e.file_exists).length > 0"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{{ serie.episodes.filter((e: any) => !e.file_exists).length }} introuvable{{ serie.episodes.filter((e: any) => !e.file_exists).length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
✓ À jour
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="text-xs text-accent hover:underline">
|
||||
Tout renommer
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="text-xs text-muted hover:text-primary transition-colors">
|
||||
Désimporter
|
||||
</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="text-xs text-red-400 hover:text-red-300 transition-colors">
|
||||
Supprimer fichiers
|
||||
</button>
|
||||
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
|
||||
<div class="flex items-center gap-3 px-5 py-3 flex-wrap">
|
||||
<button
|
||||
@click="toggleSerie(serie.serie_id)"
|
||||
class="w-7 h-7 -ml-1 rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors shrink-0"
|
||||
:aria-expanded="!collapsed.has(serie.serie_id)"
|
||||
:aria-label="collapsed.has(serie.serie_id) ? 'Déplier' : 'Replier'"
|
||||
>
|
||||
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
|
||||
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
|
||||
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
|
||||
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
|
||||
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
|
||||
<div class="flex items-center gap-1.5 ml-auto">
|
||||
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
|
||||
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste épisodes -->
|
||||
<div v-if="!collapsed.has(serie.serie_id)" class="flex flex-col divide-y divide-border/50 mt-2 -mx-4 px-4">
|
||||
<div v-if="!collapsed.has(serie.serie_id)" class="pb-2">
|
||||
<div
|
||||
v-for="ep in serie.episodes"
|
||||
:key="ep.episode_id"
|
||||
class="flex items-center gap-3 py-2.5"
|
||||
class="flex items-center gap-3 px-5 py-2 border-t border-hover/60 flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<!-- Numéro -->
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
|
||||
S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}
|
||||
</span>
|
||||
|
||||
<!-- Noms -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary truncate">{{ ep.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span class="text-[11px] font-mono text-muted truncate max-w-xs">{{ ep.current_name }}</span>
|
||||
<p class="text-body text-primary truncate">{{ ep.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 min-w-0">
|
||||
<span class="text-xs text-muted truncate" :title="ep.current_name">{{ ep.current_name }}</span>
|
||||
<template v-if="ep.needs_rename">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0 text-yellow-400">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
<span class="text-[11px] font-mono text-yellow-400 truncate max-w-xs">{{ ep.expected_name }}</span>
|
||||
<ArrowRight :size="11" class="shrink-0 text-accent" />
|
||||
<span class="text-xs text-accent truncate" :title="ep.expected_name">{{ ep.expected_name }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span v-if="!ep.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">Introuvable</span>
|
||||
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
|
||||
<button
|
||||
v-else
|
||||
@click="renameSingle(ep, serie)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
|
||||
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
|
||||
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
|
||||
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, false)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
Désimporter
|
||||
</button>
|
||||
<button
|
||||
@click="unimportEp(ep, true)"
|
||||
:disabled="renamingEp[ep.episode_id]"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
|
||||
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entrées orphelines -->
|
||||
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
|
||||
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
|
||||
</div>
|
||||
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
|
||||
{{ removingOrphans ? '…' : 'Tout retirer' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
|
||||
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
|
||||
<span class="text-xs text-muted font-mono shrink-0 w-14">
|
||||
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 text-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
|
||||
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
|
||||
<button
|
||||
@click="removeOrphans([o.episode_id])"
|
||||
:disabled="removingOrphans"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -223,11 +246,32 @@ const renameResult = ref<{ done: number; errors: any[] } | null>(null)
|
||||
const showOnlyNeedsRename = ref(false)
|
||||
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
|
||||
const removingOrphans = ref(false)
|
||||
const orphansOpen = ref(false)
|
||||
const staleFolders = ref<{ serie_id: number; serie_title: string; expected: string; current: string[] }[]>([])
|
||||
const renamingFolders = ref(false)
|
||||
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
|
||||
const scanning = ref(false)
|
||||
const checkingNfo = ref(false)
|
||||
const lastNfoUpdate = ref<{ serieTitle: string; updatedAt: string } | null>(null)
|
||||
const migrating = ref(false)
|
||||
|
||||
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
|
||||
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
|
||||
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
|
||||
const missingIn = (serie: any) => serie.episodes.filter((e: any) => !e.file_exists).length
|
||||
const missingFiles = computed(() => series.value.reduce((acc, s) => acc + missingIn(s), 0))
|
||||
|
||||
// « 3 h », « 12 min », « 2 j »
|
||||
const sinceScan = computed(() => {
|
||||
if (!lastScan.value) return ''
|
||||
const m = Math.max(0, Math.floor((Date.now() - new Date(lastScan.value.at).getTime()) / 60_000))
|
||||
return m < 60 ? `${m} min` : m < 1440 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} j`
|
||||
})
|
||||
|
||||
const filteredSeries = computed(() =>
|
||||
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
|
||||
@@ -248,6 +292,86 @@ async function load() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const [folders, scanRes, nfo] = await Promise.all([
|
||||
fetch('/api/organized-folders', { credentials: 'include' }),
|
||||
fetch('/api/scan', { credentials: 'include' }),
|
||||
fetch('/api/nfo-updates/recent', { credentials: 'include' }),
|
||||
])
|
||||
if (folders.ok) staleFolders.value = await folders.json()
|
||||
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
|
||||
if (nfo.ok) lastNfoUpdate.value = (await nfo.json()).find((n: any) => n.status === 'updated') ?? null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function renameFolders() {
|
||||
const targets = staleFolders.value
|
||||
if (!targets.length) return
|
||||
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
|
||||
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
|
||||
renamingFolders.value = true
|
||||
let moved = 0
|
||||
try {
|
||||
for (const f of targets) {
|
||||
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
|
||||
const d = await res.json()
|
||||
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
|
||||
moved += d.moved ?? 0
|
||||
}
|
||||
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
|
||||
await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
renamingFolders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNfo() {
|
||||
checkingNfo.value = true
|
||||
try {
|
||||
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
checkingNfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateIds() {
|
||||
migrating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
|
||||
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
|
||||
if (data.updated > 0) await load()
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
migrating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSerie(id: number) {
|
||||
|
||||
@@ -1,73 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Serveur Jellyfin</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Configurez la connexion à votre serveur Jellyfin pour synchroniser les utilisateurs
|
||||
et permettre l'authentification depuis le plugin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
|
||||
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Synchronisation des utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Importe les utilisateurs Jellyfin en tant que comptes FanKarr.
|
||||
Les comptes existants sont ignorés. Les utilisateurs pourront ensuite se connecter
|
||||
via le plugin Jellyfin sans configuration supplémentaire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div v-if="syncResult" class="flex flex-col gap-1">
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p v-if="syncResult.users.length > 0" class="text-xs text-muted">
|
||||
Nouveaux : {{ syncResult.users.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary w-fit disabled:opacity-50">
|
||||
<SettingsSection
|
||||
title="Synchronisation des utilisateurs"
|
||||
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
|
||||
</button>
|
||||
<p v-if="syncError" class="text-xs text-red-400">{{ syncError }}</p>
|
||||
<p v-if="!hasToken" class="text-xs text-muted">Configurez d'abord le token admin.</p>
|
||||
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
|
||||
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
|
||||
<span v-else-if="syncResult" class="text-meta text-secondary">
|
||||
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
|
||||
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Référence API ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-base font-semibold text-primary">Référence API v1</h2>
|
||||
<div class="settings-card overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Méthode</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Endpoint</th>
|
||||
<th class="text-left px-3 py-2 text-muted font-medium">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border/50">
|
||||
<tr v-for="ep in apiEndpoints" :key="ep.path">
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-mono font-semibold" :class="methodClass(ep.method)">{{ ep.method }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<code class="text-muted">{{ ep.path }}</code>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted">{{ ep.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- ── API publique ───────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
|
||||
<h3 class="card-title">API publique</h3>
|
||||
<p class="text-meta text-muted">
|
||||
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="ep in apiEndpoints" :key="ep.method + ep.path"
|
||||
class="flex items-center gap-x-3.5 gap-y-1 px-5 py-2.5 border-t border-hover flex-wrap sm:flex-nowrap"
|
||||
>
|
||||
<span class="pill h-[22px] px-2 text-[10.5px] w-14 justify-center shrink-0" :class="ep.method === 'GET' ? 'pill-neutral' : 'pill-wait'">{{ ep.method }}</span>
|
||||
<code class="text-body text-primary sm:w-60 shrink-0 font-sans">{{ ep.path }}</code>
|
||||
<span class="text-meta text-muted">{{ ep.desc }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -76,24 +54,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { RefreshCw } from 'lucide-vue-next'
|
||||
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const hasToken = ref(false)
|
||||
const syncing = ref(false)
|
||||
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
|
||||
const syncError = ref<string | null>(null)
|
||||
|
||||
const apiEndpoints = [
|
||||
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
|
||||
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
|
||||
{ method: 'GET', path: '/api/v1/series/search?q=', desc: 'Rechercher dans le catalogue FanKai' },
|
||||
{ method: 'POST', path: '/api/v1/requests', desc: 'Créer ou mettre à jour une demande' },
|
||||
{ method: 'GET', path: '/api/v1/requests', desc: 'Récupérer ses propres demandes' },
|
||||
]
|
||||
const apiBase = `${window.location.origin}/api/v1`
|
||||
|
||||
function methodClass(m: string) {
|
||||
return { GET: 'text-green-400', POST: 'text-blue-400', DELETE: 'text-red-400' }[m] ?? 'text-muted'
|
||||
}
|
||||
const apiEndpoints = [
|
||||
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
|
||||
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
|
||||
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
|
||||
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
|
||||
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
|
||||
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
|
||||
]
|
||||
|
||||
async function syncUsers() {
|
||||
syncing.value = true
|
||||
|
||||
+135
-113
@@ -1,109 +1,108 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
|
||||
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="exportLogs" :disabled="entries.length === 0" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Download :size="15" /> Exporter
|
||||
</button>
|
||||
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-1">
|
||||
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<button
|
||||
v-for="l in levels" :key="l.value"
|
||||
@click="filterLevel = l.value; load()"
|
||||
class="px-3 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="filterLevel === l.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
class="chip"
|
||||
:class="{
|
||||
'is-active': filterLevel === l.value,
|
||||
'border-err/30 text-err hover:text-err': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
|
||||
}"
|
||||
:aria-pressed="filterLevel === l.value"
|
||||
>
|
||||
{{ l.label }}
|
||||
{{ l.label }}<span
|
||||
class="chip-count"
|
||||
:class="{
|
||||
'text-err! font-bold': l.value === 'error' && (counts.error ?? 0) > 0 && filterLevel !== 'error',
|
||||
'text-accent! font-bold': l.value === 'warn' && (counts.warn ?? 0) > 0 && filterLevel !== 'warn',
|
||||
}"
|
||||
>{{ l.value === 'all' ? totalCount : (counts[l.value] ?? 0) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option value="organize">organize</option>
|
||||
<option value="api">api</option>
|
||||
<option value="auth">auth</option>
|
||||
</select>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<span class="text-xs text-muted mr-1">Afficher</span>
|
||||
<button
|
||||
v-for="n in [100, 500, 2000]" :key="n"
|
||||
@click="limit = n; load()"
|
||||
class="px-2 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="limit === n
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ n === 2000 ? 'Tout' : n }}
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<select v-model="filterSource" @change="load()" class="field h-[34px] pointer-coarse:h-10 py-0 rounded-full w-auto text-[13px]" aria-label="Source">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option v-for="s in sources" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<div class="segmented" role="group" aria-label="Nombre d'entrées">
|
||||
<button
|
||||
v-for="n in [100, 500, 2000]" :key="n"
|
||||
@click="limit = n; load()"
|
||||
class="segmented-item"
|
||||
:class="{ 'is-active': limit === n }"
|
||||
:aria-pressed="limit === n"
|
||||
>{{ n === 2000 ? 'Tout' : n }}</button>
|
||||
</div>
|
||||
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
|
||||
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
Chargement…
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
|
||||
Aucun log
|
||||
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
|
||||
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="settings-card p-0 overflow-hidden">
|
||||
<section v-else class="bg-card rounded-card overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(entry, i) in entries" :key="i"
|
||||
class="border-b border-border/50 hover:bg-hover transition-colors"
|
||||
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
|
||||
>
|
||||
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
|
||||
{{ entry.level.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
|
||||
<td class="px-3 py-2 text-primary break-all">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm effacement -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
|
||||
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
|
||||
<p class="text-xs text-muted">Cette action est irréversible.</p>
|
||||
<div class="min-w-[640px]">
|
||||
<div class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
|
||||
<span>Heure</span><span>Niveau</span><span>Source</span><span>Message</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
|
||||
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
|
||||
<div
|
||||
v-for="(entry, i) in entries" :key="i"
|
||||
class="grid grid-cols-[92px_128px_104px_minmax(0,1fr)] items-start gap-y-1 px-5 py-2 border-b border-hover last:border-b-0 hover:bg-hover/30 transition-colors"
|
||||
>
|
||||
<span class="text-meta text-muted tabular-nums pt-px" :title="fullDate(entry.at)">{{ formatTime(entry.at) }}</span>
|
||||
<span>
|
||||
<span class="pill h-5 px-2 text-[10.5px] gap-1" :class="levelInfo(entry.level).class">
|
||||
<TriangleAlert v-if="entry.level === 'error'" :size="11" :stroke-width="2.25" />
|
||||
{{ levelInfo(entry.level).label }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted truncate pt-px">{{ entry.source }}</span>
|
||||
<span class="text-body break-words" :class="entry.level === 'error' ? 'text-err' : 'text-primary'">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-meta text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 id="logs-clear-title" class="card-title">Vider les journaux ?</h3>
|
||||
<p class="text-meta text-muted">Cette action est irréversible.</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="showConfirm = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="doClear" class="btn-danger">Vider</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,12 +112,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatSize } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const entries = ref<any[]>([])
|
||||
const allEntries = ref<any[]>([])
|
||||
const fileSize = ref(0)
|
||||
const loading = ref(true)
|
||||
const showConfirm = ref(false)
|
||||
@@ -127,27 +129,37 @@ const filterSource = ref('')
|
||||
const limit = ref(100)
|
||||
|
||||
const levels = [
|
||||
{ label: 'Tous', value: 'all' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
{ label: 'Error', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
{ label: 'Tout', value: 'all' },
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Avertissements', value: 'warn' },
|
||||
{ label: 'Erreurs', value: 'error' },
|
||||
{ label: 'Debug', value: 'debug' },
|
||||
]
|
||||
|
||||
// Compteurs et sources
|
||||
const counts = computed(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
|
||||
return c
|
||||
})
|
||||
const totalCount = computed(() => allEntries.value.length)
|
||||
const sources = computed(() => [...new Set(allEntries.value.map(e => e.source).filter(Boolean))].sort())
|
||||
|
||||
async function fetchLogs(level: string, source: string, max: number) {
|
||||
const params = new URLSearchParams({ limit: String(max), level, ...(source ? { source } : {}) })
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
return res.ok ? await res.json() : null
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit.value),
|
||||
level: filterLevel.value,
|
||||
...(filterSource.value ? { source: filterSource.value } : {}),
|
||||
})
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
entries.value = data.entries
|
||||
fileSize.value = data.size
|
||||
}
|
||||
const [filtered, all] = await Promise.all([
|
||||
fetchLogs(filterLevel.value, filterSource.value, limit.value),
|
||||
fetchLogs('all', '', 2000),
|
||||
])
|
||||
if (filtered) { entries.value = filtered.entries; fileSize.value = filtered.size }
|
||||
if (all) allEntries.value = all.entries
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -156,30 +168,40 @@ async function load() {
|
||||
async function doClear() {
|
||||
showConfirm.value = false
|
||||
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
|
||||
toast('Logs effacés', 'success')
|
||||
toast('Journaux vidés', 'success')
|
||||
await load()
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
// Fichier texte
|
||||
function exportLogs() {
|
||||
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
|
||||
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `fankarr-journaux-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
const time = d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
return d.toDateString() === new Date().toDateString() ? time : `${d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })} ${time.slice(0, 5)}`
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
function fullDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('fr-FR')
|
||||
}
|
||||
|
||||
function levelClass(level: string): string {
|
||||
function levelInfo(level: string): { label: string; class: string } {
|
||||
return ({
|
||||
debug: 'bg-border text-muted',
|
||||
info : 'bg-blue-500/10 text-blue-400',
|
||||
warn : 'bg-yellow-500/10 text-yellow-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
} as Record<string, string>)[level] ?? 'bg-border text-muted'
|
||||
debug: { label: 'Debug', class: 'pill-muted' },
|
||||
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
|
||||
warn : { label: 'Avertissement', class: 'pill-wait' },
|
||||
error: { label: 'Erreur', class: 'pill-err' },
|
||||
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
</script>
|
||||
@@ -1,190 +1,185 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Media Management</h2>
|
||||
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
|
||||
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<!-- Chargement -->
|
||||
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Chemins -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Dossier téléchargements</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
<!-- Dossiers -->
|
||||
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
|
||||
<span class="field-label flex items-center gap-2">
|
||||
{{ field.label }}
|
||||
<span v-if="isDocker" class="pill pill-neutral h-5 px-2 text-[10.5px]">Docker</span>
|
||||
</span>
|
||||
<button
|
||||
@click="openPicker(field.key)"
|
||||
class="field flex items-center gap-2.5 text-left h-[42px] py-0"
|
||||
:class="isRootPath(form[field.key]) ? 'border-accent/50' : ''"
|
||||
>
|
||||
<span class="flex-1 truncate" :class="form[field.key] ? 'text-primary' : 'text-muted'">{{ form[field.key] || '/' }}</span>
|
||||
<span class="text-meta text-secondary shrink-0">Parcourir</span>
|
||||
</button>
|
||||
<p v-if="isRootPath(form[field.key])" class="text-xs text-accent flex items-center gap-1.5">
|
||||
<TriangleAlert :size="13" /> Chemin non configuré : cliquez pour choisir un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted">{{ field.hint }}</p>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Médiathèque Fankai</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
|
||||
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Alerte chemins non configurés -->
|
||||
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
|
||||
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
|
||||
<p class="text-xs text-muted">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
|
||||
Configurez-les ci-dessus avant de sauvegarder.
|
||||
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
|
||||
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="card-title text-accent">Chemins non configurés</p>
|
||||
<p class="text-meta text-secondary">
|
||||
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
|
||||
:key="mode"
|
||||
@click="form.organizeMode = mode"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors capitalize"
|
||||
:class="form.organizeMode === mode
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
<!-- Import -->
|
||||
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
|
||||
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
|
||||
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
|
||||
<button
|
||||
v-for="mode in (['hardlink', 'copy', 'move'] as const)"
|
||||
:key="mode"
|
||||
type="button"
|
||||
@click="form.organizeMode = mode"
|
||||
class="segmented-item"
|
||||
:class="{ 'is-active': form.organizeMode === mode }"
|
||||
:aria-pressed="form.organizeMode === mode"
|
||||
>
|
||||
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<div class="h-px bg-hover" />
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
<SettingsToggle
|
||||
:model-value="form.nfoSupport"
|
||||
@update:model-value="onNfoToggle"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Infuse, etc.)."
|
||||
label="NFO et métadonnées"
|
||||
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-if="form.organizeMode === 'move'"
|
||||
v-model="form.deleteTorrentOnMove"
|
||||
label="Supprimer le torrent après déplacement"
|
||||
description="Supprime automatiquement le torrent du client après un import en mode Déplacer."
|
||||
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.autoUnimportMissing"
|
||||
label="Désimporter si fichier manquant"
|
||||
description="Si un fichier importé n'est plus trouvé sur le disque lors du scan, il est automatiquement retiré de la bibliothèque."
|
||||
label="Désimporter si le fichier a disparu"
|
||||
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
|
||||
/>
|
||||
<SettingsToggle
|
||||
v-model="form.englishDirectory"
|
||||
label="Utilise 'Season' au lieu de 'Saison' dans les dossiers"
|
||||
description="Si activé, les dossiers de saisons seront nommés 'Season 01' au lieu de 'Saison 01'."
|
||||
label="Dossiers « Season » plutôt que « Saison »"
|
||||
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Plex -->
|
||||
<div class="settings-card flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="settings-label">Plex</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
Créez une bibliothèque Plex connectée à l'agent de métadonnées Fankai.
|
||||
Requiert Plex Media Server 1.43+ pour la configuration automatique de l'agent.
|
||||
</p>
|
||||
<!-- Serveurs média -->
|
||||
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
|
||||
Jellyfin
|
||||
<span class="pill h-5 px-[9px] text-[11px]" :class="jellyfinConfigured ? 'pill-ok' : 'pill-muted'">{{ jellyfinConfigured ? 'Configuré' : 'Non configuré' }}</span>
|
||||
</span>
|
||||
<span class="text-meta text-muted">Synchronisation des comptes et connexion depuis le plugin FanKarr Search.</span>
|
||||
</div>
|
||||
<RouterLink to="/settings/jellyfin" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Réglages Jellyfin</RouterLink>
|
||||
</div>
|
||||
<button @click="plexOpen = true" class="btn-secondary shrink-0">Configurer Plex</button>
|
||||
</div>
|
||||
<div class="h-px bg-hover" />
|
||||
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-sm font-medium text-primary">Plex</span>
|
||||
<span class="text-meta text-muted">Crée une bibliothèque Plex reliée à l'agent de métadonnées Fankai. Nécessite Plex Media Server 1.43 ou plus récent pour configurer l'agent automatiquement.</span>
|
||||
</div>
|
||||
<button @click="plexOpen = true" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">Configurer Plex</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '...' : 'Sauvegarder' }}
|
||||
<!-- Scan -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Loader v-if="scanning" :size="15" class="animate-spin" />
|
||||
<ScanSearch v-else :size="15" />
|
||||
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary">
|
||||
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
|
||||
</button>
|
||||
<span v-if="scanResult" class="text-xs" :class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'">
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
<span v-if="lastScan" class="text-meta text-muted">
|
||||
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
<!-- Choix de dossier -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
|
||||
<!-- Plex Wizard -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
<!-- Assistant Plex -->
|
||||
<PlexWizard
|
||||
v-if="plexOpen"
|
||||
:media-path="form.mediaPath"
|
||||
@close="plexOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="nfoConfirmOpen"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="nfoConfirmOpen = false"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-sm p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h3 class="text-sm font-semibold text-primary">Activer les NFO / Métadonnées ?</h3>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
les métadonnées sont déjà gérées directement par l'agent — activer les NFO est inutile
|
||||
et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-xs text-muted leading-relaxed">
|
||||
Activez cette option uniquement si vous utilisez <span class="text-primary">Infuse</span>
|
||||
ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button @click="confirmNfo" class="btn-primary text-xs">Oui, activer quand même</button>
|
||||
<button @click="nfoConfirmOpen = false" class="btn-secondary text-xs">Annuler</button>
|
||||
<!-- Confirmation NFO -->
|
||||
<Teleport to="body">
|
||||
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 id="nfo-title" class="card-title">Activer les NFO et métadonnées ?</h3>
|
||||
<p class="text-meta text-secondary leading-relaxed">
|
||||
Si vous utilisez l'agent Fankai sur <span class="text-primary">Jellyfin, Plex, Emby ou Kodi</span>,
|
||||
les métadonnées sont déjà gérées par l'agent : activer les NFO est inutile et peut créer des conflits.
|
||||
</p>
|
||||
<p class="text-meta text-secondary leading-relaxed">
|
||||
Activez cette option seulement si vous utilisez <span class="text-primary">Infuse</span> ou un lecteur qui lit les fichiers NFO locaux.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="nfoConfirmOpen = false" class="btn-ghost">Annuler</button>
|
||||
<button @click="confirmNfo" class="btn-primary">Activer quand même</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
import PlexWizard from '@/components/settings/PlexWizard.vue'
|
||||
@@ -197,7 +192,14 @@ const isDocker = ref(false)
|
||||
const loaded = ref(false)
|
||||
const plexOpen = ref(false)
|
||||
const nfoConfirmOpen = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
const lastScan = ref<{ at: string; found: number; added: number } | null>(null)
|
||||
const jellyfinConfigured = ref(false)
|
||||
const savedSnapshot = ref('')
|
||||
|
||||
const pathFields = [
|
||||
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
|
||||
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
|
||||
] as const
|
||||
|
||||
function onNfoToggle(val: boolean) {
|
||||
if (val && !form.value.nfoSupport) {
|
||||
@@ -229,6 +231,13 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
|
||||
|
||||
function isRootPath(p: string): boolean { return !p || p === '/' }
|
||||
|
||||
// Réglages modifiés
|
||||
const dirtyCount = computed(() => {
|
||||
if (!savedSnapshot.value) return 0
|
||||
const saved = JSON.parse(savedSnapshot.value)
|
||||
return (Object.keys(form.value) as (keyof typeof form.value)[]).filter(k => saved[k] !== form.value[k]).length
|
||||
})
|
||||
|
||||
const hasUnconfiguredPaths = computed(() =>
|
||||
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
|
||||
)
|
||||
@@ -249,7 +258,7 @@ async function save() {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
@@ -258,12 +267,11 @@ async function save() {
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
|
||||
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
@@ -276,12 +284,19 @@ async function scan() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
const [settingsRes, systemRes, infoRes, scanRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
fetch('/api/scan', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (settingsRes.ok) {
|
||||
const s = await settingsRes.json()
|
||||
for (const k of Object.keys(form.value) as (keyof typeof form.value)[]) if (k in s) (form.value as any)[k] = s[k]
|
||||
jellyfinConfigured.value = !!s.jellyfinUrl
|
||||
savedSnapshot.value = JSON.stringify(form.value)
|
||||
}
|
||||
if (scanRes.ok) lastScan.value = (await scanRes.json()).lastScan ?? null
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
loaded.value = true
|
||||
|
||||
@@ -1,103 +1,105 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── Informations du compte ────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Mon compte</h2>
|
||||
<p class="text-sm text-muted mt-1">Informations associées à votre compte FanKarr.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="settings-label mb-1">Nom d'utilisateur</label>
|
||||
<p class="text-sm text-primary font-mono bg-shell px-3 py-2 rounded-lg border border-border w-fit">
|
||||
{{ auth.username }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1">Rôle</label>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="auth.isAdmin ? 'bg-violet-500/20 text-violet-300' : 'bg-blue-500/20 text-blue-300'">
|
||||
{{ auth.isAdmin ? 'Administrateur' : 'Utilisateur' }}
|
||||
<!-- ── Compte ─────────────────────────────────────────────── -->
|
||||
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
|
||||
<div class="flex items-center gap-[18px]">
|
||||
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
|
||||
{{ auth.username?.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[5px]">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<span class="text-[17px] font-bold text-primary">{{ auth.username }}</span>
|
||||
<span class="pill" :class="auth.isAdmin ? 'pill-wait' : 'pill-neutral'">{{ auth.isAdmin ? 'Administrateur' : 'Invité' }}</span>
|
||||
</div>
|
||||
<span v-if="me" class="text-meta text-muted">
|
||||
<template v-if="me.createdAt">Compte créé le {{ formatDate(me.createdAt) }}</template>
|
||||
<template v-if="me.createdAt && me.lastLoginAt"> · </template>
|
||||
<template v-if="me.lastLoginAt">dernière connexion {{ formatRelative(me.lastLoginAt) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Changer de mot de passe ───────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Changer le mot de passe</h2>
|
||||
<p class="text-sm text-muted mt-1">Votre nouveau mot de passe doit faire au moins 6 caractères.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Mot de passe actuel</label>
|
||||
<input v-model="currentPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
<!-- ── Mot de passe ──────────────────────────────────────── -->
|
||||
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-current" class="field-label">Mot de passe actuel</label>
|
||||
<input id="pwd-current" v-model="currentPassword" type="password" class="field" placeholder="••••••••" autocomplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Nouveau mot de passe</label>
|
||||
<input v-model="newPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-new" class="field-label">Nouveau mot de passe</label>
|
||||
<input id="pwd-new" v-model="newPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Confirmer le nouveau mot de passe</label>
|
||||
<input v-model="confirmPassword" type="password" class="settings-input" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button @click="changePassword" :disabled="changingPwd" class="btn-primary">
|
||||
{{ changingPwd ? 'Modification…' : 'Modifier le mot de passe' }}
|
||||
</button>
|
||||
<span v-if="pwdSuccess" class="text-xs text-green-400">Mot de passe modifié.</span>
|
||||
<span v-if="pwdError" class="text-xs text-red-400">{{ pwdError }}</span>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="pwd-confirm" class="field-label">Confirmer le nouveau mot de passe</label>
|
||||
<input id="pwd-confirm" v-model="confirmPassword" type="password" class="field" placeholder="••••••••" autocomplete="new-password" @keyup.enter="changePassword" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Token API personnel ──────────────────────────────── -->
|
||||
<section data-tour="profile-token" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Token API personnel</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Utilisez ce token pour accéder à l'API FanKarr depuis des applications tierces
|
||||
(ex : plugin Jellyfin).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 text-xs bg-shell px-3 py-2 rounded-lg border border-border text-muted font-mono truncate">
|
||||
{{ showToken ? myToken : '••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••' }}
|
||||
</code>
|
||||
<button @click="showToken = !showToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ showToken ? 'Masquer' : 'Afficher' }}
|
||||
</button>
|
||||
<button @click="copyToken" class="btn-ghost text-xs shrink-0">
|
||||
{{ tokenCopied ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="regenerateToken" class="btn-ghost text-xs text-red-400 hover:text-red-300 w-fit">
|
||||
Regénérer le token
|
||||
<div class="flex items-center gap-3.5 flex-wrap">
|
||||
<button @click="changePassword" :disabled="changingPwd" class="btn-secondary pointer-fine:h-[38px]">
|
||||
{{ changingPwd ? 'Modification…' : 'Changer le mot de passe' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted">
|
||||
Authentification via header :
|
||||
<code class="bg-shell px-1.5 py-0.5 rounded text-xs">Authorization: Bearer <token></code>
|
||||
</p>
|
||||
<span v-if="pwdSuccess" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Mot de passe modifié.</span>
|
||||
<span v-else-if="pwdError" class="text-meta text-err">{{ pwdError }}</span>
|
||||
<span v-else class="text-meta text-muted">Les autres sessions ouvertes resteront connectées.</span>
|
||||
</div>
|
||||
</section>
|
||||
</SettingsSection>
|
||||
|
||||
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
|
||||
<!-- ── Apparence ───────────────────────────────────────── -->
|
||||
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
|
||||
<ThemePicker />
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Jeton d'API ─────────────────────────────────────── -->
|
||||
<div data-tour="profile-token">
|
||||
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
|
||||
<div class="flex items-center gap-2.5 flex-wrap">
|
||||
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
|
||||
{{ showToken ? myToken : maskedToken }}
|
||||
</code>
|
||||
<button @click="showToken = !showToken" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" :aria-label="showToken ? 'Masquer le jeton' : 'Afficher le jeton'" :title="showToken ? 'Masquer' : 'Afficher'">
|
||||
<EyeOff v-if="showToken" :size="15" />
|
||||
<Eye v-else :size="15" />
|
||||
</button>
|
||||
<button @click="copyToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<Check v-if="tokenCopied" :size="15" class="text-ok" />
|
||||
<Copy v-else :size="15" />
|
||||
{{ tokenCopied ? 'Copié' : 'Copier' }}
|
||||
</button>
|
||||
<button @click="regenerateToken" class="btn-secondary pointer-fine:h-[38px]">
|
||||
<RefreshCw :size="15" /> Régénérer
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
|
||||
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer <jeton></code>.
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Check, Copy, Eye, EyeOff, RefreshCw } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import ThemePicker from '@/components/settings/ThemePicker.vue'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const me = ref<{ createdAt?: string; lastLoginAt?: string | null } | null>(null)
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
// ── Mot de passe ──────────────────────────────────────────────
|
||||
const currentPassword = ref('')
|
||||
@@ -141,20 +143,25 @@ async function changePassword() {
|
||||
changingPwd.value = false
|
||||
}
|
||||
|
||||
// ── Token API ─────────────────────────────────────────────────
|
||||
// ── Jeton d'API 2───────────────────────────────────────────────
|
||||
const myToken = ref('')
|
||||
const showToken = ref(false)
|
||||
const tokenCopied = ref(false)
|
||||
|
||||
async function loadMyToken() {
|
||||
const maskedToken = computed(() => myToken.value
|
||||
? `${myToken.value.slice(0, 4)}${'•'.repeat(24)}${myToken.value.slice(-4)}`
|
||||
: '•'.repeat(32))
|
||||
|
||||
async function loadMe() {
|
||||
const res = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
myToken.value = d.apiToken ?? ''
|
||||
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMyToken)
|
||||
onMounted(loadMe)
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
@@ -165,7 +172,7 @@ async function copyToken() {
|
||||
}
|
||||
|
||||
async function regenerateToken() {
|
||||
if (!confirm('Regénérer le token ? L\'ancien token sera immédiatement invalidé.')) return
|
||||
if (!confirm('Régénérer le jeton ? L\'ancien sera immédiatement invalidé.')) return
|
||||
const res = await fetch('/api/auth/regenerate-token', {
|
||||
method: 'POST', credentials: 'include',
|
||||
})
|
||||
|
||||
@@ -1,27 +1,82 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
|
||||
<RouterView />
|
||||
<div class="flex flex-col md:flex-row min-h-full">
|
||||
|
||||
<nav
|
||||
v-if="groups.length > 1"
|
||||
ref="navRef"
|
||||
data-tour="settings-menu"
|
||||
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
|
||||
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
|
||||
>
|
||||
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
|
||||
<div v-for="group in groups" :key="group.label" class="flex md:flex-col gap-1 md:gap-0.5 shrink-0">
|
||||
<span class="hidden md:block tag-label px-2.5 pb-2">{{ group.label }}</span>
|
||||
<RouterLink
|
||||
v-for="link in group.links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="flex items-center h-9 pointer-coarse:h-10 px-2.5 rounded-lg text-body whitespace-nowrap transition-colors"
|
||||
:class="route.path === link.to ? 'bg-hover text-primary font-semibold' : 'text-secondary hover:text-primary hover:bg-hover/60'"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1 min-w-0 px-4 md:px-10 pt-[26px] pb-16">
|
||||
<div class="max-w-[960px] flex flex-col gap-5">
|
||||
<header class="flex items-end justify-between gap-5 flex-wrap">
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<h2 class="font-display text-[26px] font-bold text-primary leading-tight">{{ page.title }}</h2>
|
||||
<p v-if="page.description" class="text-body text-muted">{{ page.description }}</p>
|
||||
</div>
|
||||
<div id="settings-actions" class="flex items-center gap-2.5 empty:hidden" />
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
'settings-profile' : 'Mon profil',
|
||||
'settings-download' : 'Clients de téléchargement',
|
||||
'settings-media' : 'Gestion des médias',
|
||||
'settings-catalogue' : 'Catalogue Fankai',
|
||||
'settings-logs' : 'Journaux',
|
||||
'settings-users' : 'Utilisateurs & Invitations',
|
||||
'settings-jellyfin' : 'Jellyfin & API',
|
||||
watch(() => route.path, async () => {
|
||||
await nextTick()
|
||||
const nav = navRef.value
|
||||
if (!nav || nav.scrollWidth <= nav.clientWidth) return
|
||||
nav.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({ inline: 'center', block: 'nearest' })
|
||||
}, { immediate: true })
|
||||
|
||||
const pages: Record<string, { title: string; description: string }> = {
|
||||
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
|
||||
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
|
||||
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
|
||||
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
|
||||
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
|
||||
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
|
||||
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
|
||||
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
|
||||
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
|
||||
</script>
|
||||
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
|
||||
|
||||
const groups = computed(() => {
|
||||
const link = (to: string) => ({ to, label: pages[to]!.title })
|
||||
if (!auth.isAdmin) return [{ label: 'Compte', links: [link('/settings/profile')] }]
|
||||
return [
|
||||
{ label: 'Compte', links: [link('/settings/profile'), link('/settings/users')] },
|
||||
{ label: 'Médias', links: [link('/settings/media-management'), link('/settings/import-management'), link('/settings/catalogue')] },
|
||||
{ label: 'Services', links: [link('/settings/download-client'), link('/settings/jellyfin')] },
|
||||
{ label: 'Système', links: [link('/settings/logs'), link('/settings/advanced')] },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
+245
-186
@@ -1,224 +1,226 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── Utilisateurs ─────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Utilisateurs</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez les comptes ayant accès à FanKarr.</p>
|
||||
</div>
|
||||
<button @click="openCreateUser" class="btn-secondary">+ Créer</button>
|
||||
</div>
|
||||
<Teleport defer to="#settings-actions">
|
||||
<button @click="openCreateInvite" class="btn-primary pointer-fine:h-[38px]">
|
||||
<Plus :size="15" :stroke-width="2.25" /> Créer une invitation
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div v-if="users.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun utilisateur.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="u in users" :key="u.id"
|
||||
class="settings-card flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>
|
||||
{{ u.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Utilisateur' }}
|
||||
· créé le {{ formatDate(u.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="openEditUser(u)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
|
||||
<button
|
||||
@click="confirmDelete(u)"
|
||||
:disabled="u.id === currentUserId"
|
||||
class="btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>Supprimer</button>
|
||||
</div>
|
||||
<!-- ── Comptes ────────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-meta text-muted hidden sm:inline">Le jeton sert à l'API publique et aux applications tierces.</span>
|
||||
<button @click="openCreateUser" class="btn-secondary btn-sm"><UserPlus :size="14" /> Créer un compte</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Invitations ──────────────────────────────────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Invitations</h2>
|
||||
<p class="text-sm text-muted mt-1">Générez des liens pour permettre à de nouveaux utilisateurs de s'inscrire.</p>
|
||||
</div>
|
||||
<button @click="openCreateInvite" class="btn-secondary">+ Générer</button>
|
||||
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
|
||||
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
|
||||
</div>
|
||||
|
||||
<div v-if="invites.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun lien d'invitation actif.
|
||||
</div>
|
||||
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="inv in invites" :key="inv.code"
|
||||
class="settings-card flex items-center justify-between gap-4"
|
||||
:class="{ 'opacity-50': isExpired(inv) || isExhausted(inv) }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span v-if="isExpired(inv) || isExhausted(inv)" class="text-xs text-muted">[Invalide]</span>
|
||||
<span v-if="inv.note" class="text-sm text-primary font-medium truncate">{{ inv.note }}</span>
|
||||
<span v-else class="text-sm text-muted italic">Sans note</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
{{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisations
|
||||
<template v-if="inv.expiresAt"> · expire {{ formatDate(inv.expiresAt) }}</template>
|
||||
<template v-else> · sans expiration</template>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1.5">
|
||||
<code class="text-xs text-muted bg-shell px-2 py-0.5 rounded truncate max-w-xs">
|
||||
{{ inviteUrl(inv.code) }}
|
||||
</code>
|
||||
<button @click="copyInvite(inv.code)" class="btn-ghost text-xs shrink-0">
|
||||
{{ copied === inv.code ? 'Copié !' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="u in users" :key="u.id"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center gap-x-3 gap-y-2 px-5 py-3 md:py-0 md:min-h-[58px] border-b border-hover last:border-b-0"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-8 h-8 rounded-full bg-hover flex items-center justify-center font-display text-[15px] font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">
|
||||
{{ u.username.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-px min-w-0">
|
||||
<span class="text-sm font-semibold text-primary truncate">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted truncate">{{ userSubtitle(u) }}</span>
|
||||
</div>
|
||||
<button @click="deleteInvite(inv.code)" class="btn-ghost text-xs text-red-400 hover:text-red-300 shrink-0">
|
||||
Révoquer
|
||||
</div>
|
||||
|
||||
<span class="pill w-fit order-3 md:order-none" :class="u.role === 'admin' ? 'pill-wait' : 'pill-neutral'">
|
||||
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
|
||||
</span>
|
||||
<span class="text-[13px] text-secondary order-4 md:order-none">
|
||||
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
|
||||
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
|
||||
<button @click="copyToken(u)" class="text-meta text-secondary hover:text-primary transition-colors shrink-0">
|
||||
{{ copied === `token-${u.id}` ? 'Copié' : 'Copier' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement automatique des demandes ──────────── -->
|
||||
<section class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Téléchargement automatique</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-4">
|
||||
<!-- Tous les utilisateurs -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadAll"
|
||||
@change="toggleAutoDownloadAll"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">Tous les utilisateurs</p>
|
||||
<p class="text-xs text-muted">Chaque approbation déclenche un téléchargement, quel que soit le demandeur.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div v-if="!autoDownloadAll && users.length > 0" class="border-t border-border/50" />
|
||||
|
||||
<!-- Par utilisateur -->
|
||||
<template v-if="!autoDownloadAll">
|
||||
<label
|
||||
v-for="u in users" :key="u.id"
|
||||
class="flex items-center gap-3 cursor-pointer"
|
||||
<div class="relative justify-self-end order-2 md:order-none" @click.stop>
|
||||
<button
|
||||
@click="userMenu = userMenu === u.id ? null : u.id"
|
||||
class="w-[30px] h-[30px] rounded-full flex items-center justify-center text-muted hover:text-primary hover:bg-hover transition-colors"
|
||||
:aria-label="`Actions pour ${u.username}`" aria-haspopup="menu" :aria-expanded="userMenu === u.id"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="autoDownloadUserIds.includes(u.id)"
|
||||
@change="toggleAutoDownloadUser(u.id)"
|
||||
class="w-4 h-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
|
||||
>{{ u.username.charAt(0).toUpperCase() }}</span>
|
||||
<span class="text-sm text-primary">{{ u.username }}</span>
|
||||
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<p v-if="users.length === 0" class="text-xs text-muted">Aucun utilisateur.</p>
|
||||
</template>
|
||||
|
||||
<p v-if="autoDownloadSaved" class="text-xs text-green-400">Paramètre sauvegardé.</p>
|
||||
<Ellipsis :size="16" />
|
||||
</button>
|
||||
<div v-if="userMenu === u.id" class="menu absolute right-0 top-full mt-1.5 w-48 z-20" role="menu">
|
||||
<button role="menuitem" class="menu-item" @click="userMenu = null; openEditUser(u)"><Pencil :size="14" /> Modifier</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
class="menu-item text-err hover:text-err"
|
||||
:disabled="u.id === currentUserId"
|
||||
:title="u.id === currentUserId ? 'Vous ne pouvez pas supprimer votre propre compte' : undefined"
|
||||
@click="userMenu = null; confirmDelete(u)"
|
||||
><Trash2 :size="14" /> Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ─────────────────── -->
|
||||
<!-- ── Invitations ────────────────────────────────────────── -->
|
||||
<section class="bg-card rounded-card">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
|
||||
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
|
||||
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
|
||||
</div>
|
||||
|
||||
<p v-if="invites.length === 0" class="text-body text-muted px-5 py-5">Aucun lien d'invitation. Créez-en un pour inviter quelqu'un.</p>
|
||||
|
||||
<div
|
||||
v-for="inv in invites" :key="inv.code"
|
||||
class="flex items-center gap-4 flex-wrap sm:flex-nowrap px-5 py-3.5 border-b border-hover last:border-b-0"
|
||||
>
|
||||
<span class="w-8 h-8 rounded-full border border-dashed flex items-center justify-center shrink-0 text-muted" :class="isInvalid(inv) ? 'border-border-light opacity-60' : 'border-border'">
|
||||
<Link2 :size="15" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
|
||||
<span class="text-body truncate" :class="isInvalid(inv) ? 'text-muted' : 'text-primary'">
|
||||
<template v-if="inv.note">{{ inv.note }} · </template>{{ inviteUrl(inv.code).replace(/^https?:\/\//, '') }}
|
||||
</span>
|
||||
<span class="text-meta text-muted">
|
||||
<template v-if="isExhausted(inv)">utilisations épuisées ({{ inv.uses }}/{{ inv.maxUses }})</template>
|
||||
<template v-else-if="isExpired(inv)">expirée {{ formatRelative(inv.expiresAt!) }}</template>
|
||||
<template v-else>
|
||||
créée {{ formatRelative(inv.createdAt) }} · {{ inv.uses }}/{{ inv.maxUses ?? '∞' }} utilisation{{ (inv.maxUses ?? 2) > 1 ? 's' : '' }}
|
||||
· {{ inv.expiresAt ? `expire ${formatFuture(inv.expiresAt)}` : 'sans expiration' }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<button v-if="!isInvalid(inv)" @click="copyInvite(inv.code)" class="btn-secondary btn-sm">
|
||||
<Check v-if="copied === inv.code" :size="14" class="text-ok" />
|
||||
<Copy v-else :size="14" />
|
||||
{{ copied === inv.code ? 'Lien copié' : 'Copier le lien' }}
|
||||
</button>
|
||||
<button
|
||||
@click="deleteInvite(inv.code)"
|
||||
class="w-8 h-8 rounded-full border border-border-light text-muted flex items-center justify-center hover:text-err hover:border-err/30 transition-colors"
|
||||
title="Révoquer le lien" aria-label="Révoquer le lien"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Téléchargement auto ────────────── -->
|
||||
<SettingsSection
|
||||
title="Téléchargement automatique"
|
||||
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
|
||||
>
|
||||
<template #actions>
|
||||
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
|
||||
</template>
|
||||
|
||||
<SettingsToggle
|
||||
:model-value="autoDownloadAll"
|
||||
@update:model-value="setAutoDownloadAll"
|
||||
label="Tous les utilisateurs"
|
||||
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
|
||||
/>
|
||||
|
||||
<template v-if="!autoDownloadAll">
|
||||
<div class="h-px bg-hover" />
|
||||
<div v-for="u in users" :key="u.id" class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="w-6 h-6 rounded-full bg-hover flex items-center justify-center font-display text-xs font-bold shrink-0" :class="u.role === 'admin' ? 'text-accent' : 'text-secondary'">{{ u.username.charAt(0).toUpperCase() }}</span>
|
||||
<span class="text-body text-primary truncate">{{ u.username }}</span>
|
||||
<span class="text-meta text-muted">{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button" role="switch"
|
||||
:aria-checked="autoDownloadUserIds.includes(u.id)"
|
||||
:aria-label="`Téléchargement automatique pour ${u.username}`"
|
||||
@click="toggleAutoDownloadUser(u.id)"
|
||||
class="switch"
|
||||
><span class="switch-knob" /></button>
|
||||
</div>
|
||||
<p v-if="users.length === 0" class="text-meta text-muted">Aucun utilisateur.</p>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
|
||||
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="userModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="userModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">
|
||||
{{ userModal.mode === 'create' ? 'Créer un utilisateur' : 'Modifier l\'utilisateur' }}
|
||||
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
|
||||
<h3 id="user-modal-title" class="card-title">
|
||||
{{ userModal.mode === 'create' ? 'Créer un compte' : 'Modifier le compte' }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="userModal.username" type="text" class="settings-input" placeholder="username" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-name" class="field-label">Identifiant</label>
|
||||
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">
|
||||
{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}
|
||||
</label>
|
||||
<input v-model="userModal.password" type="password" class="settings-input"
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
|
||||
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
|
||||
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Rôle</label>
|
||||
<select v-model="userModal.role" class="settings-input">
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<span class="field-label">Rôle</span>
|
||||
<div class="segmented w-fit">
|
||||
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'user' }" @click="userModal.role = 'user'">Invité</button>
|
||||
<button type="button" class="segmented-item" :class="{ 'is-active': userModal.role === 'admin' }" @click="userModal.role = 'admin'">Administrateur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="userModal.error" class="text-xs text-red-400">{{ userModal.error }}</p>
|
||||
<p v-if="userModal.error" class="text-meta text-err">{{ userModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="userModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
|
||||
{{ userModal.loading ? '...' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Modal créer invitation ────────────────────────────── -->
|
||||
<!-- ── Modal créer invitation ─────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="inviteModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center px-4" @click.self="inviteModal = null">
|
||||
<div class="settings-card w-full max-w-sm flex flex-col gap-5 p-6">
|
||||
<h3 class="text-base font-semibold text-primary">Générer une invitation</h3>
|
||||
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
|
||||
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
|
||||
<h3 id="invite-modal-title" class="card-title">Créer une invitation</h3>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Note (optionnelle)</label>
|
||||
<input v-model="inviteModal.note" type="text" class="settings-input" placeholder="Pour qui est ce lien ?" />
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-note" class="field-label">Note (facultative)</label>
|
||||
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Utilisations max</label>
|
||||
<input v-model.number="inviteModal.maxUses" type="number" min="1" class="settings-input" placeholder="1" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour illimité</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label mb-1.5">Expire dans (heures)</label>
|
||||
<input v-model.number="inviteModal.expiresInHours" type="number" min="1" class="settings-input" placeholder="24" />
|
||||
<p class="text-xs text-muted mt-1">Laisser vide pour pas d'expiration</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-uses" class="field-label">Utilisations max</label>
|
||||
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="invite-hours" class="field-label">Expire dans (heures)</label>
|
||||
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
|
||||
</div>
|
||||
|
||||
<p v-if="inviteModal.error" class="text-xs text-red-400">{{ inviteModal.error }}</p>
|
||||
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="flex gap-2.5 justify-end">
|
||||
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
|
||||
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
|
||||
{{ inviteModal.loading ? '...' : 'Générer' }}
|
||||
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,16 +231,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Check, Copy, Ellipsis, Link2, Pencil, Plus, Trash2, UserPlus } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { copyToClipboard } from '@/composables/useClipboard'
|
||||
import { formatRelative, plural } from '@/utils/format'
|
||||
import SettingsSection from '@/components/settings/SettingsSection.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
|
||||
interface User {
|
||||
id : string
|
||||
username : string
|
||||
role : 'admin' | 'user'
|
||||
apiToken : string
|
||||
createdAt: string
|
||||
id : string
|
||||
username : string
|
||||
role : 'admin' | 'user'
|
||||
apiToken : string
|
||||
createdAt : string
|
||||
lastLoginAt?: string | null
|
||||
}
|
||||
|
||||
interface Invite {
|
||||
@@ -252,11 +259,13 @@ interface Invite {
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const currentUserId = auth.username // utilisé pour désactiver la suppression de soi-même
|
||||
const currentUserId = computed(() => auth.userId) // pas de suppression de son propre compte
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const invites = ref<Invite[]>([])
|
||||
const copied = ref<string | null>(null)
|
||||
const pendingByUser = ref<Map<string, number>>(new Map())
|
||||
const userMenu = ref<string | null>(null)
|
||||
|
||||
// ── Téléchargement auto des demandes ──────────────────────────
|
||||
const autoDownloadAll = ref(false)
|
||||
@@ -294,6 +303,16 @@ async function loadInvites() {
|
||||
if (res.ok) invites.value = await res.json()
|
||||
}
|
||||
|
||||
async function loadPendingRequests() {
|
||||
const res = await fetch('/api/requests', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const counts = new Map<string, number>()
|
||||
for (const r of await res.json())
|
||||
if (r.status === 'pending')
|
||||
for (const q of r.requesters) counts.set(q.userId, (counts.get(q.userId) ?? 0) + 1)
|
||||
pendingByUser.value = counts
|
||||
}
|
||||
|
||||
async function loadAutoDownloadSetting() {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
@@ -321,8 +340,8 @@ async function saveAutoDownloadSetting() {
|
||||
autoDownloadTimer = setTimeout(() => { autoDownloadSaved.value = false }, 2000)
|
||||
}
|
||||
|
||||
function toggleAutoDownloadAll(e: Event) {
|
||||
autoDownloadAll.value = (e.target as HTMLInputElement).checked
|
||||
function setAutoDownloadAll(value: boolean) {
|
||||
autoDownloadAll.value = value
|
||||
if (autoDownloadAll.value) autoDownloadUserIds.value = []
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
@@ -334,7 +353,13 @@ function toggleAutoDownloadUser(userId: string) {
|
||||
saveAutoDownloadSetting()
|
||||
}
|
||||
|
||||
onMounted(() => { loadUsers(); loadInvites(); loadAutoDownloadSetting() })
|
||||
const closeUserMenu = () => { userMenu.value = null }
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers(); loadInvites(); loadAutoDownloadSetting(); loadPendingRequests()
|
||||
document.addEventListener('click', closeUserMenu)
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────
|
||||
function openCreateUser() {
|
||||
@@ -373,7 +398,30 @@ async function confirmDelete(u: User) {
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
function userSubtitle(u: User) {
|
||||
const pending = pendingByUser.value.get(u.id) ?? 0
|
||||
const parts: string[] = []
|
||||
if (u.id === currentUserId.value) parts.push('vous')
|
||||
if (pending > 0) parts.push(`${plural(pending, 'demande')} en attente`)
|
||||
if (parts.length === 0) parts.push(`créé le ${formatDate(u.createdAt)}`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function maskToken(token: string) {
|
||||
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
|
||||
}
|
||||
|
||||
async function copyToken(u: User) {
|
||||
try {
|
||||
await copyToClipboard(u.apiToken)
|
||||
copied.value = `token-${u.id}`
|
||||
setTimeout(() => { copied.value = null }, 2000)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Invites ───────────────────────────────────────────────────
|
||||
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
|
||||
|
||||
function openCreateInvite() {
|
||||
inviteModal.value = { note: '', maxUses: 1, expiresInHours: 24, error: null, loading: false }
|
||||
}
|
||||
@@ -416,7 +464,14 @@ async function copyInvite(code: string) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
}
|
||||
|
||||
// « dans 5 j », « dans 3 h »
|
||||
function formatFuture(iso: string) {
|
||||
const diff = new Date(iso).getTime() - Date.now()
|
||||
const h = Math.round(diff / 3_600_000)
|
||||
return h < 24 ? `dans ${Math.max(1, h)} h` : `dans ${Math.round(h / 24)} j`
|
||||
}
|
||||
|
||||
function isExpired(inv: Invite) {
|
||||
@@ -426,4 +481,8 @@ function isExpired(inv: Invite) {
|
||||
function isExhausted(inv: Invite) {
|
||||
return inv.maxUses !== null && inv.uses >= inv.maxUses
|
||||
}
|
||||
|
||||
function isInvalid(inv: Invite) {
|
||||
return isExpired(inv) || isExhausted(inv)
|
||||
}
|
||||
</script>
|
||||
Reference in new issue
Block a user