feat(organize): worker thread + badges statut + notifications

- api: /api/organize/recent pour les notifications frontend
- api: /api/system pour détecter l'environnement Docker
- ui: badge organisation (NON ORGANISÉ / EN COURS / ORGANISÉ) dans DownloadsView
- ui: icône download avec compteur actifs dans le header CatalogView
- ui: toast automatique quand l'auto-organisation se termine
- ui: paths en lecture seule avec badge DOCKER dans SettingsView
This commit is contained in:
MasutaYunikon committed 2026-03-11 13:44:51 +01:00
1 parent c097cd4d1f
commit 97da422037
5 files changed
+187 -36

No files matched your search

+57 -8
View File
@@ -8,14 +8,20 @@
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
</span>
<div class="flex items-center gap-2">
<!-- Icône downloads avec badge -->
<router-link to="/downloads"
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative">
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
>
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<!-- Badge si téléchargements actifs -->
<!-- Badge compteur -->
<span v-if="activeDownloads > 0"
class="absolute top-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/>
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 bg-orange-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
{{ activeDownloads }}
</span>
</router-link>
<router-link
to="/settings"
@@ -148,19 +154,20 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
defineOptions({ name: 'CatalogView' })
import { useToast } from '@/composables/useToast'
const store = useSeriesStore()
const auth = useAuthStore()
const router = useRouter()
const { add: toast } = useToast()
const search = ref('')
const activeFilter = ref('all')
const activeDownloads = ref(0)
const filters = [
{ label: 'Toutes', value: 'all' },
@@ -181,10 +188,52 @@ const filtered = computed(() => {
async function handleLogout() {
await auth.logout()
router.push('/auth')
await router.push('/auth')
}
// ── Polling downloads actifs + notifications organisation ──────
let dlInterval: ReturnType<typeof setInterval> | null = null
const seenNotifs = new Set<string>()
async function fetchActiveDownloads() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const torrents = await res.json()
activeDownloads.value = torrents.filter((t: any) => t.state === 'downloading').length
} catch {}
}
async function fetchOrganizeNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (!res.ok) return
const notifs: any[] = await res.json()
for (const n of notifs) {
const key = `${n.hash}-${n.at}`
if (seenNotifs.has(key)) continue
seenNotifs.add(key)
if (n.done > 0) {
const msg = n.errors > 0
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
toast(msg, n.errors > 0 ? 'error' : 'success')
}
}
} catch {}
}
onMounted(() => {
if (store.series.length === 0) store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
fetchActiveDownloads()
fetchOrganizeNotifs()
}, 10000)
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
</script>
+19 -11
View File
@@ -103,16 +103,25 @@
</span>
</div>
<!-- Bouton organiser si complété -->
<button v-if="t.state === 'seeding'"
@click="organize(t)"
:disabled="organizing[t.hash]"
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer"
:class="organized[t.hash]
? 'border-green-500/40 text-green-500'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'">
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }}
</button>
<!-- Badge organisation -->
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<!-- Badge statut -->
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
'border-[#1e2d3d] text-[#5a7a94]'">
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
'NON ORGANISÉ' }}
</span>
<!-- Bouton organiser si pas encore fait -->
<button v-if="t.organizeState !== 'done'"
@click="organize(t)"
:disabled="organizing[t.hash]"
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
{{ organizing[t.hash] ? '...' : 'ORGANISER' }}
</button>
</div>
</div>
</div>
</div>
@@ -130,7 +139,6 @@ const loading = ref(true)
const polling = ref(true)
const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({})
const organized = ref<Record<string, boolean>>({})
let pollInterval: ReturnType<typeof setInterval> | null = null
+30 -13
View File
@@ -87,20 +87,34 @@
<!-- Dossier complétion -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label>
<input v-model="form.completePath"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<input v-model="form.completePath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/downloads/complete"/>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent (monté dans Docker)</p>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier de complétion de qBittorrent</span>
</p>
</div>
<!-- Dossier Jellyfin -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label>
<input v-model="form.mediaPath"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<input v-model="form.mediaPath" :disabled="isDocker"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 outline-none transition-colors"
:class="isDocker ? 'text-[#5a7a94] cursor-not-allowed opacity-60' : 'text-white focus:border-[#e8513a]'"
placeholder="/media/Kai"/>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier racine de la bibliothèque Fankai dans Jellyfin</span>
</p>
</div>
<!-- Mode organisation -->
@@ -215,8 +229,9 @@ const form = ref({
organizeMode: 'hardlink' as 'hardlink' | 'move',
})
const saving = ref(false)
const updating = ref(false)
const saving = ref(false)
const updating = ref(false)
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([])
@@ -244,18 +259,20 @@ function clientLabel(type: string): string {
// ── Init ───────────────────────────────────────────────────────
onMounted(async () => {
const [settingsRes, statusRes, clientsRes, availRes] = await Promise.all([
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
for (const c of clients.value) refreshHealth(c.uuid)
for (const c of clients.value) await refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
@@ -345,7 +362,7 @@ async function saveClient() {
if (res.ok) {
const client = await res.json()
clients.value.push(client)
refreshHealth(client.uuid)
await refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {