Förslag-sidan: typ-knappar i vänsterspalten, alla fynd nyast först
All checks were successful
build-and-push / build (push) Successful in 12s
All checks were successful
build-and-push / build (push) Successful in 12s
Tre knappar (Artister & album / Låtar / Genrer) ersätter batch-listan; huvudspalten visar alla cachade fynd av vald typ i tidsordning med nyast först, grupperade per körning med datum-avdelare. Nytt API GET /api/suggest/items?type=X (plattar batcher med batch-metadata). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -235,6 +235,11 @@ export function buildApp(config, deps = {}) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/suggest/items', async (req) => {
|
||||||
|
const type = SUGGEST_TYPES.includes(req.query.type) ? req.query.type : 'mix'
|
||||||
|
return suggest.allItems(type)
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/suggest/batches', async () => {
|
app.get('/api/suggest/batches', async () => {
|
||||||
return suggest.loadBatches().map((b) => ({
|
return suggest.loadBatches().map((b) => ({
|
||||||
id: b.id,
|
id: b.id,
|
||||||
|
|||||||
@@ -355,6 +355,16 @@ export function suggestEngine({ config, store, lidarr, fetchImpl = fetch }) {
|
|||||||
return items.slice(0, limit)
|
return items.slice(0, limit)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// alla fynd av en typ, nyast först, med batch-metadata för gruppering
|
||||||
|
allItems(type = 'mix', limit = 500) {
|
||||||
|
return loadBatches()
|
||||||
|
.filter((b) => (b.type ?? 'mix') === type)
|
||||||
|
.flatMap((b) =>
|
||||||
|
b.items.map((it) => ({ ...it, batchId: b.id, batchCreatedAt: b.createdAt, trigger: b.trigger })),
|
||||||
|
)
|
||||||
|
.slice(0, limit)
|
||||||
|
},
|
||||||
|
|
||||||
lastRun(type = 'mix') {
|
lastRun(type = 'mix') {
|
||||||
return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null
|
return loadBatches().find((b) => (b.type ?? 'mix') === type)?.createdAt ?? null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ test('run: batch-id räknas upp och latest tar nyaste först', async () => {
|
|||||||
assert.equal(engine.loadBatches()[0].id, b2.id)
|
assert.equal(engine.loadBatches()[0].id, b2.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('allItems: plattar batcher nyast först med batch-metadata', async () => {
|
||||||
|
const { engine } = setup()
|
||||||
|
const b1 = await engine.run()
|
||||||
|
const b2 = await engine.run()
|
||||||
|
const all = engine.allItems('mix')
|
||||||
|
assert.equal(all.length, 2)
|
||||||
|
assert.equal(all[0].batchId, b2.id, 'nyaste batchens fynd först')
|
||||||
|
assert.equal(all[1].batchId, b1.id)
|
||||||
|
assert.equal(all[0].batchCreatedAt, b2.createdAt)
|
||||||
|
assert.equal(engine.allItems('tracks').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('run: markdown-staket runt JSON hanteras', async () => {
|
test('run: markdown-staket runt JSON hanteras', async () => {
|
||||||
const { engine } = setup({
|
const { engine } = setup({
|
||||||
geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```',
|
geminiResponse: '```json\n[{"type":"artist","name":"Ghost","motivation":"x"}]\n```',
|
||||||
|
|||||||
@@ -1,70 +1,85 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import MusicCard from '../components/MusicCard.vue'
|
import MusicCard from '../components/MusicCard.vue'
|
||||||
import DetailModal from '../components/DetailModal.vue'
|
import DetailModal from '../components/DetailModal.vue'
|
||||||
|
|
||||||
const batches = ref([])
|
const TYPES = [
|
||||||
const selected = ref(null)
|
{ key: 'mix', label: '✨ Artister & album' },
|
||||||
|
{ key: 'tracks', label: '🎵 Låtar' },
|
||||||
|
{ key: 'genres', label: '🎼 Genrer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const active = ref('mix')
|
||||||
|
const items = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const openDetail = ref(null)
|
const openDetail = ref(null)
|
||||||
|
|
||||||
async function load() {
|
async function pick(type) {
|
||||||
const res = await fetch('/api/suggest/batches')
|
active.value = type
|
||||||
batches.value = await res.json()
|
|
||||||
if (batches.value.length && !selected.value) pick(batches.value[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pick(id) {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/suggest/batches/${id}`)
|
const res = await fetch(`/api/suggest/items?type=${type}`)
|
||||||
if (res.ok) selected.value = await res.json()
|
items.value = res.ok ? await res.json() : []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gruppera per körning (nyast först, ordningen kommer från API:t)
|
||||||
|
const groups = computed(() => {
|
||||||
|
const out = []
|
||||||
|
for (const it of items.value) {
|
||||||
|
const last = out[out.length - 1]
|
||||||
|
if (last && last.batchId === it.batchId) last.items.push(it)
|
||||||
|
else out.push({ batchId: it.batchId, createdAt: it.batchCreatedAt, trigger: it.trigger, items: [it] })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
function fmt(iso) {
|
function fmt(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
return new Date(iso).toLocaleString('sv-SE', { dateStyle: 'medium', timeStyle: 'short' })
|
return new Date(iso).toLocaleString('sv-SE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(() => pick('mix'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="wrap">
|
<section class="wrap">
|
||||||
<aside>
|
<aside>
|
||||||
<h2>Förslag</h2>
|
<h2>Förslag</h2>
|
||||||
<p v-if="!batches.length" class="dim">Inga batcher ännu — generera från Upptäck-sidan.</p>
|
|
||||||
<button
|
<button
|
||||||
v-for="b in batches"
|
v-for="t in TYPES"
|
||||||
:key="b.id"
|
:key="t.key"
|
||||||
class="batch"
|
class="typebtn"
|
||||||
:class="{ active: selected?.id === b.id }"
|
:class="{ active: active === t.key }"
|
||||||
@click="pick(b.id)"
|
@click="pick(t.key)"
|
||||||
>
|
>
|
||||||
<strong>#{{ b.id }}</strong> — {{ fmt(b.createdAt) }}
|
{{ t.label }}
|
||||||
<span class="dim">{{ { tracks: 'låtar', singers: 'sångare', genres: 'genrer' }[b.type] ?? 'artister/album' }} · {{ b.count }} förslag · {{ b.trigger }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<template v-if="selected">
|
<p v-if="loading" class="dim">Laddar…</p>
|
||||||
<p class="dim">
|
<p v-else-if="!groups.length" class="dim">
|
||||||
Batch #{{ selected.id }} ({{ fmt(selected.createdAt) }}) — signaler:
|
Inga förslag av den här typen ännu — generera från Upptäck-sidan.
|
||||||
{{ Object.entries(selected.signalCounts ?? {}).map(([k, v]) => `${k}: ${v}`).join(', ') }}
|
</p>
|
||||||
|
|
||||||
|
<template v-for="g in groups" :key="g.batchId">
|
||||||
|
<p class="dim divider">
|
||||||
|
Körning #{{ g.batchId }} · {{ fmt(g.createdAt) }} · {{ g.trigger }}
|
||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<MusicCard
|
<MusicCard
|
||||||
v-for="(r, i) in selected.items"
|
v-for="(r, i) in g.items"
|
||||||
:key="`${selected.id}-${r.mbid ?? i}`"
|
:key="`${g.batchId}-${r.mbid ?? r.name}-${i}`"
|
||||||
:item="r"
|
:item="r"
|
||||||
@detail="openDetail = $event"
|
@detail="openDetail = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-else-if="loading" class="dim">Laddar…</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
|
<DetailModal v-if="openDetail" :item="openDetail" @close="openDetail = null" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,7 +87,7 @@ onMounted(load)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 16rem 1fr;
|
grid-template-columns: 14rem 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -95,24 +110,22 @@ aside {
|
|||||||
top: 5rem;
|
top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch {
|
.typebtn {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.15rem;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch:hover {
|
.typebtn:hover {
|
||||||
background: var(--card-hover);
|
background: var(--card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch.active {
|
.typebtn.active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dim {
|
.dim {
|
||||||
@@ -120,8 +133,14 @@ aside {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content .dim {
|
.divider {
|
||||||
margin: 0 0 1rem;
|
margin: 1.2rem 0 0.6rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
|
|||||||
Reference in New Issue
Block a user