Frontend et backend entièrement réels : plus aucune donnée simulée
À 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:
parent
5816c7eda6
commit
a639b705a6
53 changed files with 1352 additions and 587 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import type { User } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { ApiError, apiFetch } from './client';
|
||||
|
||||
export interface Credentials {
|
||||
email: string;
|
||||
|
|
@ -10,6 +10,14 @@ export interface SignupPayload extends Credentials {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface UserView {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_guest: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '??';
|
||||
|
|
@ -19,35 +27,49 @@ function initialsOf(name: string): string {
|
|||
.join('');
|
||||
}
|
||||
|
||||
export async function login({ email }: Credentials): Promise<User> {
|
||||
return mockFetch({
|
||||
id: 'usr_1',
|
||||
name: 'Leïla Barghouti',
|
||||
initials: 'LB',
|
||||
email,
|
||||
role: 'Administratrice',
|
||||
isGuest: false,
|
||||
});
|
||||
function toUser(view: UserView): User {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
initials: initialsOf(view.name || view.email),
|
||||
email: view.email,
|
||||
role: view.is_guest ? 'Accès invité' : 'Compte personnel',
|
||||
isGuest: view.is_guest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function signup({ name, email }: SignupPayload): Promise<User> {
|
||||
return mockFetch({
|
||||
id: 'usr_new',
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
email,
|
||||
role: 'Administratrice',
|
||||
isGuest: false,
|
||||
export async function login({ email, password }: Credentials): Promise<User> {
|
||||
const view = await apiFetch<UserView>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return toUser(view);
|
||||
}
|
||||
|
||||
export async function signup({ name, email, password }: SignupPayload): Promise<User> {
|
||||
const view = await apiFetch<UserView>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
return toUser(view);
|
||||
}
|
||||
|
||||
export async function loginAsGuest(): Promise<User> {
|
||||
return mockFetch({
|
||||
id: 'usr_guest',
|
||||
name: 'Invité',
|
||||
initials: 'IN',
|
||||
email: '',
|
||||
role: 'Accès invité',
|
||||
isGuest: true,
|
||||
});
|
||||
const view = await apiFetch<UserView>('/api/auth/guest', { method: 'POST' });
|
||||
return toUser(view);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
/** Restaure la session depuis le cookie au chargement de la page. `null` si non connecté. */
|
||||
export async function fetchCurrentUser(): Promise<User | null> {
|
||||
try {
|
||||
const view = await apiFetch<UserView>('/api/auth/me');
|
||||
return toUser(view);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,25 @@
|
|||
import type { BackupDevice } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatBytes, formatRelativeTime } from '../lib/format';
|
||||
import type { BackupItem } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const STATUS_LABEL: Record<BackupDevice['status'], string> = {
|
||||
'up-to-date': 'À jour',
|
||||
syncing: 'En cours',
|
||||
late: 'Retard 2 j',
|
||||
};
|
||||
|
||||
const RAW: Array<Omit<BackupDevice, 'id' | 'statusLabel'>> = [
|
||||
{ device: 'Poste principal', detail: '64 Go · documents, messages, réglages', status: 'up-to-date', icon: 'smartphone' },
|
||||
{ device: 'Portable de travail', detail: '212 Go · dossier de travail et bureau', status: 'up-to-date', icon: 'laptop' },
|
||||
{ device: 'Tablette de consultation', detail: '38 Go · documents en lecture seule', status: 'syncing', icon: 'tablet' },
|
||||
{ device: 'Coffre secondaire hors site', detail: '812 Go · miroir complet du volume', status: 'late', icon: 'server' },
|
||||
];
|
||||
|
||||
export async function getBackups(): Promise<BackupDevice[]> {
|
||||
return mockFetch(RAW.map((b, i) => ({ ...b, id: `backup_${i}`, statusLabel: STATUS_LABEL[b.status] })));
|
||||
interface FileView {
|
||||
id: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pas de suivi multi-appareils (aucun agent n'est déployé sur des
|
||||
* postes clients) : une "sauvegarde" est ici un fichier déposé avec la
|
||||
* catégorie "backup", comme n'importe quel autre dépôt.
|
||||
*/
|
||||
export async function getBackups(): Promise<BackupItem[]> {
|
||||
const views = await apiFetch<FileView[]>('/api/files?category=backup');
|
||||
return views.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.filename,
|
||||
detail: `${formatBytes(v.size_bytes)} · déposé ${formatRelativeTime(v.created_at)}`,
|
||||
icon: 'server',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,42 @@
|
|||
/**
|
||||
* Every function in src/api/ is written as if it called `API_BASE`, but
|
||||
* currently resolves a local mock after a short simulated delay. Swapping
|
||||
* in a real backend later means rewriting the body of these functions only
|
||||
* — components and hooks never change.
|
||||
* Toutes les requêtes passent par la même origine (le frontend est servi
|
||||
* par le même Caddy qui route /api/* vers tripoli-edge -> tripoli-api) :
|
||||
* pas besoin de configurer une URL de base, ni de CORS.
|
||||
*/
|
||||
export const API_BASE = import.meta.env.VITE_API_URL;
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
export function mockFetch<T>(data: T, delayMs = 220): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(data), delayMs);
|
||||
});
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(init?.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
if (body.error) message = body.error;
|
||||
} catch {
|
||||
// pas de corps JSON exploitable - on garde le statusText
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,33 @@
|
|||
import type { NotificationItem } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatRelativeTime } from '../lib/format';
|
||||
import type { IconName, NotificationItem } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const RAW: Array<Omit<NotificationItem, 'id'>> = [
|
||||
{ icon: 'upload', title: 'Transfert terminé', body: 'Archives_fiscales_2026.zip a été chiffré et scellé.', time: 'il y a 12 min', unread: true },
|
||||
{ icon: 'share', title: 'Nouveau dépôt', body: '42 documents ont été ajoutés au coffre Archives.', time: 'il y a 3 h', unread: true },
|
||||
{ icon: 'check', title: 'Sauvegarde iPhone', body: '64 Go synchronisés et vérifiés, sans erreur.', time: 'hier, 23:40', unread: false },
|
||||
{ icon: 'alert', title: 'Espace disque', body: "Il reste 18 % d'espace libre sur le volume principal.", time: 'il y a 2 jours', unread: false },
|
||||
];
|
||||
interface NotificationView {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const ICON_BY_KIND: Record<string, IconName> = {
|
||||
upload: 'upload',
|
||||
delete: 'alert',
|
||||
};
|
||||
|
||||
export async function getNotifications(): Promise<NotificationItem[]> {
|
||||
return mockFetch(RAW.map((n, i) => ({ ...n, id: `notif_${i}` })));
|
||||
const views = await apiFetch<NotificationView[]>('/api/notifications');
|
||||
return views.map((v) => ({
|
||||
id: v.id,
|
||||
icon: ICON_BY_KIND[v.kind] ?? 'check',
|
||||
title: v.title,
|
||||
body: v.body,
|
||||
time: formatRelativeTime(v.created_at),
|
||||
unread: v.unread,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
await apiFetch<void>('/api/notifications/read-all', { method: 'POST' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
import { THUMB_TONES, tone } from '../lib/gradients';
|
||||
import type { Album, PhotoTile } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatDateTime } from '../lib/format';
|
||||
import type { PhotoTile } from '../types';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
const RAW_ALBUMS: Array<Omit<Album, 'id' | 'thumbGradient'>> = [
|
||||
{ name: 'Contrats', count: '412 éléments' },
|
||||
{ name: 'Titres de propriété', count: '318 éléments' },
|
||||
{ name: 'Numérisations 1998', count: '219 éléments' },
|
||||
{ name: "Pièces d'identité", count: '1 204 éléments' },
|
||||
];
|
||||
|
||||
export async function getAlbums(): Promise<Album[]> {
|
||||
return mockFetch(
|
||||
RAW_ALBUMS.map((a, i) => ({ ...a, id: `album_${i}`, thumbGradient: THUMB_TONES[i % THUMB_TONES.length]! })),
|
||||
);
|
||||
interface FileView {
|
||||
id: string;
|
||||
filename: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const TILE_TONES = ['#3b4a63', '#4a3f5f', '#51413a', '#33505a', '#3d4a41', '#46384f'];
|
||||
|
||||
/**
|
||||
* Pas de concept d'album : ce serait simuler une organisation
|
||||
* (personnes, lieux) qu'aucune fonctionnalité réelle ne produit
|
||||
* aujourd'hui. La vue Photos est une vraie grille des fichiers
|
||||
* catégorie "photo", triés par date de dépôt.
|
||||
*/
|
||||
export async function getPhotoTiles(): Promise<PhotoTile[]> {
|
||||
return mockFetch(
|
||||
Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `tile_${i}`,
|
||||
label: `16 sept. · ${9 + i}:0${i % 6}`,
|
||||
thumbGradient: tone(TILE_TONES[i % TILE_TONES.length]!, '#141821'),
|
||||
})),
|
||||
);
|
||||
const views = await apiFetch<FileView[]>('/api/files?category=photo');
|
||||
return views.map((v) => ({
|
||||
id: v.id,
|
||||
label: formatDateTime(v.created_at),
|
||||
url: `/api/files/${v.id}`,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
import type { NotificationPrefKey, SettingsValueRow } from '../types';
|
||||
import { mockFetch } from './client';
|
||||
import { formatDateTime } from '../lib/format';
|
||||
import type { NotificationPrefKey, SettingsValueRow, User } from '../types';
|
||||
|
||||
export interface StaticSettingsRows {
|
||||
language: SettingsValueRow[];
|
||||
account: SettingsValueRow[];
|
||||
}
|
||||
const PREFS_STORAGE_KEY = 'tripoli-cloud:notification-prefs';
|
||||
|
||||
export async function getStaticSettingsRows(): Promise<StaticSettingsRows> {
|
||||
return mockFetch({
|
||||
language: [
|
||||
{ kind: 'value', id: 'lang', label: "Langue de l'interface", help: 'S’applique à tous les postes connectés à ce coffre.', value: 'Français' },
|
||||
{ kind: 'value', id: 'region', label: 'Région', help: 'Détermine le format des dates, nombres et unités.', value: 'Tunisie' },
|
||||
{ kind: 'value', id: 'tz', label: 'Fuseau horaire', help: 'Utilisé pour l’horodatage des sauvegardes.', value: 'GMT+1 · Tunis' },
|
||||
{ kind: 'value', id: 'week-start', label: 'Premier jour de la semaine', help: 'Affichage des calendriers et des vues par date.', value: 'Lundi' },
|
||||
],
|
||||
account: [
|
||||
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible par les personnes autorisées sur ce coffre.', value: 'Leïla Barghouti' },
|
||||
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: 'Sert d’identifiant de connexion.', value: 'leila@domaine.tn' },
|
||||
{ kind: 'value', id: '2fa', label: 'Double authentification', help: 'Application d’authentification requise à chaque connexion.', value: 'Activée' },
|
||||
{ kind: 'value', id: 'access', label: 'Accès autorisés', help: 'Quatre personnes disposent d’une clé de déverrouillage.', value: '4 accès' },
|
||||
{ kind: 'value', id: 'recovery-key', label: 'Clé de récupération', help: 'À conserver hors ligne — elle ne peut pas être régénérée.', value: 'Téléchargée' },
|
||||
],
|
||||
});
|
||||
/**
|
||||
* Section "Compte" construite à partir du vrai utilisateur courant.
|
||||
* Les champs fictifs de la maquette d'origine (2FA, clé de récupération,
|
||||
* accès partagés) n'ont aucune fonctionnalité réelle derrière et ont
|
||||
* été retirés plutôt que simulés.
|
||||
*/
|
||||
export function accountRows(user: User): SettingsValueRow[] {
|
||||
return [
|
||||
{ kind: 'value', id: 'display-name', label: 'Nom affiché', help: 'Visible sur ce compte.', value: user.name || '—' },
|
||||
{ kind: 'value', id: 'email', label: 'Adresse e-mail', help: "Sert d'identifiant de connexion.", value: user.email || '—' },
|
||||
{ kind: 'value', id: 'type', label: 'Type de compte', help: '', value: user.isGuest ? 'Invité (démonstration)' : 'Compte personnel' },
|
||||
];
|
||||
}
|
||||
|
||||
export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: string; help: string }> = {
|
||||
|
|
@ -31,6 +24,38 @@ export const NOTIFICATION_PREF_LABELS: Record<NotificationPrefKey, { label: stri
|
|||
shared: { label: 'Activité des partages', help: 'Quand un accès autorisé dépose ou retire un document.' },
|
||||
};
|
||||
|
||||
export async function getDefaultNotificationPrefs(): Promise<Record<NotificationPrefKey, boolean>> {
|
||||
return mockFetch({ push: true, email: false, weekly: true, shared: true });
|
||||
const DEFAULT_PREFS: Record<NotificationPrefKey, boolean> = {
|
||||
push: true,
|
||||
email: false,
|
||||
weekly: true,
|
||||
shared: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Préférences persistées dans le navigateur (localStorage), pas sur le
|
||||
* serveur : réellement sauvegardées (elles survivent à un rechargement),
|
||||
* mais propres à cet appareil - pas de synchronisation multi-appareils,
|
||||
* ce serait mentir sur une fonctionnalité qui n'existe pas.
|
||||
*/
|
||||
export function getNotificationPrefs(): Record<NotificationPrefKey, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_PREFS;
|
||||
return { ...DEFAULT_PREFS, ...(JSON.parse(raw) as Partial<Record<NotificationPrefKey, boolean>>) };
|
||||
} catch {
|
||||
return DEFAULT_PREFS;
|
||||
}
|
||||
}
|
||||
|
||||
export function setNotificationPrefs(prefs: Record<NotificationPrefKey, boolean>): void {
|
||||
try {
|
||||
localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
// stockage indisponible (navigation privée, quota) - la préférence
|
||||
// reste simplement en mémoire pour cette session
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMemberSince(isoDate: string): string {
|
||||
return `Membre depuis le ${formatDateTime(isoDate)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import { mockFetch } from './client';
|
||||
import { formatBytes } from '../lib/format';
|
||||
import { apiFetch } from './client';
|
||||
|
||||
interface StorageView {
|
||||
used_bytes: number;
|
||||
available_bytes: number;
|
||||
}
|
||||
|
||||
export interface StorageSummary {
|
||||
label: string;
|
||||
percent: number;
|
||||
sealedNote: string;
|
||||
}
|
||||
|
||||
/** Pas de quota fictif du type "1 To" : le total affiché est l'espace
|
||||
* réellement disponible sur le disque du serveur (`statvfs`), pas un
|
||||
* chiffre inventé. */
|
||||
export async function getStorageSummary(): Promise<StorageSummary> {
|
||||
return mockFetch({ label: '812 Go / 1 To', percent: 81, sealedNote: 'Coffre scellé il y a 2 h' });
|
||||
const view = await apiFetch<StorageView>('/api/storage');
|
||||
const total = view.used_bytes + view.available_bytes;
|
||||
const percent = total > 0 ? Math.round((view.used_bytes / total) * 100) : 0;
|
||||
return {
|
||||
label: `${formatBytes(view.used_bytes)} / ${formatBytes(total)}`,
|
||||
percent,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue