feat: add app download
ci-api / test (push) Successful in 27m41s
ci-web / test (push) Successful in 14m6s

This commit is contained in:
Xor290
2026-08-09 15:35:29 +02:00
parent 0609ca30d0
commit d8794358c5
13 changed files with 520 additions and 24 deletions
+2
View File
@@ -10,6 +10,7 @@ import { Demos } from './pages/backoffice/Demos'
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
import { Codes } from './pages/backoffice/Codes'
import { Subscription } from './pages/backoffice/Subscription'
import { AppDownloads } from './pages/backoffice/AppDownloads'
import { Profile } from './pages/backoffice/Profile'
import { PublicLayout } from './components/PublicLayout'
import { BackofficeLayout } from './components/BackofficeLayout'
@@ -103,6 +104,7 @@ export function App() {
}
/>
<Route path="subscription" element={<Subscription />} />
<Route path="downloads" element={<AppDownloads />} />
<Route path="profile" element={<Profile />} />
</Route>
+6
View File
@@ -55,6 +55,7 @@ export function BackofficeLayout() {
</Heading>
<HStack spacing={1} display={{ base: 'none', md: 'flex' }}>
{isClient && <NavItem to="/app/subscription">Abonnement</NavItem>}
{isClient && <NavItem to="/app/downloads">Applications</NavItem>}
{(isAdmin || isClient) && <NavItem to="/app/profile">Profile</NavItem>}
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
@@ -99,6 +100,11 @@ export function BackofficeLayout() {
Abonnement
</NavItem>
)}
{isClient && (
<NavItem to="/app/downloads" onClick={onClose} mobile>
Applications
</NavItem>
)}
{isAdmin && (
<NavItem to="/app/demos" onClick={onClose} mobile>
Démos
+19
View File
@@ -147,6 +147,16 @@ export interface AlertSettings {
telegram_chat_id: string
}
export interface AppDownload {
name: string
size_bytes: number
}
export interface AppDownloadsResponse {
eligible: boolean
items: AppDownload[]
}
// --- Endpoints ---
export const api = {
@@ -221,4 +231,13 @@ export const api = {
getAlertSettings: () => request<AlertSettings>('GET', '/profile/alerts'),
setAlertSettings: (settings: AlertSettings) =>
request<AlertSettings>('POST', '/profile/alerts', settings),
listAppDownloads: () => request<AppDownloadsResponse>('GET', '/apps'),
}
// URL de téléchargement direct d'une app — le cookie de session httpOnly
// suffit à authentifier la navigation (voir auth.tokenFromRequest côté API),
// pas besoin de fetch + blob.
export function appDownloadUrl(name: string): string {
return `${BASE}/api/v1/apps/${encodeURIComponent(name)}`
}
+13
View File
@@ -72,6 +72,19 @@ export function timeRemaining(expiresAt: string, now: number = Date.now()): stri
return `${hours} h ${mins} min`
}
// Taille de fichier lisible (ex. "42.3 Mo").
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
const units = ['Ko', 'Mo', 'Go']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(1)} ${units[unitIndex]}`
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR', {
day: '2-digit',
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from 'react'
import {
Box,
Button,
Flex,
HStack,
Heading,
Spacer,
Spinner,
Stack,
Text,
VStack,
useToast,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, appDownloadUrl, type AppDownload } from '../../lib/api'
import { formatFileSize } from '../../lib/format'
const DownloadIcon = () => (
<Box as="svg" w="16px" h="16px" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Box as="path" d="M12 3v12m0 0-4-4m4 4 4-4M5 21h14" />
</Box>
)
export function AppDownloads() {
const toast = useToast()
const navigate = useNavigate()
const [items, setItems] = useState<AppDownload[]>([])
const [eligible, setEligible] = useState(false)
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
try {
const res = await api.listAppDownloads()
setItems(res.items ?? [])
setEligible(res.eligible)
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login')
else toast({ status: 'error', title: 'Chargement des applications impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
}, [load])
return (
<>
<Flex mb={6} align="center">
<Heading size="md">Applications</Heading>
<Spacer />
</Flex>
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
{loading ? (
<Spinner />
) : !eligible ? (
<Text color="gray.500">
Le téléchargement des applications nécessite une démo ou un abonnement actif.
</Text>
) : items.length === 0 ? (
<Text color="gray.500">Aucune application disponible pour le moment.</Text>
) : (
<VStack align="stretch" spacing={3}>
{items.map((item) => (
<HStack key={item.name} justify="space-between" flexWrap="wrap" rowGap={2}>
<Stack spacing={0}>
<Text fontFamily="mono">{item.name}</Text>
<Text fontSize="sm" color="gray.500">
{formatFileSize(item.size_bytes)}
</Text>
</Stack>
<Button
as="a"
href={appDownloadUrl(item.name)}
download={item.name}
size="sm"
colorScheme="primary"
leftIcon={<DownloadIcon />}
>
Télécharger
</Button>
</HStack>
))}
</VStack>
)}
</Box>
</>
)
}