Frontend et backend entièrement réels : plus aucune donnée simulée
All checks were successful
CI / build-and-test (push) Successful in 2m14s
CI / deploy (push) Successful in 3m17s

À 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>
This commit is contained in:
Adam Belkacemi 2026-09-17 18:19:51 +02:00
parent 5816c7eda6
commit a639b705a6
53 changed files with 1352 additions and 587 deletions

View file

@ -1,33 +1,143 @@
import { THUMB_TONES } from '../lib/gradients';
import type { FileItem, StatItem } from '../types';
import { mockFetch } from './client';
import { formatBytes, formatRelativeTime } from '../lib/format';
import type { FileItem, FileKind, StatItem } from '../types';
import { apiFetch } from './client';
const RAW_FILES: Array<Omit<FileItem, 'id' | 'thumbGradient'>> = [
{ name: 'Archives_fiscales_2026.zip', kind: 'archive', extLabel: 'Archive ZIP', size: '2,4 Go', modified: 'il y a 12 min' },
{ name: 'Contrats_signes_2026', kind: 'folder', extLabel: 'Dossier · 412 éléments', size: '18,7 Go', modified: 'il y a 2 h' },
{ name: 'Acte_de_propriete.pdf', kind: 'document', extLabel: 'Document PDF', size: '1,1 Mo', modified: 'hier' },
{ name: 'Backup_poste_principal', kind: 'backup', extLabel: 'Sauvegarde', size: '64 Go', modified: 'hier' },
{ name: 'Conseil_administration.mp4', kind: 'video', extLabel: 'Vidéo MP4', size: '820 Mo', modified: 'il y a 3 jours' },
{ name: 'Scans_administratifs', kind: 'folder', extLabel: 'Dossier · 38 éléments', size: '240 Mo', modified: 'il y a 5 jours' },
{ name: 'Statuts_et_annexes.docx', kind: 'document', extLabel: 'Document Word', size: '96 Ko', modified: 'il y a 1 sem.' },
{ name: 'Numerisations_1998', kind: 'folder', extLabel: 'Dossier · 219 éléments', size: '3,2 Go', modified: 'il y a 2 sem.' },
];
interface FileView {
id: string;
filename: string;
content_type: string;
category: string;
size_bytes: number;
created_at: string;
}
export async function getFiles(): Promise<FileItem[]> {
return mockFetch(
RAW_FILES.map((f, i) => ({
...f,
id: `file_${i}`,
thumbGradient: THUMB_TONES[i % THUMB_TONES.length]!,
})),
);
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[]> {
return mockFetch([
{ id: 'stat_storage', label: 'Espace utilisé', value: '812 Go', note: '188 Go disponibles', icon: 'hard-drive', tone: 'brand' },
{ id: 'stat_docs', label: 'Documents', value: '12 480', note: '+142 cette semaine', icon: 'image', tone: 'brand' },
{ id: 'stat_devices', label: 'Postes', value: '4', note: 'tous scellés', icon: 'smartphone', tone: 'success' },
{ id: 'stat_access', label: 'Accès actifs', value: '7', note: '3 liens expirent bientôt', icon: 'share', tone: 'violet' },
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',
},
];
}