refactor: refactor sur server et les pages qui commencer à faire beaucoup trop de ligne
This commit is contained in:
1 parent
609ddc777f
commit
bf2274b761
25 files changed
+2769
-2725
No files matched your search
@@ -0,0 +1,122 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
<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'">
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</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" />
|
||||
{{ col.label }}
|
||||
</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 { 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
|
||||
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:hideImported': [value: boolean]
|
||||
'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)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
onClickOutside(colRef, () => { colOpen.value = false })
|
||||
|
||||
function handleSort(val: string) {
|
||||
emit('sort', val)
|
||||
sortOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
|
||||
<!-- Nom + état -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</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>
|
||||
</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>
|
||||
<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">
|
||||
{{ importing ? '...' : '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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
torrent : any
|
||||
tab : 'active' | 'done'
|
||||
columns : Record<string, boolean>
|
||||
importing : boolean
|
||||
deleting : boolean
|
||||
showConfirmDelete: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
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)))
|
||||
|
||||
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`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<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>
|
||||
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-32 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">
|
||||
<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>
|
||||
|
||||
<!-- Titre + boutons actions -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ serie.title }}</h1>
|
||||
|
||||
<button
|
||||
@click="emit('openManualImport')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition shrink-0"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div v-if="Object.keys(organizedByEpisode).length > 0" class="relative" @click.stop>
|
||||
<button
|
||||
@click="unimportMenuOpen = !unimportMenuOpen"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-500/40 transition shrink-0"
|
||||
>
|
||||
<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>
|
||||
Désimporter
|
||||
</button>
|
||||
<div v-if="unimportMenuOpen" class="absolute top-full left-0 mt-1 bg-card border border-border rounded-xl p-1 z-20 w-48 shadow-xl flex flex-col gap-0.5">
|
||||
<button @click="emit('unimport', false); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
|
||||
Retirer de la bibliothèque
|
||||
</button>
|
||||
<button @click="emit('unimport', true); unimportMenuOpen = false" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
|
||||
Supprimer les fichiers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<p v-if="serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Boutons intégrale / tout télécharger -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
v-if="hasDownloadableTorrents"
|
||||
@click="emit('downloadAll')"
|
||||
:disabled="downloadingAll"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-accent text-white hover:bg-accent-hover transition"
|
||||
:class="downloadingAll ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
>
|
||||
<svg width="14" height="14" 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' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="(t, i) in torrentsIntegrale"
|
||||
:key="i"
|
||||
:title="t.raw"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
|
||||
(isAlreadyQueued(t) || isDownloaded(`integrale-${i}`))
|
||||
? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
:disabled="isAlreadyQueued(t) || isDownloaded(`integrale-${i}`)"
|
||||
@click="emit('download', `integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
>
|
||||
<component :is="btnIcon(`integrale-${i}`, t)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression intégrale -->
|
||||
<template v-for="(t, i) in torrentsIntegrale" :key="`prog-${i}`">
|
||||
<div v-if="torrentProgress(extractHash(t)) && torrentProgress(extractHash(t))!.progress < 100" class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full bg-accent rounded-full transition-all duration-500" :style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ArrowLeft, Tv, Download, Loader, Check } from 'lucide-vue-next'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string }
|
||||
|
||||
const props = defineProps<{
|
||||
serie : any
|
||||
torrentsIntegrale : any[]
|
||||
organizedByEpisode : Record<string, any>
|
||||
hasDownloadableTorrents: boolean
|
||||
downloadingAll : boolean
|
||||
activeTorrents : ActiveTorrent[]
|
||||
downloading : string[]
|
||||
downloaded : string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
openManualImport: []
|
||||
unimport : [deleteFile: boolean]
|
||||
downloadAll : []
|
||||
download : [key: string, url: string | null, magnet: string | null]
|
||||
}>()
|
||||
|
||||
const unimportMenuOpen = ref(false)
|
||||
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
function extractHash(torrent: any): string | null {
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
function btnIcon(key: string, torrent: any) {
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return h(Check, { size: 14 })
|
||||
return h(Download, { size: 14 })
|
||||
}
|
||||
|
||||
defineExpose({ closeUnimportMenu: () => { unimportMenuOpen.value = false } })
|
||||
</script>
|
||||
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Boutons saison -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="!season.torrent && hasDownloadable"
|
||||
@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"
|
||||
>
|
||||
<component :is="downloadingSeason ? loaderIcon : downloadIcon" />
|
||||
{{ downloadingSeason ? 'Envoi…' : 'Tout télécharger' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
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'"
|
||||
:disabled="!canDownloadSeason"
|
||||
@click="canDownloadSeason && emit('download', `season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<component :is="seasonBtnIcon" />
|
||||
{{ seasonBtnLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progression saison -->
|
||||
<div v-if="seasonProgress && seasonProgress.progress < 100" class="px-5 pb-3 -mt-1">
|
||||
<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: `${seasonProgress.progress}%` }" />
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ seasonProgress.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsed" class="divide-y divide-border/50">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="px-5 py-3 hover:bg-hover/50 transition-colors"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<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" />
|
||||
<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">
|
||||
{{ 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
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton état épisode -->
|
||||
<button
|
||||
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
|
||||
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && emit('download', `ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<component :is="epStateIcon(ep)" />
|
||||
</button>
|
||||
|
||||
<!-- Actions épisode importé -->
|
||||
<template v-if="ep.organized && organizedByEpisode[String(ep.id)]">
|
||||
<span
|
||||
v-if="epNeedsRename(ep)"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-yellow-500/10 text-yellow-400 border-yellow-500/20 cursor-default"
|
||||
:title="`Actuel : ${organizedByEpisode[String(ep.id)]?.dest_filename}\nAttendu : ${epExpectedName(ep)}`"
|
||||
>Rename</span>
|
||||
|
||||
<div class="relative" @click.stop>
|
||||
<button
|
||||
@click="epMenuOpen = epMenuOpen === ep.id ? null : ep.id"
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
|
||||
</button>
|
||||
<div v-if="epMenuOpen === ep.id" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-44 shadow-xl flex flex-col gap-0.5">
|
||||
<button v-if="epNeedsRename(ep)" @click="emit('renameEpisode', ep, season); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
Renommer
|
||||
</button>
|
||||
<button @click="emit('unimportEpisode', ep, season, false); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-muted hover:bg-hover transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
|
||||
Désimporter
|
||||
</button>
|
||||
<button @click="emit('unimportEpisode', ep, season, true); epMenuOpen = null" :disabled="epActionLoading[ep.id]" class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-red-400 hover:bg-red-500/10 transition-colors">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>
|
||||
Supprimer le fichier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string }
|
||||
|
||||
const props = defineProps<{
|
||||
season : any
|
||||
collapsed : boolean
|
||||
activeTorrents : ActiveTorrent[]
|
||||
downloading : string[]
|
||||
downloaded : string[]
|
||||
organizedByEpisode: Record<string, any>
|
||||
epActionLoading : Record<number, boolean>
|
||||
downloadingSeason ?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle : [id: number]
|
||||
download : [key: string, url: string | null, magnet: string | null]
|
||||
downloadSeason : [season: any]
|
||||
renameEpisode : [ep: any, season: any]
|
||||
unimportEpisode: [ep: any, season: any, deleteFile: boolean]
|
||||
}>()
|
||||
|
||||
const epMenuOpen = ref<number | null>(null)
|
||||
const downloadIcon = h(Download, { size: 12 })
|
||||
const loaderIcon = h(Loader, { size: 12, class: 'animate-spin' })
|
||||
|
||||
// ── Helpers hash / progress ────────────────────────────────────
|
||||
function extractHash(torrent: any): string | null {
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return props.activeTorrents.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
function isDownloading(key: string) { return props.downloading.includes(key) }
|
||||
function isDownloaded(key: string) { return props.downloaded.includes(key) }
|
||||
function isAlreadyQueued(torrent: any): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return props.activeTorrents.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => torrentProgress(extractHash(props.season.torrent)))
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
)
|
||||
)
|
||||
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'
|
||||
})
|
||||
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 })
|
||||
})
|
||||
|
||||
// ── État épisode ───────────────────────────────────────────────
|
||||
function epProgress(ep: any) { return torrentProgress(extractHash(ep.torrent)) }
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
if (ep.organized || isDownloaded(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent)) return 'done'
|
||||
const prog = epProgress(ep)
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
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)]
|
||||
if (!entry) return ''
|
||||
const srcExt = entry.dest_filename ? '.' + entry.dest_filename.split('.').pop() : '.mkv'
|
||||
if (ep.nfo_filename) return ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt
|
||||
if (ep.formatted_name?.trim()) return ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt
|
||||
return entry.dest_filename
|
||||
}
|
||||
function epNeedsRename(ep: any): boolean {
|
||||
const entry = props.organizedByEpisode[String(ep.id)]
|
||||
if (!entry) return false
|
||||
const expected = epExpectedName(ep)
|
||||
return !!expected && expected !== entry.dest_filename
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
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`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
defineExpose({ closeEpMenu: () => { epMenuOpen.value = null } })
|
||||
</script>
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, SlidersHorizontal, ChevronDown } 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 }[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search' : [value: string]
|
||||
'update:activeFilter': [value: string]
|
||||
'update:activeSort' : [value: string]
|
||||
'update:posterSize' : [value: string]
|
||||
}>()
|
||||
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
</script>
|
||||
@@ -0,0 +1,304 @@
|
||||
<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">
|
||||
|
||||
<!-- 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">Configuration Plex</h3>
|
||||
<p class="text-xs 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' :
|
||||
plexStep === 'done' ? 'Configuration terminée' :
|
||||
'Erreur' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="emit('close')" class="text-muted hover:text-primary text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Auth -->
|
||||
<div v-if="plexStep === 'auth'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- OAuth en attente -->
|
||||
<div v-else-if="plexAuthMethod === 'oauth'" class="flex flex-col items-center gap-4 py-4 text-center">
|
||||
<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>
|
||||
</div>
|
||||
<button @click="cancelOAuth" class="text-xs text-muted hover:text-primary transition-colors">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" />
|
||||
</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" />
|
||||
</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" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="plexConnect" :disabled="plexLoading || !plexForm.username || !plexForm.password" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
<button @click="plexAuthMethod = null" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="plexError && !plexAuthMethod" class="text-xs text-red-400">{{ 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>
|
||||
<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'"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ plexError }}</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button @click="plexConnectServer" :disabled="!plexSelectedServer || plexLoading" class="btn-primary">
|
||||
{{ plexLoading ? 'Connexion…' : 'Continuer' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'auth'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- É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" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-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" />
|
||||
</div>
|
||||
<p v-if="plexError" class="text-xs text-red-400">{{ 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' }}
|
||||
</button>
|
||||
<button @click="plexStep = 'server'" class="btn-secondary">Retour</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Étape 4 : Résultat -->
|
||||
<div v-else-if="plexStep === 'done'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<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>
|
||||
</div>
|
||||
<p class="text-xs" :class="s.ok ? 'text-primary' : 'text-red-400'">{{ 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>
|
||||
<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>
|
||||
<li>Dans <span class="text-primary">Metadata Agent</span>, créez un agent avec ce provider</li>
|
||||
<li>Dans votre bibliothèque → ⋯ → <span class="text-primary">Manage Library → Edit → Advanced → Agent</span>, sélectionnez l'agent Fankai</li>
|
||||
<li>Rafraîchissez les métadonnées</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button @click="emit('close')" class="btn-primary">Fermer</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{ mediaPath: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const plexStep = ref<'auth' | 'server' | 'library' | 'done'>('auth')
|
||||
const plexLoading = ref(false)
|
||||
const plexError = ref('')
|
||||
const plexNeeds2FA = ref(false)
|
||||
const plexToken = ref('')
|
||||
const plexServers = ref<any[]>([])
|
||||
const plexSelectedServer = ref<any>(null)
|
||||
const plexServerUrl = ref('')
|
||||
const plexManualSetup = ref(false)
|
||||
const plexSteps = ref<{ step: string; ok: boolean; message: string }[]>([])
|
||||
const plexAuthMethod = ref<'oauth' | 'credentials' | null>(null)
|
||||
const plexOAuthPinId = ref<string | null>(null)
|
||||
let plexOAuthTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const plexForm = ref({
|
||||
username : '',
|
||||
password : '',
|
||||
code : '',
|
||||
libraryName: 'Fankai',
|
||||
libraryPath: props.mediaPath,
|
||||
})
|
||||
|
||||
// ── OAuth ─────────────────────────────────────────────────────
|
||||
async function startOAuth() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/oauth/start', { method: 'POST', credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { plexError.value = data.error ?? 'Erreur OAuth'; return }
|
||||
plexOAuthPinId.value = String(data.pinId)
|
||||
plexAuthMethod.value = 'oauth'
|
||||
window.open(data.authUrl, '_blank')
|
||||
let attempts = 0
|
||||
plexOAuthTimer = setInterval(async () => {
|
||||
attempts++
|
||||
if (attempts > 150) { cancelOAuth(); plexError.value = 'Délai expiré — réessayez'; return }
|
||||
try {
|
||||
const r = await fetch(`/api/plex/oauth/poll/${plexOAuthPinId.value}`, { credentials: 'include' })
|
||||
const d = await r.json()
|
||||
if (d.ok && !d.pending) {
|
||||
cancelOAuth()
|
||||
plexToken.value = d.token
|
||||
plexServers.value = d.servers
|
||||
if (d.servers.length === 1) { plexSelectedServer.value = d.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
}
|
||||
} catch {}
|
||||
}, 2000)
|
||||
} catch {
|
||||
plexError.value = "Impossible de démarrer l'authentification"
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOAuth() {
|
||||
if (plexOAuthTimer) { clearInterval(plexOAuthTimer); plexOAuthTimer = null }
|
||||
plexOAuthPinId.value = null
|
||||
if (plexAuthMethod.value === 'oauth') plexAuthMethod.value = null
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────
|
||||
async function plexConnect() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/connect', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ username: plexForm.value.username, password: plexForm.value.password, code: plexForm.value.code || undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
if (data.requires2FA) { plexNeeds2FA.value = true; plexError.value = 'Entrez votre code 2FA' }
|
||||
else plexError.value = data.error ?? 'Erreur de connexion'
|
||||
return
|
||||
}
|
||||
plexToken.value = data.token
|
||||
plexServers.value = data.servers
|
||||
if (data.servers.length === 1) { plexSelectedServer.value = data.servers[0]; await plexConnectServer() }
|
||||
else plexStep.value = 'server'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectServer(server: any) { plexSelectedServer.value = server }
|
||||
|
||||
async function plexConnectServer() {
|
||||
if (!plexSelectedServer.value) return
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const conns = plexSelectedServer.value.connections ?? []
|
||||
const conn = conns.find((c: any) => c.local && !c.relay) ?? conns.find((c: any) => !c.local && !c.relay) ?? conns.find((c: any) => c.relay)
|
||||
plexServerUrl.value = conn?.uri ?? ''
|
||||
if (!plexServerUrl.value) throw new Error('Aucune URL de connexion disponible')
|
||||
plexForm.value.libraryPath = props.mediaPath
|
||||
plexStep.value = 'library'
|
||||
} catch (err) {
|
||||
plexError.value = err instanceof Error ? err.message : 'Erreur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function plexSetup() {
|
||||
plexError.value = ''
|
||||
plexLoading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/plex/setup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ token: plexToken.value, serverUrl: plexServerUrl.value, libraryName: plexForm.value.libraryName, libraryPath: plexForm.value.libraryPath }),
|
||||
})
|
||||
const data = await res.json()
|
||||
plexSteps.value = data.steps ?? []
|
||||
plexManualSetup.value = data.manualSetup ?? false
|
||||
plexStep.value = 'done'
|
||||
if (!data.ok) plexError.value = data.error ?? 'Erreur lors du setup'
|
||||
} catch {
|
||||
plexError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
plexLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => cancelOAuth())
|
||||
</script>
|
||||
Reference in new issue
Block a user