refacto: réécriture des textes, messages et commentaires
This commit is contained in:
1 parent
6a3cb21adf
commit
586eccbbb3
118 files changed
+1621
-2159
No files matched your search
@@ -3,47 +3,42 @@
|
||||
<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">
|
||||
|
||||
<!-- 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>
|
||||
<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="btn-icon btn-sm border-transparent" aria-label="Fermer"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<!-- 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-body">Scan du dossier…</p>
|
||||
<p class="text-body">Analyse 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-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 btn-sm mt-2"><RefreshCw :size="14" /> Rescanner</button>
|
||||
<button @click="scan" class="btn-secondary btn-sm mt-2"><RefreshCw :size="14" /> Relancer l'analyse</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-body text-err">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary btn-sm">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste des fichiers -->
|
||||
<template v-else-if="items.length > 0 && step !== 'done'">
|
||||
|
||||
<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>
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-ok">{{ plural(alreadyImportedCount, 'déjà importé', 'déjà importés') }}</span></span>
|
||||
· <span class="text-accent">{{ plural(newMatchedCount, 'nouveau', 'nouveaux') }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · {{ plural(unmatchedCount, 'non associé', 'non associés') }}</span>
|
||||
</p>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Rescanner</button>
|
||||
<button @click="scan" class="text-meta text-secondary hover:text-primary transition-colors flex items-center gap-1.5"><RefreshCw :size="13" /> Relancer l'analyse</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
@@ -80,7 +75,7 @@
|
||||
@change="onEpisodeChange(i)"
|
||||
:aria-label="`Épisode associé à ${item.file.name}`"
|
||||
>
|
||||
<option :value="null">— Non associé —</option>
|
||||
<option :value="null">Aucun épisode</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
<option
|
||||
v-for="ep in season.episodes"
|
||||
@@ -88,8 +83,8 @@
|
||||
:value="ep.id"
|
||||
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
|
||||
>
|
||||
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
|
||||
E{{ String(ep.episode_number).padStart(2, '0') }} · {{ ep.title || `Épisode ${ep.episode_number}` }}
|
||||
{{ isEpisodeOrganized(ep.id) ? '(déjà importé)' : '' }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
@@ -108,14 +103,13 @@
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
|
||||
{{ importing ? 'Import…' : `Importer ${plural(newMatchedCount, 'fichier')}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Résultat -->
|
||||
<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"
|
||||
@@ -127,16 +121,16 @@
|
||||
<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' : '' }}
|
||||
{{ plural(importResult.done, 'fichier importé', 'fichiers importés') }}
|
||||
<span v-if="importResult.errors.length > 0" class="text-err">
|
||||
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
|
||||
· {{ plural(importResult.errors.length, 'erreur') }}
|
||||
</span>
|
||||
</p>
|
||||
</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 }}
|
||||
<span class="text-primary">{{ e.file }}</span> : {{ e.error }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2.5">
|
||||
@@ -155,6 +149,7 @@ 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'
|
||||
import { formatSize, plural } from '@/utils/format'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -171,7 +166,7 @@ const emit = defineEmits<{
|
||||
imported: []
|
||||
}>()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
// ── État ──
|
||||
const step = ref<'list' | 'done'>('list')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
@@ -189,7 +184,7 @@ const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
originalEpisodeId : number | null // ep_id au moment du scan (immuable) — sert au serveur pour cleanup
|
||||
originalEpisodeId : number | null // épisode auquel le fichier était déjà associé lors de l'analyse
|
||||
hash : string | null
|
||||
alreadyImported : boolean
|
||||
willRename : boolean
|
||||
@@ -197,12 +192,12 @@ interface FileItem {
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
// ── Calculs ──
|
||||
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
|
||||
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
// ── Utilitaires ──
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
return !!props.organized?.[String(epId)]
|
||||
}
|
||||
@@ -236,13 +231,6 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
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 onEpisodeChange(i: number) {
|
||||
const item = items.value[i]
|
||||
if (!item) return
|
||||
@@ -252,8 +240,7 @@ function onEpisodeChange(i: number) {
|
||||
item.alreadyImported = false
|
||||
item.willRename = newEpId !== null
|
||||
|
||||
// Retirer les autres fichiers "déjà importés" pour ce même épisode :
|
||||
// l'utilisateur a choisi de les remplacer par ce fichier-ci.
|
||||
// Le fichier choisi remplace ceux déjà importés pour cet épisode
|
||||
if (newEpId !== null) {
|
||||
items.value = items.value.filter(other =>
|
||||
other === item || !(other.episode_id === newEpId && other.alreadyImported)
|
||||
@@ -261,7 +248,7 @@ function onEpisodeChange(i: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
// ── Actions ──
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
@@ -271,7 +258,7 @@ async function scan() {
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
scanError.value = data.error ?? 'Erreur lors du scan'
|
||||
scanError.value = data.error ?? "Impossible d'analyser le dossier."
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
@@ -281,7 +268,6 @@ async function scan() {
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
items.value = files.map(f => {
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return { file: f, episode_id: existingEpId, originalEpisodeId: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
@@ -291,7 +277,7 @@ async function scan() {
|
||||
return { file: f, ...match, originalEpisodeId: null, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
} catch {
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
scanError.value = 'Impossible de contacter le serveur.'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
@@ -306,7 +292,7 @@ async function doImport() {
|
||||
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
|
||||
|
||||
if (payload.length === 0) {
|
||||
importError.value = 'Aucun nouveau fichier à importer'
|
||||
importError.value = 'Aucun nouveau fichier à importer.'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
@@ -320,26 +306,26 @@ async function doImport() {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
importError.value = data.error ?? 'Erreur inconnue'
|
||||
toast(data.error ?? 'Erreur lors de l\'import', 'error')
|
||||
importError.value = data.error ?? "Impossible d'importer les fichiers."
|
||||
toast(data.error ?? "Impossible d'importer les fichiers", 'error')
|
||||
return
|
||||
}
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
|
||||
if (data.done > 0) {
|
||||
toast(`${data.done} fichier${data.done > 1 ? 's' : ''} importé${data.done > 1 ? 's' : ''} ✓`, 'success')
|
||||
toast(plural(data.done, 'fichier importé', 'fichiers importés'), 'success')
|
||||
}
|
||||
if ((data.errors ?? []).length > 0) {
|
||||
toast(`${data.errors.length} fichier${data.errors.length > 1 ? 's' : ''} en erreur`, 'error')
|
||||
toast(`${plural(data.errors.length, 'fichier')} en erreur`, 'error')
|
||||
}
|
||||
|
||||
// Notifier le parent immédiatement (rafraîchit organizedByEpisode + page série)
|
||||
// Le parent recharge les épisodes importés
|
||||
emit('imported')
|
||||
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
importError.value = 'Impossible de contacter le serveur'
|
||||
importError.value = 'Impossible de contacter le serveur.'
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
importing.value = false
|
||||
@@ -348,7 +334,7 @@ async function doImport() {
|
||||
|
||||
function backToList() {
|
||||
step.value = 'list'
|
||||
scan() // re-scan → les fichiers importés passent en "déjà importé"
|
||||
scan() // nouvelle analyse pour marquer les fichiers importés
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
|
||||
Reference in new issue
Block a user