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
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:
76
frontend/src/App.vue
Normal file
76
frontend/src/App.vue
Normal 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
6
frontend/src/main.js
Normal 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')
|
||||
45
frontend/src/pages/DiscoverPage.vue
Normal file
45
frontend/src/pages/DiscoverPage.vue
Normal 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 på vad du
|
||||
lyssnat på 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>
|
||||
87
frontend/src/pages/LoginPage.vue
Normal file
87
frontend/src/pages/LoginPage.vue
Normal 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
19
frontend/src/router.js
Normal 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
67
frontend/src/style.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user