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:
1 parent
c097cd4d1f
commit
97da422037
5 files changed
+187
-36
No files matched your search
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user