Import Tripoli Cloud frontend prototype

React/TypeScript/Vite/Tailwind prototype from the design session (login,
dashboard, photos, backups, settings views with mocked API layer).
Infra work (Rust services, Ansible, OpenTofu) follows in subsequent commits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-09-16 21:21:43 +02:00
commit 5dbd3b96f6
73 changed files with 4789 additions and 0 deletions

View file

@ -0,0 +1,50 @@
import { useState, type FormEvent } from 'react';
import { useSession } from '../contexts/SessionContext';
import { isValidEmail, isValidPassword } from '../lib/validators';
import type { AuthMode } from '../types';
export function useAuthForm() {
const { login, signup, continueAsGuest, loading } = useSession();
const [mode, setModeState] = useState<AuthMode>('login');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [submitted, setSubmitted] = useState(false);
const setMode = (next: AuthMode) => {
setModeState(next);
setSubmitted(false);
};
const emailError = submitted && !isValidEmail(email);
const passwordError = submitted && !isValidPassword(password);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!isValidEmail(email) || !isValidPassword(password)) {
setSubmitted(true);
return;
}
if (mode === 'signup') {
void signup({ name, email, password });
} else {
void login({ email, password });
}
};
return {
mode,
setMode,
name,
setName,
email,
setEmail,
password,
setPassword,
emailError,
passwordError,
handleSubmit,
continueAsGuest,
loading,
};
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import { BACKGROUND_IMAGES } from '../lib/backgrounds';
const INTERVAL_MS = 5400;
export function useBackgroundCrossfade(): number {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % BACKGROUND_IMAGES.length);
}, INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
return index;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { getBackups } from '../api/backups';
import type { BackupDevice } from '../types';
export function useBackups() {
const [backups, setBackups] = useState<BackupDevice[]>([]);
useEffect(() => {
void getBackups().then(setBackups);
}, []);
return backups;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from 'react';
import { getDashboardStats } from '../api/files';
import type { StatItem } from '../types';
export function useDashboardStats() {
const [stats, setStats] = useState<StatItem[]>([]);
useEffect(() => {
void getDashboardStats().then(setStats);
}, []);
return stats;
}

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
/** Mirrors Tailwind's `md` breakpoint (768px) — the sidebar becomes a drawer below it. */
const MOBILE_QUERY = '(max-width: 767px)';
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
useEffect(() => {
const mql = window.matchMedia(MOBILE_QUERY);
const onChange = () => setIsMobile(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isMobile;
}

View file

@ -0,0 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import { getNotifications } from '../api/notifications';
import type { NotificationItem } from '../types';
export function useNotifications() {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [allRead, setAllRead] = useState(false);
useEffect(() => {
void getNotifications().then(setNotifications);
}, []);
const items = useMemo(
() => notifications.map((n) => ({ ...n, unread: n.unread && !allRead })),
[notifications, allRead],
);
const unreadCount = items.filter((n) => n.unread).length;
return { notifications: items, unreadCount, markAllRead: () => setAllRead(true) };
}

View file

@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
import { getAlbums, getPhotoTiles } from '../api/photos';
import type { Album, PhotoScope, PhotoTile } from '../types';
const SCOPES: PhotoScope[] = ['Tout', 'Personnes', 'Lieux'];
export function usePhotos() {
const [albums, setAlbums] = useState<Album[]>([]);
const [tiles, setTiles] = useState<PhotoTile[]>([]);
const [scope, setScope] = useState<PhotoScope>('Tout');
useEffect(() => {
void getAlbums().then(setAlbums);
void getPhotoTiles().then(setTiles);
}, []);
return { albums, tiles, scopes: SCOPES, scope, setScope };
}

View file

@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react';
import { getFiles } from '../api/files';
import type { FileFilter, FileItem } from '../types';
const FILTERS: FileFilter[] = ['Tous', 'Dossiers', 'Documents', 'Médias'];
function matchesFilter(file: FileItem, filter: FileFilter): boolean {
switch (filter) {
case 'Tous':
return true;
case 'Dossiers':
return file.kind === 'folder';
case 'Documents':
return file.kind === 'document';
case 'Médias':
return file.kind === 'video' || file.kind === 'image';
}
}
export function useRecentFiles() {
const [files, setFiles] = useState<FileItem[]>([]);
const [filter, setFilter] = useState<FileFilter>('Tous');
const [sortDesc, setSortDesc] = useState(true);
const [gridView, setGridView] = useState(true);
useEffect(() => {
void getFiles().then(setFiles);
}, []);
const visibleFiles = useMemo(() => {
const filtered = files.filter((f) => matchesFilter(f, filter));
return sortDesc ? filtered : filtered.slice().reverse();
}, [files, filter, sortDesc]);
return {
files: visibleFiles,
filters: FILTERS,
filter,
setFilter,
sortDesc,
toggleSort: () => setSortDesc((v) => !v),
gridView,
setGridView,
};
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import {
getDefaultNotificationPrefs,
getStaticSettingsRows,
NOTIFICATION_PREF_LABELS,
type StaticSettingsRows,
} from '../api/settings';
import type { NotificationPrefKey, SettingsRow, SettingsTabKey } from '../types';
const TAB_TITLES: Record<SettingsTabKey, string> = {
language: 'Langue et région',
notifications: 'Préférences de notification',
account: 'Paramètres du compte',
};
export const SETTINGS_TABS: Array<{ key: SettingsTabKey; label: string }> = [
{ key: 'language', label: 'Langue et région' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'account', label: 'Compte' },
];
export function useSettings(activeTab: SettingsTabKey) {
const [staticRows, setStaticRows] = useState<StaticSettingsRows | null>(null);
const [prefs, setPrefs] = useState<Record<NotificationPrefKey, boolean> | null>(null);
useEffect(() => {
void getStaticSettingsRows().then(setStaticRows);
void getDefaultNotificationPrefs().then(setPrefs);
}, []);
const togglePref = (key: NotificationPrefKey) => {
setPrefs((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev));
};
let rows: SettingsRow[] = [];
if (activeTab === 'language') {
rows = staticRows?.language ?? [];
} else if (activeTab === 'account') {
rows = staticRows?.account ?? [];
} else if (prefs) {
rows = (Object.keys(NOTIFICATION_PREF_LABELS) as NotificationPrefKey[]).map((key) => ({
kind: 'toggle',
id: key,
label: NOTIFICATION_PREF_LABELS[key].label,
help: NOTIFICATION_PREF_LABELS[key].help,
on: prefs[key],
}));
}
return { title: TAB_TITLES[activeTab], rows, togglePref };
}

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from 'react';
import { getStorageSummary, type StorageSummary } from '../api/vault';
export function useStorageSummary() {
const [summary, setSummary] = useState<StorageSummary | null>(null);
useEffect(() => {
void getStorageSummary().then(setSummary);
}, []);
return summary;
}

View file

@ -0,0 +1,44 @@
import { useEffect, useRef, useState, type DragEvent } from 'react';
/**
* Drives the UploadZone's demo transfer. `externalProgress` lets a caller
* override the simulated value with real numbers later (the component
* itself never knows the difference) — this is the `progress` prop the
* brief asked to expose.
*/
export function useUploadSimulation(externalProgress?: number) {
const [uploading, setUploading] = useState(false);
const [internalProgress, setInternalProgress] = useState(0);
const [dragging, setDragging] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearInterval(timerRef.current), []);
const start = () => {
window.clearInterval(timerRef.current);
setUploading(true);
setInternalProgress(0);
setDragging(false);
timerRef.current = window.setInterval(() => {
setInternalProgress((p) => {
const next = Math.min(100, p + 4 + Math.random() * 7);
if (next >= 100) window.clearInterval(timerRef.current);
return next;
});
}, 280);
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
setDragging(true);
};
const onDragLeave = () => setDragging(false);
const onDrop = (e: DragEvent) => {
e.preventDefault();
start();
};
const progress = Math.round(externalProgress ?? internalProgress);
return { uploading, progress, dragging, start, onDragOver, onDragLeave, onDrop };
}