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:
commit
5dbd3b96f6
73 changed files with 4789 additions and 0 deletions
50
app/src/hooks/useAuthForm.ts
Normal file
50
app/src/hooks/useAuthForm.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
17
app/src/hooks/useBackgroundCrossfade.ts
Normal file
17
app/src/hooks/useBackgroundCrossfade.ts
Normal 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;
|
||||
}
|
||||
11
app/src/hooks/useBackups.ts
Normal file
11
app/src/hooks/useBackups.ts
Normal 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;
|
||||
}
|
||||
11
app/src/hooks/useDashboardStats.ts
Normal file
11
app/src/hooks/useDashboardStats.ts
Normal 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;
|
||||
}
|
||||
17
app/src/hooks/useIsMobile.ts
Normal file
17
app/src/hooks/useIsMobile.ts
Normal 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;
|
||||
}
|
||||
20
app/src/hooks/useNotifications.ts
Normal file
20
app/src/hooks/useNotifications.ts
Normal 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) };
|
||||
}
|
||||
18
app/src/hooks/usePhotos.ts
Normal file
18
app/src/hooks/usePhotos.ts
Normal 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 };
|
||||
}
|
||||
45
app/src/hooks/useRecentFiles.ts
Normal file
45
app/src/hooks/useRecentFiles.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
app/src/hooks/useSettings.ts
Normal file
51
app/src/hooks/useSettings.ts
Normal 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 };
|
||||
}
|
||||
10
app/src/hooks/useStorageSummary.ts
Normal file
10
app/src/hooks/useStorageSummary.ts
Normal 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;
|
||||
}
|
||||
44
app/src/hooks/useUploadSimulation.ts
Normal file
44
app/src/hooks/useUploadSimulation.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue