feat: analyse import + all tests fixed
This commit is contained in:
parent
fbe9619224
commit
3170a7c60e
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<NotificationToast />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
<script setup>
|
||||
import NotificationToast from './shared/components/ui/NotificationToast.vue';
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -18,4 +17,4 @@ export default {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
195
assets/vue/app/domain/import/README.md
Normal file
195
assets/vue/app/domain/import/README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Domaine Import - Analyse et Import de Fichiers CBZ/CBR
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce domaine permet l'import de fichiers CBZ/CBR dans Mangarr en utilisant l'analyse intelligente de noms de fichiers pour trouver automatiquement les correspondances avec les mangas de la bibliothèque.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Structure des Dossiers
|
||||
|
||||
```
|
||||
domain/import/
|
||||
├── domain/
|
||||
│ └── entities/
|
||||
│ └── FileImport.js # Entité représentant un fichier à importer
|
||||
├── infrastructure/
|
||||
│ └── api/
|
||||
│ └── apiImportRepository.js # Client API
|
||||
├── application/
|
||||
│ └── store/
|
||||
│ └── newImportStore.js # Store Pinia principal
|
||||
└── presentation/
|
||||
├── pages/
|
||||
│ └── NewImportPage.vue # Page principale d'import
|
||||
└── components/
|
||||
├── FileImportCard.vue # Carte de fichier à importer
|
||||
├── ImportResults.vue # Résumé des résultats
|
||||
└── StatusBadge.vue # Badge de statut
|
||||
```
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### 1. Upload de Fichiers
|
||||
|
||||
- **Drag & Drop** : Support du glisser-déposer pour les fichiers CBZ/CBR
|
||||
- **Sélection multiple** : Import de plusieurs fichiers simultanément
|
||||
- **Validation** : Vérification automatique des formats acceptés
|
||||
|
||||
### 2. Analyse Intelligente
|
||||
|
||||
- **Extraction automatique** : Le système analyse le nom de fichier pour extraire :
|
||||
- Le titre du manga
|
||||
- Le numéro de chapitre (si présent)
|
||||
- Le numéro de volume (si présent)
|
||||
|
||||
- **Correspondance automatique** :
|
||||
- Recherche des mangas correspondants dans la bibliothèque
|
||||
- Score de correspondance pour chaque résultat
|
||||
- Sélection automatique du meilleur match
|
||||
|
||||
### 3. Sélection et Validation
|
||||
|
||||
- **Sélection de manga** : Dropdown avec tous les mangas correspondants et leur score
|
||||
- **Prévisualisation** : Affichage de la couverture et des informations du manga sélectionné
|
||||
- **Édition des numéros** : Possibilité de modifier les numéros de chapitre/volume extraits
|
||||
- **Exclusivité** : Un fichier ne peut être importé que comme chapitre OU volume (pas les deux)
|
||||
|
||||
### 4. Import
|
||||
|
||||
- **Import unitaire** : Import fichier par fichier
|
||||
- **Import groupé** : Import de tous les fichiers prêts en une seule fois
|
||||
- **Retry** : Possibilité de réessayer en cas d'erreur
|
||||
- **Suivi en temps réel** : Indicateurs de progression et statuts
|
||||
|
||||
### 5. Résultats
|
||||
|
||||
- **Statistiques** : Nombre de fichiers importés, erreurs, total
|
||||
- **Détails** : Liste des fichiers importés avec leurs associations
|
||||
- **Erreurs** : Affichage détaillé des erreurs pour débogage
|
||||
|
||||
## API Endpoints Utilisés
|
||||
|
||||
### Analyse de fichiers
|
||||
```
|
||||
GET /api/manga-matches?filename={filename}
|
||||
```
|
||||
Retourne :
|
||||
```json
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"id": "string",
|
||||
"title": "string",
|
||||
"slug": "string",
|
||||
"alternativeSlugs": ["string"],
|
||||
"thumbnailUrl": "string",
|
||||
"matchScore": 100
|
||||
}
|
||||
],
|
||||
"chapterNumber": 1.5,
|
||||
"volumeNumber": 2.0,
|
||||
"possibleTitles": ["string"]
|
||||
}
|
||||
```
|
||||
|
||||
### Import de fichier
|
||||
```
|
||||
POST /api/import/upload-file
|
||||
```
|
||||
FormData :
|
||||
- `file`: Le fichier CBZ/CBR
|
||||
- `mangaId`: ID du manga sélectionné
|
||||
- `chapterNumber`: Numéro de chapitre (optionnel, float)
|
||||
- `volumeNumber`: Numéro de volume (optionnel, float)
|
||||
|
||||
## Store Pinia
|
||||
|
||||
Le store `newImportStore` gère tout l'état de l'application :
|
||||
|
||||
### État
|
||||
- `files`: Liste des fichiers en cours de traitement
|
||||
- `analyzingFiles`: Set des IDs de fichiers en analyse
|
||||
- `importingFiles`: Set des IDs de fichiers en import
|
||||
- `isLoading`: État de chargement global
|
||||
- `globalError`: Erreur globale éventuelle
|
||||
|
||||
### Getters
|
||||
- `pendingFiles`: Fichiers en attente d'analyse
|
||||
- `analyzedFiles`: Fichiers analysés
|
||||
- `readyFiles`: Fichiers prêts pour l'import
|
||||
- `importedFiles`: Fichiers importés avec succès
|
||||
- `errorFiles`: Fichiers en erreur
|
||||
- `hasReadyFiles`: Au moins un fichier prêt
|
||||
- `allFilesProcessed`: Tous les fichiers traités
|
||||
- `progressPercentage`: Pourcentage de progression
|
||||
|
||||
### Actions Principales
|
||||
- `addFiles(fileList)`: Ajoute des fichiers et lance l'analyse automatique
|
||||
- `analyzeFile(fileId)`: Analyse un fichier spécifique
|
||||
- `setFileManga(fileId, manga)`: Définit le manga sélectionné
|
||||
- `setFileChapterNumber(fileId, number)`: Définit le numéro de chapitre
|
||||
- `setFileVolumeNumber(fileId, number)`: Définit le numéro de volume
|
||||
- `importFile(fileId)`: Importe un fichier
|
||||
- `importAllReadyFiles()`: Importe tous les fichiers prêts
|
||||
- `autoSelectBestMatches()`: Sélection automatique des meilleurs matchs
|
||||
- `retryFile(fileId)`: Réessaye l'analyse ou l'import d'un fichier
|
||||
|
||||
## Entité FileImport
|
||||
|
||||
Représente un fichier dans le processus d'import :
|
||||
|
||||
### Propriétés
|
||||
- `file`: Objet File du navigateur
|
||||
- `filename`: Nom du fichier original
|
||||
- `analysis`: Résultat de l'analyse (matches, chapterNumber, volumeNumber)
|
||||
- `selectedManga`: Manga sélectionné par l'utilisateur
|
||||
- `selectedChapterNumber`: Numéro de chapitre (auto ou manuel)
|
||||
- `selectedVolumeNumber`: Numéro de volume (auto ou manuel)
|
||||
- `status`: pending | analyzed | importing | imported | error
|
||||
- `errorMessage`: Message d'erreur le cas échéant
|
||||
|
||||
### Méthodes Utiles
|
||||
- `hasMatches()`: Vérifie si des correspondances ont été trouvées
|
||||
- `getMatches()`: Retourne la liste des correspondances
|
||||
- `getBestMatch()`: Retourne la meilleure correspondance
|
||||
- `isReadyForImport()`: Vérifie si le fichier est prêt à être importé
|
||||
- `getImportData()`: Prépare les données pour l'API d'import
|
||||
|
||||
## Workflow Utilisateur
|
||||
|
||||
1. **Upload**: L'utilisateur glisse-dépose ou sélectionne des fichiers CBZ/CBR
|
||||
2. **Analyse automatique**: Chaque fichier est analysé pour extraire les informations
|
||||
3. **Sélection auto**: Le meilleur match est automatiquement sélectionné
|
||||
4. **Validation**: L'utilisateur peut modifier le manga ou les numéros si nécessaire
|
||||
5. **Import**: Import unitaire ou groupé des fichiers prêts
|
||||
6. **Résultats**: Affichage du résumé avec succès et erreurs
|
||||
|
||||
## Gestion des Erreurs
|
||||
|
||||
### Erreurs d'analyse
|
||||
- Aucun manga trouvé → Message informatif, possibilité de réessayer
|
||||
- Erreur réseau → Message d'erreur, bouton retry disponible
|
||||
|
||||
### Erreurs d'import
|
||||
- Échec d'upload → Fichier marqué en erreur avec message détaillé
|
||||
- Erreur serveur → Fichier en erreur, possibilité de retry
|
||||
|
||||
## Améliorations Futures
|
||||
|
||||
1. **Recherche manuelle** : Permettre la recherche manuelle si aucun match
|
||||
2. **Multi-sélection** : Sélectionner plusieurs fichiers pour actions groupées
|
||||
3. **Historique** : Garder un historique des imports récents
|
||||
4. **Validation avancée** : Vérifier si le chapitre/volume existe déjà
|
||||
5. **Métadonnées** : Extraire et afficher plus de métadonnées des fichiers CBZ
|
||||
|
||||
## Composants Réutilisables
|
||||
|
||||
### Depuis Shared
|
||||
- `FileUpload.vue`: Zone d'upload avec drag & drop
|
||||
- `LoadingSpinner.vue`: Indicateur de chargement
|
||||
|
||||
### Spécifiques au Domaine
|
||||
- `FileImportCard.vue`: Carte complète de gestion d'un fichier
|
||||
- `StatusBadge.vue`: Badge de statut avec couleurs
|
||||
- `ImportResults.vue`: Résumé des résultats d'import
|
||||
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
316
assets/vue/app/domain/import/application/store/newImportStore.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { useNotifications } from '../../../../shared/composables/useNotifications';
|
||||
import { FileImport } from '../../domain/entities/FileImport';
|
||||
import { ApiImportRepository } from '../../infrastructure/api/apiImportRepository';
|
||||
|
||||
const importRepository = new ApiImportRepository();
|
||||
const { showSuccess, showError, showInfo } = useNotifications();
|
||||
|
||||
export const useNewImportStore = defineStore('newImport', {
|
||||
state: () => ({
|
||||
// Files being processed
|
||||
files: [], // Array of FileImport entities
|
||||
|
||||
// Loading states
|
||||
analyzingFiles: new Set(), // File IDs being analyzed
|
||||
importingFiles: new Set(), // File IDs being imported
|
||||
|
||||
// Global states
|
||||
isLoading: false,
|
||||
globalError: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// File status getters
|
||||
pendingFiles: (state) => state.files.filter(f => f.isPending()),
|
||||
analyzedFiles: (state) => state.files.filter(f => f.isAnalyzed()),
|
||||
readyFiles: (state) => state.files.filter(f => f.isReadyForImport()),
|
||||
importedFiles: (state) => state.files.filter(f => f.isImported()),
|
||||
errorFiles: (state) => state.files.filter(f => f.hasError()),
|
||||
|
||||
// Counts
|
||||
totalFiles: (state) => state.files.length,
|
||||
readyCount: (state) => state.files.filter(f => f.isReadyForImport()).length,
|
||||
importedCount: (state) => state.files.filter(f => f.isImported()).length,
|
||||
errorCount: (state) => state.files.filter(f => f.hasError()).length,
|
||||
|
||||
// Status helpers
|
||||
hasFiles: (state) => state.files.length > 0,
|
||||
hasReadyFiles: (state) => state.files.some(f => f.isReadyForImport()),
|
||||
allFilesProcessed: (state) => {
|
||||
return state.files.length > 0 &&
|
||||
state.files.every(f => f.isImported() || f.hasError());
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressPercentage: (state) => {
|
||||
if (state.files.length === 0) return 0;
|
||||
const processed = state.files.filter(f => f.isImported() || f.hasError()).length;
|
||||
return Math.round((processed / state.files.length) * 100);
|
||||
},
|
||||
|
||||
// Specific file finders
|
||||
getFileById: (state) => (id) => {
|
||||
return state.files.find(f => f.id === id);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
// === FILE MANAGEMENT ===
|
||||
|
||||
/**
|
||||
* Add files to the import queue
|
||||
*/
|
||||
addFiles(fileList) {
|
||||
const validFiles = Array.from(fileList).filter(file => {
|
||||
const extension = file.name.split('.').pop().toLowerCase();
|
||||
return ['cbz', 'cbr'].includes(extension);
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
showError('Aucun fichier CBZ/CBR valide sélectionné');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = validFiles.map(file => FileImport.create(file));
|
||||
this.files.push(...newFiles);
|
||||
|
||||
showInfo(`${newFiles.length} fichier(s) ajouté(s) à la queue d'import`);
|
||||
|
||||
// Auto-analyze all new files
|
||||
this.analyzeAllPendingFiles();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a file from the queue
|
||||
*/
|
||||
removeFile(fileId) {
|
||||
const index = this.files.findIndex(f => f.id === fileId);
|
||||
if (index !== -1) {
|
||||
this.files.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all files
|
||||
*/
|
||||
clearFiles() {
|
||||
this.files = [];
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
this.globalError = null;
|
||||
},
|
||||
|
||||
// === ANALYSIS ACTIONS ===
|
||||
|
||||
/**
|
||||
* Analyze all pending files
|
||||
*/
|
||||
async analyzeAllPendingFiles() {
|
||||
const pendingFiles = this.pendingFiles;
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingFiles.map(file => this.analyzeFile(file.id))
|
||||
);
|
||||
showSuccess(`${pendingFiles.length} fichier(s) analysé(s) avec succès`);
|
||||
} catch (error) {
|
||||
console.error('Error analyzing files:', error);
|
||||
this.globalError = 'Erreur lors de l\'analyse des fichiers';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Analyze a specific file
|
||||
*/
|
||||
async analyzeFile(fileId) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex === -1) return;
|
||||
|
||||
const file = this.files[fileIndex];
|
||||
if (!file.isPending()) return;
|
||||
|
||||
this.analyzingFiles.add(fileId);
|
||||
|
||||
try {
|
||||
const analysis = await importRepository.analyzeFilename(file.filename);
|
||||
file.setAnalysis(analysis);
|
||||
|
||||
// Force reactivity by replacing the object in the array
|
||||
this.files[fileIndex] = file;
|
||||
|
||||
if (!file.hasMatches()) {
|
||||
showError(`Aucun manga trouvé pour le fichier: ${file.filename}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error analyzing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'analyse: ${error.message}`);
|
||||
this.files[fileIndex] = file;
|
||||
showError(`Erreur lors de l'analyse de ${file.filename}`);
|
||||
} finally {
|
||||
this.analyzingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === SELECTION ACTIONS ===
|
||||
|
||||
/**
|
||||
* Update manga selection for a file
|
||||
*/
|
||||
setFileManga(fileId, manga) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedManga(manga);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update chapter number for a file
|
||||
*/
|
||||
setFileChapterNumber(fileId, chapterNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedChapterNumber(chapterNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update volume number for a file
|
||||
*/
|
||||
setFileVolumeNumber(fileId, volumeNumber) {
|
||||
const fileIndex = this.files.findIndex(f => f.id === fileId);
|
||||
if (fileIndex !== -1) {
|
||||
this.files[fileIndex].setSelectedVolumeNumber(volumeNumber);
|
||||
// Force reactivity
|
||||
this.files[fileIndex] = this.files[fileIndex];
|
||||
}
|
||||
},
|
||||
|
||||
// === IMPORT ACTIONS ===
|
||||
|
||||
/**
|
||||
* Import all ready files
|
||||
*/
|
||||
async importAllReadyFiles() {
|
||||
const readyFiles = this.readyFiles;
|
||||
if (readyFiles.length === 0) {
|
||||
showError('Aucun fichier prêt pour l\'import');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
try {
|
||||
for (const file of readyFiles) {
|
||||
try {
|
||||
await this.importFile(file.id);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`Failed to import file ${file.filename}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
showSuccess(`${successCount} fichier(s) importé(s) avec succès`);
|
||||
}
|
||||
if (errorCount > 0) {
|
||||
showError(`${errorCount} fichier(s) ont échoué lors de l'import`);
|
||||
}
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Import a specific file
|
||||
*/
|
||||
async importFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file || !file.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
this.importingFiles.add(fileId);
|
||||
file.setImporting();
|
||||
|
||||
try {
|
||||
const importData = file.getImportData();
|
||||
await importRepository.importFile(
|
||||
file.file,
|
||||
importData.mangaId,
|
||||
importData.chapterNumber,
|
||||
importData.volumeNumber
|
||||
);
|
||||
|
||||
file.setImported();
|
||||
showSuccess(`Fichier ${file.filename} importé avec succès`);
|
||||
} catch (error) {
|
||||
console.error(`Error importing file ${file.filename}:`, error);
|
||||
file.setError(`Erreur d'import: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.importingFiles.delete(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry import for a failed file
|
||||
*/
|
||||
async retryFile(fileId) {
|
||||
const file = this.getFileById(fileId);
|
||||
if (!file) return;
|
||||
|
||||
if (file.hasError() && file.selectedManga) {
|
||||
// If the file had an import error but has selections, retry import
|
||||
await this.importFile(fileId);
|
||||
} else {
|
||||
// If the file had an analysis error, retry analysis
|
||||
file.status = 'pending';
|
||||
file.errorMessage = null;
|
||||
await this.analyzeFile(fileId);
|
||||
}
|
||||
},
|
||||
|
||||
// === UTILITY ACTIONS ===
|
||||
|
||||
/**
|
||||
* Auto-select best matches for all files
|
||||
*/
|
||||
autoSelectBestMatches() {
|
||||
let selectedCount = 0;
|
||||
|
||||
this.analyzedFiles.forEach(file => {
|
||||
const bestMatch = file.getBestMatch();
|
||||
if (bestMatch) {
|
||||
file.setSelectedManga(bestMatch);
|
||||
selectedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedCount > 0) {
|
||||
showInfo(`${selectedCount} correspondance(s) automatique(s) effectuée(s)`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset global state
|
||||
*/
|
||||
resetGlobalState() {
|
||||
this.globalError = null;
|
||||
this.isLoading = false;
|
||||
this.analyzingFiles.clear();
|
||||
this.importingFiles.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
200
assets/vue/app/domain/import/domain/entities/FileImport.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Entité représentant un fichier en cours d'import avec ses correspondances possibles
|
||||
*/
|
||||
export class FileImport {
|
||||
constructor({
|
||||
file, // File object from browser
|
||||
filename, // Original filename
|
||||
analysis = null, // Result from /api/manga-matches endpoint
|
||||
selectedManga = null, // Selected manga match
|
||||
selectedChapterNumber = null, // Selected chapter number (extracted from filename)
|
||||
selectedVolumeNumber = null, // Selected volume number (extracted from filename)
|
||||
status = 'pending', // 'pending', 'analyzed', 'importing', 'imported', 'error'
|
||||
errorMessage = null,
|
||||
importedAt = null
|
||||
}) {
|
||||
this.file = file;
|
||||
this.filename = filename;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterNumber = selectedChapterNumber;
|
||||
this.selectedVolumeNumber = selectedVolumeNumber;
|
||||
this.status = status;
|
||||
this.errorMessage = errorMessage;
|
||||
this.importedAt = importedAt;
|
||||
this.id = this._generateId();
|
||||
}
|
||||
|
||||
static create(file) {
|
||||
return new FileImport({
|
||||
file,
|
||||
filename: file.name
|
||||
});
|
||||
}
|
||||
|
||||
_generateId() {
|
||||
return `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Status helpers
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasMatches() {
|
||||
return this.analysis && this.analysis.matches && this.analysis.matches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.analysis?.matches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
// Sort by matchScore (highest first) and return the best one
|
||||
return matches.length > 0 ? matches.sort((a, b) => b.matchScore - a.matchScore)[0] : null;
|
||||
}
|
||||
|
||||
// Analysis extracted data
|
||||
getExtractedChapterNumber() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getExtractedVolumeNumber() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForImport() {
|
||||
// Ready if a manga is selected and at least chapter or volume number is set
|
||||
return this.selectedManga && (this.selectedChapterNumber !== null || this.selectedVolumeNumber !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterNumber !== null) return 'chapter';
|
||||
if (this.selectedVolumeNumber !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File helpers
|
||||
getFormattedSize() {
|
||||
if (!this.file || !this.file.size) return 'Unknown';
|
||||
|
||||
const bytes = this.file.size;
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getFileExtension() {
|
||||
const extension = this.filename.split('.').pop().toLowerCase();
|
||||
return extension;
|
||||
}
|
||||
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.getFileExtension());
|
||||
}
|
||||
|
||||
// Update methods
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
|
||||
// Auto-set extracted chapter/volume numbers from analysis
|
||||
if (analysis.chapterNumber !== null && analysis.chapterNumber !== undefined) {
|
||||
this.selectedChapterNumber = analysis.chapterNumber;
|
||||
}
|
||||
if (analysis.volumeNumber !== null && analysis.volumeNumber !== undefined) {
|
||||
this.selectedVolumeNumber = analysis.volumeNumber;
|
||||
}
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Keep the chapter/volume numbers from analysis
|
||||
}
|
||||
|
||||
setSelectedChapterNumber(chapterNumber) {
|
||||
this.selectedChapterNumber = chapterNumber;
|
||||
// If setting chapter, clear volume
|
||||
if (chapterNumber !== null) {
|
||||
this.selectedVolumeNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolumeNumber = volumeNumber;
|
||||
// If setting volume, clear chapter
|
||||
if (volumeNumber !== null) {
|
||||
this.selectedChapterNumber = null;
|
||||
}
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.importedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterNumber !== null) {
|
||||
data.chapterNumber = this.selectedChapterNumber;
|
||||
}
|
||||
|
||||
if (this.selectedVolumeNumber !== null) {
|
||||
data.volumeNumber = this.selectedVolumeNumber;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
239
assets/vue/app/domain/import/domain/entities/ImportFile.js
Normal file
@@ -0,0 +1,239 @@
|
||||
export class ImportFile {
|
||||
constructor({
|
||||
id,
|
||||
originalName,
|
||||
fileSize,
|
||||
extension,
|
||||
status = 'pending',
|
||||
createdAt,
|
||||
metadata = null,
|
||||
mangaMatches = [],
|
||||
selectedMangaSlug = null,
|
||||
selectedVolume = null,
|
||||
selectedChapter = null,
|
||||
errorMessage = null,
|
||||
processedAt = null,
|
||||
// New properties for simplified workflow
|
||||
file = null, // Browser File object
|
||||
analysis = null, // Analysis result from API
|
||||
selectedManga = null, // Selected manga object
|
||||
selectedChapterId = null // Selected chapter ID
|
||||
}) {
|
||||
this.id = id;
|
||||
this.originalName = originalName;
|
||||
this.fileSize = fileSize;
|
||||
this.extension = extension;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.metadata = metadata;
|
||||
this.mangaMatches = mangaMatches;
|
||||
this.selectedMangaSlug = selectedMangaSlug;
|
||||
this.selectedVolume = selectedVolume;
|
||||
this.selectedChapter = selectedChapter;
|
||||
this.errorMessage = errorMessage;
|
||||
this.processedAt = processedAt;
|
||||
|
||||
// New properties
|
||||
this.file = file;
|
||||
this.analysis = analysis;
|
||||
this.selectedManga = selectedManga;
|
||||
this.selectedChapterId = selectedChapterId;
|
||||
this.mangaMatches = mangaMatches; // Store found manga matches
|
||||
}
|
||||
|
||||
static create(data) {
|
||||
return new ImportFile({
|
||||
...data,
|
||||
createdAt: data.createdAt || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Create from browser File object
|
||||
static createFromFile(file) {
|
||||
return new ImportFile({
|
||||
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
originalName: file.name,
|
||||
fileSize: file.size,
|
||||
extension: file.name.split('.').pop().toLowerCase(),
|
||||
file: file,
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
isProcessed() {
|
||||
return this.status === 'processed';
|
||||
}
|
||||
|
||||
hasError() {
|
||||
return this.status === 'error';
|
||||
}
|
||||
|
||||
isPending() {
|
||||
return this.status === 'pending';
|
||||
}
|
||||
|
||||
needsConversion() {
|
||||
return this.extension === 'cbr';
|
||||
}
|
||||
|
||||
isReadyForImport() {
|
||||
return this.isProcessed() && this.selectedMangaSlug && (this.selectedVolume || this.selectedChapter);
|
||||
}
|
||||
|
||||
getFormattedSize() {
|
||||
const bytes = parseInt(this.fileSize);
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
getContentType() {
|
||||
if (this.metadata?.chapter) {
|
||||
return `Chapter ${this.metadata.chapter}`;
|
||||
}
|
||||
if (this.metadata?.volume) {
|
||||
return `Volume ${this.metadata.volume}`;
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// === NEW METHODS FOR SIMPLIFIED WORKFLOW ===
|
||||
|
||||
// Status helpers for new workflow
|
||||
isAnalyzed() {
|
||||
return this.status === 'analyzed';
|
||||
}
|
||||
|
||||
isImporting() {
|
||||
return this.status === 'importing';
|
||||
}
|
||||
|
||||
isImported() {
|
||||
return this.status === 'imported';
|
||||
}
|
||||
|
||||
// Analysis helpers
|
||||
hasAnalysis() {
|
||||
return this.analysis && this.analysis.possibleTitles && this.analysis.possibleTitles.length > 0;
|
||||
}
|
||||
|
||||
getPossibleTitles() {
|
||||
return this.analysis?.possibleTitles || [];
|
||||
}
|
||||
|
||||
getAnalyzedChapter() {
|
||||
return this.analysis?.chapterNumber || null;
|
||||
}
|
||||
|
||||
getAnalyzedVolume() {
|
||||
return this.analysis?.volumeNumber || null;
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
hasMatches() {
|
||||
return this.mangaMatches && this.mangaMatches.length > 0;
|
||||
}
|
||||
|
||||
getMatches() {
|
||||
return this.mangaMatches || [];
|
||||
}
|
||||
|
||||
getBestMatch() {
|
||||
const matches = this.getMatches();
|
||||
return matches.length > 0 ? matches[0] : null;
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
isReadyForNewImport() {
|
||||
return this.selectedManga && (this.selectedChapterId || this.selectedVolume !== null);
|
||||
}
|
||||
|
||||
getImportType() {
|
||||
if (this.selectedChapterId) return 'chapter';
|
||||
if (this.selectedVolume !== null) return 'volume';
|
||||
return null;
|
||||
}
|
||||
|
||||
// File validation
|
||||
isValidFormat() {
|
||||
const validExtensions = ['cbz', 'cbr'];
|
||||
return validExtensions.includes(this.extension);
|
||||
}
|
||||
|
||||
// Update methods for new workflow
|
||||
setAnalysis(analysis) {
|
||||
this.analysis = analysis;
|
||||
this.status = 'analyzed';
|
||||
}
|
||||
|
||||
setMangaMatches(matches) {
|
||||
this.mangaMatches = matches;
|
||||
|
||||
// Auto-select best match if available
|
||||
const bestMatch = this.getBestMatch();
|
||||
if (bestMatch) {
|
||||
this.selectedManga = bestMatch;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedManga(manga) {
|
||||
this.selectedManga = manga;
|
||||
// Reset chapter/volume selection when manga changes
|
||||
this.selectedChapterId = null;
|
||||
this.selectedVolume = null;
|
||||
}
|
||||
|
||||
setSelectedChapterById(chapterId) {
|
||||
this.selectedChapterId = chapterId;
|
||||
this.selectedVolume = null; // Can't have both
|
||||
}
|
||||
|
||||
setSelectedVolumeNumber(volumeNumber) {
|
||||
this.selectedVolume = volumeNumber;
|
||||
this.selectedChapterId = null; // Can't have both
|
||||
}
|
||||
|
||||
setImporting() {
|
||||
this.status = 'importing';
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setImported() {
|
||||
this.status = 'imported';
|
||||
this.processedAt = new Date().toISOString();
|
||||
this.errorMessage = null;
|
||||
}
|
||||
|
||||
setError(message) {
|
||||
this.status = 'error';
|
||||
this.errorMessage = message;
|
||||
}
|
||||
|
||||
// Export selection for API
|
||||
getImportData() {
|
||||
if (!this.isReadyForNewImport()) {
|
||||
throw new Error('File is not ready for import');
|
||||
}
|
||||
|
||||
const data = {
|
||||
mangaId: this.selectedManga.id
|
||||
};
|
||||
|
||||
if (this.selectedChapterId) {
|
||||
data.chapterId = this.selectedChapterId;
|
||||
}
|
||||
|
||||
if (this.selectedVolume !== null) {
|
||||
data.volumeNumber = this.selectedVolume;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export class ApiImportRepository {
|
||||
/**
|
||||
* Analyse le nom d'un fichier et trouve les mangas correspondants
|
||||
* @param {string} filename - Nom du fichier à analyser
|
||||
* @returns {Promise<Object>} - Résultat de l'analyse avec les correspondances
|
||||
*/
|
||||
async analyzeFilename(filename) {
|
||||
try {
|
||||
console.log('Analyzing filename:', filename);
|
||||
const response = await fetch(`/api/manga-matches?filename=${encodeURIComponent(filename)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Analyze filename failed:', response.status, errorText);
|
||||
throw new Error(`Failed to analyze filename: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Analyze result:', result);
|
||||
|
||||
// Extract chapter and volume numbers from the first match if available
|
||||
const firstMatch = result.matches && result.matches.length > 0 ? result.matches[0] : null;
|
||||
const chapterNumber = firstMatch?.chapterNumber ?? null;
|
||||
const volumeNumber = firstMatch?.volumeNumber ?? null;
|
||||
|
||||
return {
|
||||
matches: result.matches || [],
|
||||
chapterNumber,
|
||||
volumeNumber,
|
||||
possibleTitles: result.possibleTitles || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les détails d'un manga par son slug
|
||||
* @param {string} slug - Slug du manga
|
||||
* @returns {Promise<Object>} - Détails du manga avec chapitres et volumes
|
||||
*/
|
||||
async getMangaDetails(slug) {
|
||||
try {
|
||||
console.log('Fetching manga details for:', slug);
|
||||
const response = await fetch(`/api/mangas/${slug}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Get manga details failed:', response.status, errorText);
|
||||
throw new Error(`Failed to get manga details: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload et import d'un fichier avec les informations du manga
|
||||
* @param {File} file - Fichier à uploader
|
||||
* @param {string} mangaId - ID du manga
|
||||
* @param {string|null} chapterId - ID du chapitre (optionnel)
|
||||
* @param {number|null} volumeNumber - Numéro du volume (optionnel)
|
||||
* @returns {Promise<Object>} - Résultat de l'import
|
||||
*/
|
||||
async importFile(file, mangaId, chapterId = null, volumeNumber = null) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mangaId', mangaId);
|
||||
|
||||
if (chapterId) {
|
||||
formData.append('chapterId', chapterId);
|
||||
}
|
||||
|
||||
if (volumeNumber) {
|
||||
formData.append('volumeNumber', volumeNumber.toString());
|
||||
}
|
||||
|
||||
console.log('Importing file:', file.name, 'for manga:', mangaId);
|
||||
const response = await fetch('/api/import/upload-file', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Import failed:', response.status, errorText);
|
||||
throw new Error(`Failed to import file: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Import result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- File Icon and Info -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||
{{ file.filename }}
|
||||
</h3>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<StatusBadge :status="file.status" :is-analyzing="isAnalyzing" :is-importing="isImporting" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{{ file.getFormattedSize() }} • {{ file.getFileExtension().toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- Extracted Info -->
|
||||
<div v-if="file.isAnalyzed()" class="mt-2 flex gap-3 text-sm">
|
||||
<span v-if="file.getExtractedChapterNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-blue-50 text-blue-700">
|
||||
Chapitre {{ file.getExtractedChapterNumber() }}
|
||||
</span>
|
||||
<span v-if="file.getExtractedVolumeNumber()" class="inline-flex items-center px-2 py-1 rounded-md bg-purple-50 text-purple-700">
|
||||
Volume {{ file.getExtractedVolumeNumber() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div v-if="file.hasError()" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Erreur</h3>
|
||||
<div class="mt-2 text-sm text-red-700">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manga Selection -->
|
||||
<div v-if="file.isAnalyzed() && file.hasMatches()" class="mt-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sélectionner un manga
|
||||
</label>
|
||||
<select
|
||||
:value="file.selectedManga?.id || ''"
|
||||
@change="handleMangaSelection"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">-- Choisir un manga --</option>
|
||||
<option
|
||||
v-for="manga in file.getMatches()"
|
||||
:key="manga.id"
|
||||
:value="manga.id"
|
||||
>
|
||||
{{ manga.title }} (Score: {{ manga.matchScore }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Selected Manga Preview -->
|
||||
<div v-if="file.selectedManga" class="flex items-center gap-3 p-3 bg-gray-50 rounded-md">
|
||||
<img
|
||||
v-if="file.selectedManga.thumbnailUrl"
|
||||
:src="file.selectedManga.thumbnailUrl"
|
||||
:alt="file.selectedManga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{{ file.selectedManga.title }}</p>
|
||||
<p class="text-sm text-gray-500">{{ file.selectedManga.slug }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter/Volume Number Inputs -->
|
||||
<div v-if="file.selectedManga" class="grid grid-cols-2 gap-3">
|
||||
<!-- Chapter Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de chapitre
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedChapterNumber ?? ''"
|
||||
@input="handleChapterNumberInput"
|
||||
:disabled="file.selectedVolumeNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Volume Number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Numéro de volume
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
:value="file.selectedVolumeNumber ?? ''"
|
||||
@input="handleVolumeNumberInput"
|
||||
:disabled="file.selectedChapterNumber !== null"
|
||||
class="w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
placeholder="Ex: 1, 1.5, 2..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Matches Message -->
|
||||
<div v-if="file.isAnalyzed() && !file.hasMatches()" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Aucun manga trouvé</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
Aucun manga ne correspond à ce fichier. Vérifiez le nom du fichier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-between items-center">
|
||||
<div class="flex space-x-3">
|
||||
<!-- Import Button -->
|
||||
<button
|
||||
v-if="file.isReadyForImport()"
|
||||
@click="$emit('import-file')"
|
||||
:disabled="isImporting"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md text-sm font-medium flex items-center"
|
||||
>
|
||||
<svg v-if="isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ isImporting ? 'Import en cours...' : 'Importer' }}
|
||||
</button>
|
||||
|
||||
<!-- Retry Button -->
|
||||
<button
|
||||
v-if="file.hasError()"
|
||||
@click="$emit('retry-file')"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
@click="$emit('remove-file')"
|
||||
class="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'manga-selected',
|
||||
'chapter-number-selected',
|
||||
'volume-number-selected',
|
||||
'import-file',
|
||||
'retry-file',
|
||||
'remove-file'
|
||||
]);
|
||||
|
||||
const handleMangaSelection = (event) => {
|
||||
const mangaId = event.target.value;
|
||||
if (mangaId) {
|
||||
const selectedManga = props.file.getMatches().find(m => m.id === mangaId);
|
||||
emit('manga-selected', selectedManga);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChapterNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const chapterNumber = value ? parseFloat(value) : null;
|
||||
emit('chapter-number-selected', chapterNumber);
|
||||
};
|
||||
|
||||
const handleVolumeNumberInput = (event) => {
|
||||
const value = event.target.value;
|
||||
const volumeNumber = value ? parseFloat(value) : null;
|
||||
emit('volume-number-selected', volumeNumber);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Import terminé</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Voici le résumé de votre session d'import
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ importedCount }}</div>
|
||||
<div class="text-sm text-gray-500">Importés</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-red-600">{{ errorCount }}</div>
|
||||
<div class="text-sm text-gray-500">Erreurs</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-600">{{ totalCount }}</div>
|
||||
<div class="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Files List -->
|
||||
<div v-if="importedFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers importés avec succès ({{ importedFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="file in importedFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-gray-900">{{ file.filename }}</span>
|
||||
<span v-if="file.selectedManga" class="ml-2 text-gray-500">
|
||||
→ {{ file.selectedManga.title }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Error Files List -->
|
||||
<div v-if="errorFiles.length > 0" class="mb-6">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">
|
||||
Fichiers en erreur ({{ errorFiles.length }})
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="file in errorFiles"
|
||||
:key="file.id"
|
||||
class="flex items-start text-sm"
|
||||
>
|
||||
<svg class="flex-shrink-0 h-4 w-4 text-red-400 mr-2 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-gray-900">{{ file.filename }}</div>
|
||||
<div class="text-red-600 text-xs mt-1">{{ file.errorMessage }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-center space-x-4 pt-6 border-t">
|
||||
<button
|
||||
@click="startNewImport"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
<button
|
||||
@click="goToLibrary"
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Aller à la bibliothèque
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useNewImportStore();
|
||||
|
||||
const importedFiles = computed(() => store.importedFiles);
|
||||
const errorFiles = computed(() => store.errorFiles);
|
||||
const importedCount = computed(() => store.importedCount);
|
||||
const errorCount = computed(() => store.errorCount);
|
||||
const totalCount = computed(() => store.totalFiles);
|
||||
|
||||
const startNewImport = () => {
|
||||
store.clearFiles();
|
||||
};
|
||||
|
||||
const goToLibrary = () => {
|
||||
router.push({ name: 'manga-collection' });
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="manga-option">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div v-if="manga.coverUrl" class="flex-shrink-0">
|
||||
<img
|
||||
:src="manga.coverUrl"
|
||||
:alt="manga.title"
|
||||
class="w-12 h-16 object-cover rounded"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-shrink-0 w-12 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-gray-900 truncate">
|
||||
{{ manga.title }}
|
||||
</h4>
|
||||
<div class="text-xs text-gray-500 space-y-1">
|
||||
<p v-if="manga.author" class="truncate">
|
||||
{{ manga.author }}
|
||||
</p>
|
||||
<p v-if="manga.publicationYear" class="truncate">
|
||||
{{ manga.publicationYear }}
|
||||
</p>
|
||||
<div v-if="manga.genres && manga.genres.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="genre in manga.genres.slice(0, 3)"
|
||||
:key="genre"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{{ genre }}
|
||||
</span>
|
||||
<span v-if="manga.genres.length > 3" class="text-xs text-gray-400">
|
||||
+{{ manga.genres.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
manga: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center">
|
||||
<!-- Loading Spinner for analyzing/importing -->
|
||||
<svg v-if="isAnalyzing || isImporting" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<span :class="badgeClasses">
|
||||
{{ badgeText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isAnalyzing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isImporting: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (props.isImporting) return 'Import en cours...';
|
||||
if (props.isAnalyzing) return 'Analyse en cours...';
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending': return 'En attente';
|
||||
case 'analyzed': return 'Analysé';
|
||||
case 'importing': return 'Import en cours';
|
||||
case 'imported': return 'Importé';
|
||||
case 'error': return 'Erreur';
|
||||
default: return 'Inconnu';
|
||||
}
|
||||
});
|
||||
|
||||
const badgeClasses = computed(() => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
|
||||
if (props.isImporting || props.isAnalyzing) {
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
}
|
||||
|
||||
switch (props.status) {
|
||||
case 'pending':
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
case 'analyzed':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`;
|
||||
case 'importing':
|
||||
return `${baseClasses} bg-blue-100 text-blue-800`;
|
||||
case 'imported':
|
||||
return `${baseClasses} bg-green-100 text-green-800`;
|
||||
case 'error':
|
||||
return `${baseClasses} bg-red-100 text-red-800`;
|
||||
default:
|
||||
return `${baseClasses} bg-gray-100 text-gray-800`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Import de Bibliothèque</h1>
|
||||
<p class="text-gray-600">
|
||||
Importez vos fichiers CBZ/CBR dans votre bibliothèque Mangarr
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar (if files are being processed) -->
|
||||
<div v-if="store.hasFiles && !store.allFilesProcessed" class="mb-8">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progression</span>
|
||||
<span class="text-sm text-gray-500">{{ store.progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: store.progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
<span>{{ store.importedCount }} importés</span>
|
||||
<span>{{ store.errorCount }} erreurs</span>
|
||||
<span>{{ store.totalFiles }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Zone -->
|
||||
<div v-if="!store.hasFiles || store.allFilesProcessed" class="mb-8">
|
||||
<FileUpload
|
||||
label="Importer des fichiers CBZ/CBR"
|
||||
accept=".cbz,.cbr"
|
||||
:multiple="true"
|
||||
description="Formats CBZ ou CBR uniquement"
|
||||
@files-selected="handleFilesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Files List -->
|
||||
<div v-if="store.hasFiles" class="space-y-6">
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button
|
||||
v-if="store.hasReadyFiles"
|
||||
@click="importAllFiles"
|
||||
:disabled="store.isLoading"
|
||||
class="bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
<LoadingSpinner v-if="store.isLoading" class="w-4 h-4 mr-2" />
|
||||
Importer tous les fichiers prêts ({{ store.readyCount }})
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="store.analyzedFiles.length > 0"
|
||||
@click="autoSelectMatches"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Sélection automatique
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="clearAllFiles"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium"
|
||||
>
|
||||
Effacer tout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Files Grid -->
|
||||
<div class="grid gap-6">
|
||||
<FileImportCard
|
||||
v-for="file in store.files"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-analyzing="store.analyzingFiles.has(file.id)"
|
||||
:is-importing="store.importingFiles.has(file.id)"
|
||||
@manga-selected="(manga) => store.setFileManga(file.id, manga)"
|
||||
@chapter-number-selected="(chapterNumber) => store.setFileChapterNumber(file.id, chapterNumber)"
|
||||
@volume-number-selected="(volumeNumber) => store.setFileVolumeNumber(file.id, volumeNumber)"
|
||||
@import-file="() => importSingleFile(file.id)"
|
||||
@retry-file="() => retryFile(file.id)"
|
||||
@remove-file="() => store.removeFile(file.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Summary (when all files are processed) -->
|
||||
<div v-if="store.allFilesProcessed" class="mt-8">
|
||||
<ImportResults />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onUnmounted } from 'vue';
|
||||
import FileUpload from '../../../../shared/components/ui/FileUpload.vue';
|
||||
import LoadingSpinner from '../../../../shared/components/ui/LoadingSpinner.vue';
|
||||
import { useNewImportStore } from '../../application/store/newImportStore';
|
||||
import FileImportCard from '../components/FileImportCard.vue';
|
||||
import ImportResults from '../components/ImportResults.vue';
|
||||
|
||||
const store = useNewImportStore();
|
||||
|
||||
// === EVENT HANDLERS ===
|
||||
|
||||
const handleFilesSelected = (files) => {
|
||||
store.addFiles(files);
|
||||
};
|
||||
|
||||
const importAllFiles = async () => {
|
||||
try {
|
||||
await store.importAllReadyFiles();
|
||||
} catch (error) {
|
||||
console.error('Error importing files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const importSingleFile = async (fileId) => {
|
||||
try {
|
||||
await store.importFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error importing file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const retryFile = async (fileId) => {
|
||||
try {
|
||||
await store.retryFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Error retrying file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const autoSelectMatches = () => {
|
||||
store.autoSelectBestMatches();
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
if (confirm('Êtes-vous sûr de vouloir effacer tous les fichiers ?')) {
|
||||
store.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
// === LIFECYCLE ===
|
||||
|
||||
// Reset state when component unmounts
|
||||
onUnmounted(() => {
|
||||
store.resetGlobalState();
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
|
||||
import ConversionPage from '../domain/conversion/presentation/pages/ConversionPage.vue';
|
||||
import NewImportPage from '../domain/import/presentation/pages/NewImportPage.vue';
|
||||
import AddManga from '../domain/manga/presentation/pages/AddManga.vue';
|
||||
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
|
||||
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
|
||||
@@ -56,10 +57,16 @@ const routes = [
|
||||
component: ChapterPage,
|
||||
props: { title: 'Lecteur' }
|
||||
},
|
||||
// Import routes
|
||||
{
|
||||
path: '/import',
|
||||
name: 'import',
|
||||
component: NewImportPage
|
||||
},
|
||||
// Pages placeholder avec chargement différé
|
||||
{
|
||||
path: '/manga/import',
|
||||
name: 'import',
|
||||
name: 'manga-import',
|
||||
component: PlaceholderComponent,
|
||||
props: { title: 'Import de bibliothèque' }
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ import MenuGroup from './sidebar/MenuGroup.vue';
|
||||
{
|
||||
icon: ArrowDownTrayIcon,
|
||||
text: 'Import bibliothèque',
|
||||
to: '/manga/import'
|
||||
to: '/import'
|
||||
},
|
||||
{ icon: GlobeAltIcon, text: 'Découvrir', to: '/manga/discover' }
|
||||
]
|
||||
|
||||
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
127
assets/vue/app/shared/components/ui/FileUpload.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<label :for="inputId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{{ label }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md"
|
||||
:class="{ 'border-green-500 bg-green-50': isDragOver, 'hover:border-gray-400': !isDragOver }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<div class="space-y-1 text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label
|
||||
:for="inputId"
|
||||
class="relative cursor-pointer bg-white rounded-md font-medium text-green-600 hover:text-green-500"
|
||||
>
|
||||
<span>Sélectionner des fichiers</span>
|
||||
<input
|
||||
:id="inputId"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</label>
|
||||
<p class="pl-1">ou glisser-déposer</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div v-if="selectedFiles.length > 0" class="mt-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Fichiers sélectionnés :</h4>
|
||||
<ul class="text-xs text-gray-600 space-y-1">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex justify-between items-center">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<span class="text-gray-400">{{ formatFileSize(file.size) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Choisir des fichiers'
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.cbz,.cbr'
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'CBZ ou CBR jusqu\'à 100MB chacun'
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'files-selected']);
|
||||
|
||||
const fileInput = ref(null);
|
||||
const isDragOver = ref(false);
|
||||
const selectedFiles = ref([]);
|
||||
|
||||
const inputId = computed(() => `file-upload-${Math.random().toString(36).substr(2, 9)}`);
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
selectedFiles.value = files;
|
||||
emit('update:modelValue', files);
|
||||
emit('files-selected', files);
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
isDragOver.value = false;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
selectedFiles.value = files;
|
||||
emit('update:modelValue', files);
|
||||
emit('files-selected', files);
|
||||
};
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newFiles) => {
|
||||
selectedFiles.value = newFiles;
|
||||
}, { deep: true });
|
||||
</script>
|
||||
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
46
assets/vue/app/shared/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<svg
|
||||
class="animate-spin"
|
||||
:class="sizeClasses"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['sm', 'md', 'lg', 'xl'].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16'
|
||||
};
|
||||
return sizes[props.size];
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user