feat: first version
This commit is contained in:
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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
51
index.html
Normal 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
422
script.js
Normal 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
188
style.css
Normal 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: "⬆️ ";
|
||||
}
|
||||
Reference in New Issue
Block a user