feat(UI/UX): affichage de la serie et des episodes sur la page download du torrent
fix: les torrents n'utilise plus la pause feat(log): + de logs sur les erreurs d'import (chemin tenté et fichier du torrent chercher) fix(docker): ajout du fichier de versions pour la navbar dans docker lors du build fix(import): les imports se font maintenant sur les series sans torrents qui n'avais pas de fichier de catalogue depuis le scraper fix(UI/UX): retour visuel lors de l'import d'une serie/episode
This commit is contained in:
1 parent
f81969cdd0
commit
cb28baf25d
7 files changed
+81
-46
No files matched your search
@@ -38,3 +38,5 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||||
@@ -16,6 +16,7 @@ RUN pnpm run build
|
|||||||
|
|
||||||
# ── Production ─────────────────────────────────────────────────
|
# ── Production ─────────────────────────────────────────────────
|
||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
ARG BUILD_VERSION=dev
|
||||||
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -25,6 +26,7 @@ COPY --from=build /app/dist/client /app/public
|
|||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
RUN mkdir -p /config
|
RUN mkdir -p /config
|
||||||
|
RUN echo "$BUILD_VERSION" > /app/version.txt
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ async function organizeTorrent(
|
|||||||
|
|
||||||
// Vérification de complétion pour épisode unique
|
// Vérification de complétion pour épisode unique
|
||||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||||
return { ...result, total: 1, skipped: 1 }
|
return { ...result, total: 1, skipped: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,8 +371,8 @@ async function organizeTorrent(
|
|||||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
error(`Fichier source introuvable : ${filename}`)
|
error(`Fichier source introuvable : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||||
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
|
return { ...result, total: 1, errors: [{ file: filename, error: `Source introuvable (chemins essayés : ${candidates.join(', ')})` }] }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(dest)) {
|
if (!fs.existsSync(dest)) {
|
||||||
@@ -418,9 +418,9 @@ async function organizeTorrent(
|
|||||||
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
|
||||||
|
|
||||||
// Vérification de complétion : si on a les infos de progression par fichier,
|
// Vérification de complétion : si on a les infos de progression par fichier,
|
||||||
// on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée).
|
// on skip les fichiers pas encore à 100% (ex: priorité 0 ou en cours de téléchargement).
|
||||||
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
|
||||||
debug(`Fichier incomplet (skip) : ${filename}`)
|
warn(`Skip (incomplet / priorité 0) : ${filename}`)
|
||||||
result.skipped++
|
result.skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -436,8 +436,7 @@ async function organizeTorrent(
|
|||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
|
// Fichier absent du disque : pas encore téléchargé ou hors sélection (priorité 0).
|
||||||
// On skip silencieusement — pas une erreur.
|
warn(`Skip (fichier absent) : ${filename}\n Chemins essayés :\n${candidates.map(c => ` - ${c}`).join('\n')}`)
|
||||||
debug(`Fichier non disponible (skip) : ${filename}`)
|
|
||||||
result.skipped++
|
result.skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,19 +49,27 @@ router.get('/downloads', requireAuth, async (_req, res) => {
|
|||||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||||
const orgFiles = organized[t.hash] ?? {}
|
const orgFiles = organized[t.hash] ?? {}
|
||||||
let totalFiles = 1
|
let totalFiles = 1
|
||||||
|
let serieName: string | undefined
|
||||||
|
let episodes: any[] = []
|
||||||
try {
|
try {
|
||||||
const availableIds = await readAvailable()
|
const availableIds = await readAvailable()
|
||||||
for (const id of availableIds) {
|
for (const id of availableIds) {
|
||||||
const sd = await readSerieData(id)
|
const sd = await readSerieData(id)
|
||||||
if (!sd) continue
|
if (!sd) continue
|
||||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
if (match) {
|
||||||
|
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||||
|
totalFiles = resolved.length || 1
|
||||||
|
serieName = sd.title ?? sd.show_title
|
||||||
|
episodes = resolved
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
const doneFiles = Object.keys(orgFiles).length
|
const doneFiles = Object.keys(orgFiles).length
|
||||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [], serieName, episodes }
|
||||||
}))
|
}))
|
||||||
res.json(enriched)
|
res.json(enriched)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||||
const organized = loadOrganizedJson()
|
const organized = loadOrganizedJson()
|
||||||
const sd = await readSerieData(Number(serie_id))
|
let sd = await readSerieData(Number(serie_id))
|
||||||
|
if (!sd) {
|
||||||
|
// readSerieData ne couvre que le cache local — fallback sur le catalogue complet
|
||||||
|
// (utile pour les séries sans torrent officiel dont les données sont peut-être absentes du cache individuel)
|
||||||
|
const all = await loadEnrichedSeriesData()
|
||||||
|
sd = all.find((s: any) => s.id === Number(serie_id)) ?? null
|
||||||
|
}
|
||||||
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
|
||||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||||
|
|||||||
@@ -58,13 +58,12 @@ async function qbTorrentExists(config: Record<string, string | number>, authHead
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
* Attend que les fichiers soient disponibles, résout l'index par chemin si possible,
|
||||||
* applique les priorités, puis reprend le torrent.
|
* puis applique les priorités (désactive tout sauf la cible).
|
||||||
*/
|
*/
|
||||||
async function qbApplyFilePriority(
|
async function qbApplyFilePriority(
|
||||||
config : Record<string, string | number>,
|
config : Record<string, string | number>,
|
||||||
hash : string,
|
hash : string,
|
||||||
fileIndex: number,
|
fileIndex: number,
|
||||||
resume : boolean,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
|
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
|
||||||
for (let attempt = 0; attempt < 60; attempt++) {
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
@@ -118,36 +117,13 @@ async function qbApplyFilePriority(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reprendre (torrent mis en pause via paused/stopped à l'ajout)
|
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné pour ${hash.slice(0, 8)}…`)
|
||||||
// On appelle les deux endpoints : resume (qBit ≤ 4.x) et start (qBit ≥ 5.0)
|
|
||||||
if (resume) {
|
|
||||||
const resumeBody = new URLSearchParams({ hashes: hash })
|
|
||||||
await Promise.allSettled([
|
|
||||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body: resumeBody, headers }),
|
|
||||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body: resumeBody, headers }),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('qbittorrent', `Fichier ${fileIndex} sélectionné${resume ? ' + reprise' : ''} pour ${hash.slice(0, 8)}…`)
|
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
if (err instanceof Error && err.message.includes('hors limites')) throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
|
|
||||||
if (resume) {
|
|
||||||
try {
|
|
||||||
const authH = await qbAuth(config)
|
|
||||||
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
||||||
const body = new URLSearchParams({ hashes: hash })
|
|
||||||
await Promise.allSettled([
|
|
||||||
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
|
|
||||||
fetch(`${config.url}/api/v2/torrents/start`, { method: 'POST', body, headers }),
|
|
||||||
])
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
throw new Error(`Timeout : métadonnées non disponibles pour ${hash.slice(0, 8)}…`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +251,7 @@ const QB: TorrentClientDriver = {
|
|||||||
const exists = await qbTorrentExists(config, authH, hash)
|
const exists = await qbTorrentExists(config, authH, hash)
|
||||||
if (exists) {
|
if (exists) {
|
||||||
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
|
||||||
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
|
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -305,14 +281,12 @@ const QB: TorrentClientDriver = {
|
|||||||
form.append('urls', url)
|
form.append('urls', url)
|
||||||
} else {
|
} else {
|
||||||
// FanKarr télécharge le .torrent et l'envoie directement à qBit
|
// FanKarr télécharge le .torrent et l'envoie directement à qBit
|
||||||
|
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
|
||||||
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
|
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
|
||||||
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
|
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
|
||||||
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
|
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
|
||||||
const torrentBytes = await torrentRes.arrayBuffer()
|
const torrentBytes = await torrentRes.arrayBuffer()
|
||||||
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
|
||||||
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
|
|
||||||
form.append('paused', 'true') // qBit < 5.0
|
|
||||||
form.append('stopped', 'true') // qBit ≥ 5.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
|
||||||
@@ -322,8 +296,8 @@ const QB: TorrentClientDriver = {
|
|||||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||||
|
|
||||||
if (hash) {
|
if (hash) {
|
||||||
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
|
logger.info('qbittorrent', `Torrent ajouté — sélection fichier ${options.file_index} en attente des métadonnées`)
|
||||||
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
|
qbApplyFilePriority(config, hash, options.file_index).catch(err =>
|
||||||
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -341,7 +315,7 @@ const QB: TorrentClientDriver = {
|
|||||||
if (!newT) continue
|
if (!newT) continue
|
||||||
const resolvedHash = String(newT.hash).toLowerCase()
|
const resolvedHash = String(newT.hash).toLowerCase()
|
||||||
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
|
logger.info('qbittorrent', `Hash résolu : ${resolvedHash.slice(0, 8)}… — application priorité fichier ${fileIndex}`)
|
||||||
await qbApplyFilePriority(config, resolvedHash, fileIndex, true)
|
await qbApplyFilePriority(config, resolvedHash, fileIndex)
|
||||||
return
|
return
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,30 @@
|
|||||||
<!-- Nom + état -->
|
<!-- Nom + état -->
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm text-primary font-medium truncate">{{ torrent.name }}</p>
|
<!-- Nom du torrent + tooltip épisodes -->
|
||||||
|
<div class="relative group/eps inline-block max-w-full">
|
||||||
|
<p class="text-sm text-primary font-medium truncate cursor-default">{{ torrent.name }}</p>
|
||||||
|
<!-- Tooltip épisodes -->
|
||||||
|
<div v-if="episodesWithProgress.length > 0"
|
||||||
|
class="absolute bottom-full left-0 mb-2 hidden group-hover/eps:block z-20 min-w-56 max-w-80">
|
||||||
|
<div class="bg-card border border-border rounded-lg p-3 text-xs shadow-xl">
|
||||||
|
<p class="text-muted font-medium mb-2">{{ episodesWithProgress.length }} épisode{{ episodesWithProgress.length > 1 ? 's' : '' }}</p>
|
||||||
|
<div class="flex flex-col gap-1 max-h-48 overflow-y-auto">
|
||||||
|
<div v-for="ep in episodesWithProgress" :key="ep.episode_id"
|
||||||
|
class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-primary shrink-0">S{{ String(ep.season_number).padStart(2,'0') }}E{{ String(ep.episode_number).padStart(2,'0') }}</span>
|
||||||
|
<span v-if="ep.progress !== null"
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded shrink-0"
|
||||||
|
:class="ep.progress >= 100 ? 'bg-green-500/15 text-green-500' : ep.priority === 0 ? 'bg-border/50 text-muted' : 'bg-accent/15 text-accent'">
|
||||||
|
{{ ep.priority === 0 ? 'ignoré' : ep.progress >= 100 ? '✓' : `${ep.progress}%` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Nom de la série -->
|
||||||
|
<p v-if="torrent.serieName" class="text-[11px] text-muted truncate mt-0.5">{{ torrent.serieName }}</p>
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(torrent.state).class">{{ stateBadge(torrent.state).label }}</span>
|
||||||
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
<span v-if="columns.client" class="text-xs text-muted">{{ torrent.client_name }}</span>
|
||||||
@@ -65,8 +88,12 @@
|
|||||||
: 'Non importé' }}
|
: 'Non importé' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing" class="btn-secondary text-xs py-1">
|
<button v-if="torrent.organizeState !== 'done'" @click="emit('import', torrent)" :disabled="importing"
|
||||||
{{ importing ? '...' : 'Importer' }}
|
class="btn-secondary text-xs py-1 flex items-center gap-1.5 min-w-[72px] justify-center">
|
||||||
|
<svg v-if="importing" width="10" height="10" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" fill="none" class="animate-spin shrink-0">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
{{ importing ? 'Import…' : 'Importer' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,6 +157,23 @@ const withFiles = ref(false)
|
|||||||
|
|
||||||
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
const clampedProgress = computed(() => Math.min(100, Math.max(0, props.torrent.progress ?? 0)))
|
||||||
|
|
||||||
|
// Épisodes enrichis avec leur progression depuis les données de fichiers du client
|
||||||
|
const episodesWithProgress = computed(() => {
|
||||||
|
const eps: any[] = props.torrent.episodes ?? []
|
||||||
|
if (!eps.length) return []
|
||||||
|
return eps.map((ep: any) => {
|
||||||
|
const file = (props.torrent.files ?? []).find((f: any) => {
|
||||||
|
const basename = String(f.name ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
|
||||||
|
return basename.toLowerCase() === ep.filename?.toLowerCase()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...ep,
|
||||||
|
progress: file != null ? Math.round((file.progress ?? 0) * 100) : null,
|
||||||
|
priority: file?.priority ?? 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function stateBadge(state: string) {
|
function stateBadge(state: string) {
|
||||||
return ({
|
return ({
|
||||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||||
|
|||||||
Reference in new issue
Block a user