Milstolpe 1: skelett — Fastify-backend med login, Vue-frontend (mörkt Jellyseerr-tema), Dockerfile
All checks were successful
build-and-push / build (push) Successful in 32s

- backend: /api/health, /api/login|logout|session, sessionscookie,
  timingSafeEqual-lösenordskoll, allt annat under /api kräver inloggning,
  serverar byggd frontend med SPA-fallback; 4 tester (node:test)
- frontend: Vue 3 + Vite, login-sida, Upptäck-skal, sökfält (stub),
  mörkt tema enligt Jellyseerr-förlagan
- docker: multi-stage arm64-image; config-plumbing för Lidarr/Jellyfin/
  Gemini via env (Jellyfin-historik prioriterad som signal per Björn)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
2026-07-31 23:18:25 +02:00
parent f164a263ee
commit 4e0a8aae13
20 changed files with 3092 additions and 5 deletions

76
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,76 @@
<script setup>
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
async function logout() {
await fetch('/api/logout', { method: 'POST' })
router.push({ name: 'login' })
}
</script>
<template>
<header v-if="route.name !== 'login'" class="topbar">
<div class="brand">
<span class="note"></span> lyssnarr
</div>
<div class="search">
<input
type="search"
placeholder="Sök artister, album, låtar… (kommer i milstolpe 3)"
disabled
/>
</div>
<button class="ghost" @click="logout">Logga ut</button>
</header>
<main>
<router-view />
</main>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 0.9rem 1.5rem;
background: rgba(17, 24, 39, 0.85);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.brand {
font-size: 1.3rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.note {
color: var(--accent-hover);
}
.search {
flex: 1;
max-width: 34rem;
}
.ghost {
background: transparent;
color: var(--text-dim);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.ghost:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
main {
padding: 1.5rem;
}
</style>

6
frontend/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router.js'
import './style.css'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,45 @@
<script setup>
// Milstolpe 1: tomt skal. Förslags-korten (Gemini) kommer i milstolpe 4,
// sök i milstolpe 3.
</script>
<template>
<section>
<h2>Upptäck</h2>
<div class="empty">
<p class="big">🎧</p>
<p>Inga förslag ännu.</p>
<p class="dim">
Här landar dina schemalagda Gemini-förslag baserade vad du
lyssnat och lagt till i dina YouTube Music-spellistor.
</p>
</div>
</section>
</template>
<style scoped>
h2 {
margin: 0 0 1rem;
}
.empty {
display: grid;
place-items: center;
gap: 0.25rem;
padding: 4rem 1rem;
background: var(--card);
border-radius: 1rem;
border: 1px dashed rgba(255, 255, 255, 0.12);
text-align: center;
}
.big {
font-size: 3rem;
margin: 0;
}
.dim {
color: var(--text-dim);
max-width: 32rem;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const password = ref('')
const error = ref('')
const busy = ref(false)
async function login() {
busy.value = true
error.value = ''
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password.value }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
error.value = body.error ?? 'inloggningen misslyckades'
return
}
router.push({ name: 'discover' })
} finally {
busy.value = false
}
}
</script>
<template>
<div class="wrap">
<form class="card" @submit.prevent="login">
<h1><span class="note"></span> lyssnarr</h1>
<p class="dim">Logga in för att upptäcka ny musik</p>
<input
v-model="password"
type="password"
placeholder="Lösenord"
autofocus
autocomplete="current-password"
/>
<button :disabled="busy || !password">Logga in</button>
<p v-if="error" class="error">{{ error }}</p>
</form>
</div>
</template>
<style scoped>
.wrap {
min-height: 80vh;
display: grid;
place-items: center;
}
.card {
display: flex;
flex-direction: column;
gap: 0.9rem;
width: min(22rem, 90vw);
padding: 2rem;
background: var(--card);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.06);
}
h1 {
margin: 0;
text-align: center;
}
.note {
color: var(--accent-hover);
}
.dim {
margin: 0;
text-align: center;
color: var(--text-dim);
}
.error {
margin: 0;
color: var(--danger);
text-align: center;
}
</style>

19
frontend/src/router.js Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import DiscoverPage from './pages/DiscoverPage.vue'
import LoginPage from './pages/LoginPage.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'discover', component: DiscoverPage },
{ path: '/login', name: 'login', component: LoginPage },
],
})
router.beforeEach(async (to) => {
if (to.name === 'login') return true
const res = await fetch('/api/session')
const { authenticated } = await res.json()
if (!authenticated) return { name: 'login' }
return true
})

67
frontend/src/style.css Normal file
View File

@@ -0,0 +1,67 @@
/* Mörkt Jellyseerr-likt tema */
:root {
--bg: #111827;
--bg-deep: #0b1120;
--card: #1f2937;
--card-hover: #273449;
--text: #e5e7eb;
--text-dim: #9ca3af;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #f87171;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100vh;
}
body {
background: linear-gradient(180deg, var(--bg) 0%, var(--bg-deep) 100%);
background-attachment: fixed;
color: var(--text);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
a {
color: var(--accent-hover);
text-decoration: none;
}
button {
font: inherit;
border: 0;
border-radius: 0.5rem;
padding: 0.55rem 1.1rem;
background: var(--accent);
color: #fff;
cursor: pointer;
transition: background 0.15s;
}
button:hover {
background: var(--accent-hover);
}
input[type='text'],
input[type='password'],
input[type='search'] {
font: inherit;
color: var(--text);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
padding: 0.55rem 0.9rem;
outline: none;
width: 100%;
}
input:focus {
border-color: var(--accent);
}