À la demande explicite de l'utilisateur après avoir constaté que tripoli.cloud affichait un 404 vide (le frontend n'avait jamais été déployé) : déploiement du frontend, puis remplacement complet des mocks par de vraies données, des deux côtés. Backend (tripoli-api) : - users.name, files.category (file/photo/backup), table notifications (migration 0002). - POST /api/auth/guest : compte invité réel et réutilisé (invite@tripoli.local), pas une identité simulée côté frontend. - GET /api/files?category=... , GET /api/notifications (+ read-all/ {id}/read), GET /api/storage (used_bytes réel, available_bytes via statvfs - jamais un quota inventé). - Notifications émises réellement sur upload/suppression. Frontend (app/) : - src/api/*.ts appellent réellement tripoli-api (plus de mockFetch). - Upload réel (XHR, vraie progression), suppression avec bouton dédié, vraies miniatures photo (<img src="/api/files/:id">). - Session restaurée au chargement (GET /api/auth/me), logout réel. - Retiré plutôt que simulé : 2FA, clé de récupération, accès partagés, albums, dossiers, "4 appareils sauvegardés", chiffres marketing et claim AES-256 côté client sur la page de connexion (jamais vrai - remplacé par le vrai : HTTPS + Argon2). Déploiement : - Frontend construit et déployé sur le serveur (le build local échouait spécifiquement sur cette machine - rolldown/esbuild natif ne répondait plus, cause non résolue ; contourné en buildant sur le serveur, comme déjà fait pour les crates Rust). - Caddy : /var/www/tripoli-cloud servi en statique pour tripoli.cloud, /api/*, /healthz, /metrics toujours proxyés vers tripoli-edge. - Pipeline CI étendu : le job deploy construit et déploie maintenant aussi le frontend, via un script sudo dédié supplémentaire (tripoli-deploy-frontend.sh) sur le compte deploy sans privilège. - Vérifié en navigateur réel (Chrome) : inscription, connexion, invité, upload avec progression, photos, sauvegardes, notifications, suppression, déconnexion - tous testés en conditions réelles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
4.1 KiB
TypeScript
143 lines
4.1 KiB
TypeScript
import { THUMB_TONES } from '../lib/gradients';
|
|
import { formatBytes, formatRelativeTime } from '../lib/format';
|
|
import type { FileItem, FileKind, StatItem } from '../types';
|
|
import { apiFetch } from './client';
|
|
|
|
interface FileView {
|
|
id: string;
|
|
filename: string;
|
|
content_type: string;
|
|
category: string;
|
|
size_bytes: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface StorageView {
|
|
used_bytes: number;
|
|
available_bytes: number;
|
|
}
|
|
|
|
function kindOf(view: FileView): FileKind {
|
|
if (view.category === 'backup') return 'backup';
|
|
if (view.content_type.startsWith('image/')) return 'image';
|
|
if (view.content_type.startsWith('video/')) return 'video';
|
|
if (
|
|
view.content_type.includes('zip') ||
|
|
view.content_type.includes('gzip') ||
|
|
view.content_type.includes('tar') ||
|
|
view.content_type.includes('compressed')
|
|
) {
|
|
return 'archive';
|
|
}
|
|
return 'document';
|
|
}
|
|
|
|
function extLabelOf(view: FileView, kind: FileKind): string {
|
|
const ext = view.filename.includes('.') ? view.filename.split('.').pop()!.toUpperCase() : '';
|
|
const byKind: Record<FileKind, string> = {
|
|
image: 'Image',
|
|
video: 'Vidéo',
|
|
archive: 'Archive',
|
|
backup: 'Sauvegarde',
|
|
document: 'Document',
|
|
folder: 'Dossier',
|
|
};
|
|
return ext ? `${byKind[kind]} ${ext}` : byKind[kind];
|
|
}
|
|
|
|
function toFileItem(view: FileView, index: number): FileItem {
|
|
const kind = kindOf(view);
|
|
return {
|
|
id: view.id,
|
|
name: view.filename,
|
|
kind,
|
|
extLabel: extLabelOf(view, kind),
|
|
size: formatBytes(view.size_bytes),
|
|
modified: formatRelativeTime(view.created_at),
|
|
thumbGradient: THUMB_TONES[index % THUMB_TONES.length]!,
|
|
};
|
|
}
|
|
|
|
export async function getFiles(category?: string): Promise<FileItem[]> {
|
|
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
|
const views = await apiFetch<FileView[]>(`/api/files${qs}`);
|
|
return views.map(toFileItem);
|
|
}
|
|
|
|
export function downloadUrl(fileId: string): string {
|
|
return `/api/files/${fileId}`;
|
|
}
|
|
|
|
export async function deleteFile(fileId: string): Promise<void> {
|
|
await apiFetch<void>(`/api/files/${fileId}`, { method: 'DELETE' });
|
|
}
|
|
|
|
/**
|
|
* Upload réel via XMLHttpRequest (pas `fetch`) : c'est la seule API du
|
|
* navigateur qui expose un vrai événement de progression sur le corps
|
|
* envoyé (`upload.onprogress`) - `fetch` n'a pas d'équivalent côté
|
|
* requête sortante.
|
|
*/
|
|
export function uploadFile(
|
|
file: File,
|
|
category: 'file' | 'photo' | 'backup',
|
|
onProgress: (percent: number) => void,
|
|
): Promise<FileItem> {
|
|
return new Promise((resolve, reject) => {
|
|
const form = new FormData();
|
|
form.append('category', category);
|
|
form.append('file', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/files');
|
|
xhr.withCredentials = true;
|
|
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
|
};
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
const view = JSON.parse(xhr.responseText) as FileView;
|
|
resolve(toFileItem(view, 0));
|
|
} else {
|
|
let message = xhr.statusText;
|
|
try {
|
|
message = (JSON.parse(xhr.responseText) as { error?: string }).error ?? message;
|
|
} catch {
|
|
// pas de corps JSON exploitable
|
|
}
|
|
reject(new Error(message));
|
|
}
|
|
};
|
|
xhr.onerror = () => reject(new Error('Échec réseau pendant le transfert'));
|
|
|
|
xhr.send(form);
|
|
});
|
|
}
|
|
|
|
export async function getDashboardStats(): Promise<StatItem[]> {
|
|
const [files, storage] = await Promise.all([
|
|
apiFetch<FileView[]>('/api/files'),
|
|
apiFetch<StorageView>('/api/storage'),
|
|
]);
|
|
|
|
return [
|
|
{
|
|
id: 'stat_storage',
|
|
label: 'Espace utilisé',
|
|
value: formatBytes(storage.used_bytes),
|
|
note: `${formatBytes(storage.available_bytes)} disponibles sur le serveur`,
|
|
icon: 'hard-drive',
|
|
tone: 'brand',
|
|
},
|
|
{
|
|
id: 'stat_docs',
|
|
label: 'Fichiers',
|
|
value: String(files.length),
|
|
note: files.length > 0 ? `dernier dépôt ${formatRelativeTime(files[0]!.created_at)}` : 'aucun dépôt',
|
|
icon: 'image',
|
|
tone: 'success',
|
|
},
|
|
];
|
|
}
|