feat: add app download
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user