feat: first version

This commit is contained in:
2025-09-05 09:25:01 +02:00
commit 393469d55e
4 changed files with 790 additions and 0 deletions

129
CLAUDE.md Normal file
View File

@@ -0,0 +1,129 @@
# MinIO Explorer
## Vue d'ensemble du projet
**Nom :** minio-explorer
**Description :** Application web simple qui permet d'explorer les fichiers d'un bucket MinIO comme un "Index of" Apache classique. Navigation en lecture seule dans l'arborescence avec possibilité de copier les liens directs vers les ressources.
**Architecture :** Single Page Application (SPA) entièrement côté client, hébergée directement dans le bucket MinIO qu'elle explore.
## Stack technique
- **Frontend :** HTML/CSS/JavaScript vanilla uniquement
- **Backend :** Aucun - communication directe avec l'API REST MinIO
- **Hébergement :** Fichiers statiques à la racine du bucket MinIO
- **Déploiement :** Via pipeline CD qui dépose les fichiers dans le bucket
## Structure du projet
```
/
├── index.html # Page principale
├── style.css # Styles (type Index of classique)
└── script.js # Logique de navigation et parsing XML
```
## Fonctionnalités
### Core Features
- ✅ Détection automatique de l'endpoint MinIO et du bucket depuis l'URL
- ✅ Parsing du XML retourné par l'API MinIO ListBucket
- ✅ Affichage type "Index of" avec tableau (nom, taille, date)
- ✅ Navigation dans les dossiers (clic sur dossier)
- ✅ Liens directs vers les fichiers avec un bouton "copier le lien"
- ✅ Lien ".." pour remonter dans l'arborescence
- ✅ Navigation directe dans tout le bucket sans notion de sous-répertoire de contenu
### Limitations volontaires
- ❌ Pas d'upload de fichiers
- ❌ Pas de modification/suppression
- ❌ Interface minimaliste (pas de fancy UI)
## Configuration technique
### Auto-configuration
L'application se configure automatiquement via l'URL :
- URL type : `https://mon-minio.example.com/mon-bucket/index.html`
- Endpoint MinIO détecté : `https://mon-minio.example.com`
- Bucket détecté : `mon-bucket`
### Prérequis MinIO
- Bucket avec politique de lecture publique
- Configuration CORS appropriée pour les appels depuis le navigateur
- API REST MinIO accessible
## Gestion d'erreurs
**Approche :** Logging simple dans la console uniquement
- Erreurs CORS → `console.error()`
- Bucket inaccessible → `console.error()`
- Erreurs réseau → `console.error()`
- Dossiers vides → comportement normal (affichage vide)
## Bonnes pratiques à respecter
### Code
- JavaScript ES6+ moderne
- Fonctions pures quand possible
- Nommage explicite des variables et fonctions
- Commentaires pour la logique métier complexe
- Séparation claire HTML/CSS/JS
### Structure
- HTML sémantique
- CSS organisé et commenté
- JavaScript modulaire (fonctions distinctes pour parsing, affichage, navigation)
### Performance
- Le moins de frameworks/librairies externes possible
- Chargement minimal des ressources
- Appels API optimisés
## Instructions pour Claude
### Approche de développement
1. **Commencer par l'architecture** : proposer la structure des fichiers et les fonctions principales
2. **Développement incrémental** : HTML de base → CSS simple → JavaScript par étapes
3. **Testing** : tester chaque fonctionnalité avant de passer à la suivante
### Style de collaboration
- **Simplicité first** : toujours privilégier la solution la plus simple
- **Pas de over-engineering** : éviter les abstractions complexes
- **Code lisible** : préférer la clarté à la concision
- **Bonnes pratiques** : respecter les standards web sans compromis
### Cas d'usage typiques
- Aide à la structuration du code JavaScript pour le parsing XML
- Conseils sur les appels à l'API MinIO depuis le navigateur
- Debug des problèmes CORS potentiels
- Amélioration progressive de l'interface
- Optimisation des performances
### Contexte d'utilisation
- Utilisé via **Claude Code** pour le développement complet
- Projet from scratch (aucun code existant)
- Développeur préfère une approche directe et pragmatique
- Focus sur la fonctionnalité avant l'esthétique
## Notes importantes
- Les erreurs ne remontent QUE dans la console (pas d'UI pour les erreurs)
- L'application ne doit pas afficher ses propres fichiers (index.html, style.css, script.js)
- Navigation uniquement en lecture, pas d'interactions de modification
- Style volontairement basique type "Index of Apache"
- Navigation directe dans tout le bucket, sans préfixe de contenu
- Évolutivité prévue mais pas prioritaire dans la V1

51
index.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index of /</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Index of <span id="current-path">/</span></h1>
<!-- Formulaire de développement pour tester différentes URLs -->
<div id="dev-form" class="dev-section">
<h2>Mode Développement</h2>
<form id="url-form">
<label for="minio-url">URL MinIO à explorer :</label>
<input type="text" id="minio-url" placeholder="https://mon-minio.example.com" />
<button type="submit">Explorer</button>
</form>
<div id="config-info"></div>
</div>
<!-- Section d'erreur -->
<div id="error-section" class="error" style="display: none;">
<p id="error-message"></p>
</div>
<!-- Loading indicator -->
<div id="loading" style="display: none;">Chargement...</div>
<!-- Tableau des fichiers et dossiers -->
<table id="files-table" style="display: none;">
<thead>
<tr>
<th>Nom</th>
<th>Taille</th>
<th>Date de modification</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="files-tbody">
</tbody>
</table>
<hr>
<address>MinIO Explorer</address>
<script src="script.js"></script>
</body>
</html>

422
script.js Normal file
View File

@@ -0,0 +1,422 @@
// Configuration globale
let config = {
baseUrl: '',
currentPath: ''
};
// Initialisation de l'application
document.addEventListener('DOMContentLoaded', function() {
initializeApp();
setupEventListeners();
});
function initializeApp() {
// Tentative de configuration automatique depuis l'URL courante
autoConfigureFromURL();
// Affichage du formulaire de développement
showDevForm();
}
function setupEventListeners() {
// Formulaire de développement
document.getElementById('url-form').addEventListener('submit', handleUrlFormSubmit);
// Gestion de la navigation arrière/avant du navigateur
window.addEventListener('popstate', handlePopState);
}
function handlePopState(event) {
console.log('Navigation arrière/avant détectée:', event.state);
if (event.state && event.state.baseUrl) {
// Restaurer la configuration
config.baseUrl = event.state.baseUrl;
config.currentPath = event.state.path || '';
updateConfigDisplay();
// Charger le contenu sans ajouter à l'historique (pour éviter les boucles)
loadBucketContents(config.currentPath, false);
}
}
function autoConfigureFromURL() {
const currentUrl = window.location.href;
const url = new URL(currentUrl);
// Configuration simplifiée : on prend juste le base URL sans le bucket
config.baseUrl = `${url.protocol}//${url.host}${url.pathname.replace('/index.html', '')}`;
// Vérifier s'il y a un fragment d'URL pour définir le chemin initial
if (url.hash) {
const hashPath = url.hash.replace('#/', '');
config.currentPath = hashPath;
}
updateConfigDisplay();
console.log('Configuration automatique:', config);
}
function handleUrlFormSubmit(event) {
event.preventDefault();
const urlInput = document.getElementById('minio-url');
const minioUrl = urlInput.value.trim();
if (!minioUrl) {
showError('Veuillez saisir une URL MinIO');
return;
}
parseMinioURL(minioUrl);
}
function parseMinioURL(url) {
try {
const urlObj = new URL(url);
// Configuration simplifiée : on prend l'URL complète comme base
config.baseUrl = url.replace(/\/$/, ''); // Supprimer le slash final si présent
config.currentPath = '';
updateConfigDisplay();
// Ajouter l'état initial à l'historique
const initialState = { path: '', baseUrl: config.baseUrl };
history.replaceState(initialState, '', window.location.href);
loadBucketContents();
} catch (error) {
showError('URL invalide: ' + error.message);
console.error('Erreur de parsing URL:', error);
}
}
function updateConfigDisplay() {
const configInfo = document.getElementById('config-info');
configInfo.innerHTML = `
<strong>Configuration actuelle:</strong><br>
URL de base: ${config.baseUrl}<br>
Chemin actuel: /${config.currentPath}
`;
}
function showDevForm() {
const devForm = document.getElementById('dev-form');
devForm.style.display = 'block';
}
function showError(message) {
const errorSection = document.getElementById('error-section');
const errorMessage = document.getElementById('error-message');
errorMessage.textContent = message;
errorSection.style.display = 'block';
// Masquer l'erreur après 5 secondes
setTimeout(() => {
errorSection.style.display = 'none';
}, 5000);
}
function showLoading(show = true) {
const loading = document.getElementById('loading');
const table = document.getElementById('files-table');
if (show) {
loading.style.display = 'block';
table.style.display = 'none';
} else {
loading.style.display = 'none';
table.style.display = 'table';
}
}
function updateCurrentPathDisplay() {
const pathDisplay = document.getElementById('current-path');
pathDisplay.textContent = '/' + config.currentPath;
}
async function loadBucketContents(path = '', pushToHistory = true) {
console.log('Debug loadBucketContents - path received:', JSON.stringify(path));
if (!config.baseUrl) {
showError('Configuration URL manquante');
return;
}
showLoading(true);
config.currentPath = path;
updateCurrentPathDisplay();
// Ajouter à l'historique du navigateur si demandé
if (pushToHistory) {
const state = { path: path, baseUrl: config.baseUrl };
const url = path ? `#/${path}` : '#/';
history.pushState(state, '', url);
}
try {
// Construction de l'URL API avec delimiter
let apiUrl = `${config.baseUrl}?list-type=2&delimiter=/`;
// Construire le prefix pour l'API MinIO
if (path) {
// Pour un sous-dossier
const cleanPath = path.startsWith('/') ? path.substring(1) : path;
const fullPrefix = cleanPath + '/';
apiUrl += `&prefix=${encodeURIComponent(fullPrefix)}`;
}
console.log('Appel API MinIO:', apiUrl);
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`Erreur HTTP: ${response.status}`);
}
const xmlText = await response.text();
console.log('Réponse XML reçue:', xmlText);
const contents = parseMinioXML(xmlText);
displayContents(contents);
} catch (error) {
console.error('Erreur lors du chargement:', error);
showError('Erreur de chargement: ' + error.message);
} finally {
showLoading(false);
}
}
function parseMinioXML(xmlText) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
const contents = {
folders: [],
files: []
};
// Déterminer le préfixe attendu basé sur la navigation actuelle
let expectedPrefix = '';
if (config.currentPath) {
expectedPrefix = config.currentPath + '/';
}
// Parse CommonPrefixes (dossiers)
const prefixes = xmlDoc.getElementsByTagName('CommonPrefixes');
for (let prefix of prefixes) {
const prefixElement = prefix.getElementsByTagName('Prefix')[0];
if (prefixElement) {
const fullPrefix = prefixElement.textContent;
// Extraire le nom du dossier (dernière partie avant le /)
const folderName = fullPrefix.replace(/\/$/, '').split('/').pop();
if (folderName) {
// Construire le chemin pour la navigation
let folderPath;
if (config.currentPath) {
folderPath = config.currentPath + '/' + folderName;
} else {
folderPath = folderName;
}
console.log('Debug parsing - fullPrefix:', fullPrefix, 'folderName:', folderName, 'currentPath:', config.currentPath, 'folderPath:', folderPath);
contents.folders.push({
name: folderName,
path: folderPath
});
}
}
}
// Parse Contents (fichiers)
const objects = xmlDoc.getElementsByTagName('Contents');
for (let obj of objects) {
const keyElement = obj.getElementsByTagName('Key')[0];
const sizeElement = obj.getElementsByTagName('Size')[0];
const modifiedElement = obj.getElementsByTagName('LastModified')[0];
if (keyElement) {
const fullKey = keyElement.textContent;
const fileName = fullKey.split('/').pop();
// Vérifier que le fichier correspond au niveau de navigation actuel
let shouldInclude = false;
if (config.currentPath) {
// Dans un sous-dossier : vérifier que la clé commence par le bon préfixe
const currentPrefix = config.currentPath + '/';
if (fullKey.startsWith(currentPrefix)) {
// S'assurer qu'on n'affiche que les fichiers directs (pas dans des sous-sous-dossiers)
const relativePath = fullKey.replace(currentPrefix, '');
if (!relativePath.includes('/')) {
shouldInclude = true;
}
}
} else {
// À la racine : afficher tous les fichiers qui ne sont pas dans des dossiers
if (!fullKey.includes('/')) {
shouldInclude = true;
}
}
// Exclure les fichiers de l'application
if (shouldInclude && fileName && !['index.html', 'style.css', 'script.js'].includes(fileName)) {
contents.files.push({
name: fileName,
size: sizeElement ? parseInt(sizeElement.textContent) : 0,
modified: modifiedElement ? new Date(modifiedElement.textContent) : new Date(),
fullKey: fullKey
});
}
}
}
return contents;
}
function displayContents(contents) {
const tbody = document.getElementById('files-tbody');
tbody.innerHTML = '';
// Bouton parent directory si on n'est pas à la racine
if (config.currentPath) {
const parentPath = config.currentPath.split('/').slice(0, -1).join('/');
const parentRow = createParentRow(parentPath);
tbody.appendChild(parentRow);
}
// Afficher les dossiers
contents.folders.forEach(folder => {
const row = createFolderRow(folder);
tbody.appendChild(row);
});
// Afficher les fichiers
contents.files.forEach(file => {
const row = createFileRow(file);
tbody.appendChild(row);
});
// Afficher le tableau
document.getElementById('files-table').style.display = 'table';
}
function createParentRow(parentPath) {
const row = document.createElement('tr');
row.innerHTML = `
<td>
<span class="icon-parent"></span>
<a href="#" class="folder-link parent-dir" data-path="${parentPath}">..</a>
</td>
<td>-</td>
<td>-</td>
<td>-</td>
`;
const link = row.querySelector('.folder-link');
link.addEventListener('click', (e) => {
e.preventDefault();
loadBucketContents(parentPath, true);
});
return row;
}
function createFolderRow(folder) {
const row = document.createElement('tr');
row.innerHTML = `
<td>
<span class="icon-folder"></span>
<a href="#" class="folder-link" data-path="${folder.path}">${folder.name}/</a>
</td>
<td>-</td>
<td>-</td>
<td>-</td>
`;
const link = row.querySelector('.folder-link');
link.addEventListener('click', (e) => {
e.preventDefault();
loadBucketContents(folder.path, true);
});
return row;
}
function createFileRow(file) {
const row = document.createElement('tr');
const fileUrl = `${config.baseUrl}/${file.fullKey}`;
row.innerHTML = `
<td>
<span class="icon-file"></span>
<a href="${fileUrl}" class="file-link" target="_blank">${file.name}</a>
</td>
<td class="file-size">${formatFileSize(file.size)}</td>
<td class="file-date">${formatDate(file.modified)}</td>
<td>
<button class="copy-btn" data-url="${fileUrl}">Copier lien</button>
</td>
`;
const copyBtn = row.querySelector('.copy-btn');
copyBtn.addEventListener('click', () => copyToClipboard(fileUrl));
return row;
}
function 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(1)) + ' ' + sizes[i];
}
function formatDate(date) {
return date.toLocaleString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
console.log('Lien copié:', text);
// Feedback visuel temporaire
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = 'Copié !';
setTimeout(() => {
btn.textContent = originalText;
}, 1000);
} catch (error) {
console.error('Erreur copie clipboard:', error);
// Fallback pour les navigateurs plus anciens
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
}

188
style.css Normal file
View File

@@ -0,0 +1,188 @@
/* Style type "Index of" Apache classique */
body {
font-family: monospace;
margin: 20px;
background-color: white;
color: black;
}
h1 {
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
}
/* Section développement */
.dev-section {
background-color: #f0f0f0;
border: 1px solid #ccc;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.dev-section h2 {
font-size: 16px;
margin-top: 0;
margin-bottom: 10px;
color: #333;
}
.dev-section form {
margin-bottom: 10px;
}
.dev-section label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.dev-section input[type="text"] {
width: 400px;
padding: 5px;
border: 1px solid #ccc;
font-family: monospace;
}
.dev-section button {
padding: 5px 15px;
margin-left: 10px;
background-color: #007acc;
color: white;
border: none;
cursor: pointer;
}
.dev-section button:hover {
background-color: #005999;
}
#config-info {
font-size: 12px;
color: #666;
margin-top: 10px;
}
/* Messages d'erreur */
.error {
background-color: #ffe6e6;
border: 1px solid #ff9999;
padding: 10px;
margin-bottom: 20px;
color: #cc0000;
border-radius: 4px;
}
/* Loading */
#loading {
font-style: italic;
color: #666;
margin: 20px 0;
}
/* Tableau des fichiers */
table {
border-collapse: collapse;
width: 100%;
font-family: monospace;
font-size: 13px;
}
th {
background-color: #f0f0f0;
border: 1px solid #ccc;
padding: 8px;
text-align: left;
font-weight: bold;
}
td {
border: 1px solid #ccc;
padding: 8px;
vertical-align: top;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
tr:hover {
background-color: #e6f3ff;
}
/* Liens et dossiers */
.folder-link {
color: #0066cc;
text-decoration: none;
font-weight: bold;
}
.folder-link:hover {
text-decoration: underline;
}
.file-link {
color: #0066cc;
text-decoration: none;
}
.file-link:hover {
text-decoration: underline;
}
.parent-dir {
font-weight: bold;
}
/* Bouton copier lien */
.copy-btn {
background-color: #f0f0f0;
border: 1px solid #ccc;
padding: 2px 6px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.copy-btn:hover {
background-color: #e0e0e0;
}
.copy-btn:active {
background-color: #d0d0d0;
}
/* Tailles de fichiers */
.file-size {
text-align: right;
font-family: monospace;
}
.file-date {
font-family: monospace;
white-space: nowrap;
}
/* Footer */
address {
margin-top: 30px;
font-style: italic;
font-size: 12px;
color: #666;
border-top: 1px solid #ccc;
padding-top: 10px;
}
/* Icônes (caractères simples) */
.icon-folder::before {
content: "📁 ";
}
.icon-file::before {
content: "📄 ";
}
.icon-parent::before {
content: "⬆️ ";
}