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:
@@ -21,7 +21,7 @@ sudo pacman -S nodejs npm # node >= 20
|
|||||||
```bash
|
```bash
|
||||||
git clone gitea-claude:brasse/lyssnarr.git
|
git clone gitea-claude:brasse/lyssnarr.git
|
||||||
cd lyssnarr
|
cd lyssnarr
|
||||||
npm install # installs backend + frontend workspaces
|
npm run setup # installs backend + frontend deps
|
||||||
npm run build # -> ./build/ (frontend dist + backend)
|
npm run build # -> ./build/ (frontend dist + backend)
|
||||||
npm run dev # dev servers (Vite + Fastify, hot reload)
|
npm run dev # dev servers (Vite + Fastify, hot reload)
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
1133
backend/package-lock.json
generated
Normal file
1133
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
backend/package.json
Normal file
17
backend/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "lyssnarr-backend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/session": "^11.1.0",
|
||||||
|
"@fastify/static": "^8.1.1",
|
||||||
|
"fastify": "^5.3.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
backend/src/app.js
Normal file
80
backend/src/app.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { timingSafeEqual } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import Fastify from 'fastify'
|
||||||
|
import fastifyCookie from '@fastify/cookie'
|
||||||
|
import fastifySession from '@fastify/session'
|
||||||
|
import fastifyStatic from '@fastify/static'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
const OPEN_ROUTES = new Set(['/api/health', '/api/login', '/api/session'])
|
||||||
|
|
||||||
|
function passwordOk(given, expected) {
|
||||||
|
if (typeof given !== 'string' || !expected) return false
|
||||||
|
const a = Buffer.from(given)
|
||||||
|
const b = Buffer.from(expected)
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildApp(config) {
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
|
||||||
|
app.register(fastifyCookie)
|
||||||
|
app.register(fastifySession, {
|
||||||
|
secret: config.sessionSecret,
|
||||||
|
cookie: {
|
||||||
|
secure: 'auto',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// allt under /api utom OPEN_ROUTES kräver inloggad session
|
||||||
|
app.addHook('preHandler', async (req, reply) => {
|
||||||
|
if (!req.url.startsWith('/api/')) return
|
||||||
|
const route = req.url.split('?')[0]
|
||||||
|
if (OPEN_ROUTES.has(route)) return
|
||||||
|
if (req.session.get('user')) return
|
||||||
|
return reply.code(401).send({ error: 'ej inloggad' })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/health', async () => ({ status: 'ok' }))
|
||||||
|
|
||||||
|
app.get('/api/session', async (req) => ({
|
||||||
|
authenticated: Boolean(req.session.get('user')),
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/api/login', async (req, reply) => {
|
||||||
|
if (!config.appPassword) {
|
||||||
|
return reply.code(500).send({ error: 'APP_PASSWORD är inte satt' })
|
||||||
|
}
|
||||||
|
const { password } = req.body ?? {}
|
||||||
|
if (!passwordOk(password, config.appPassword)) {
|
||||||
|
return reply.code(401).send({ error: 'fel lösenord' })
|
||||||
|
}
|
||||||
|
req.session.set('user', 'brasse')
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/logout', async (req) => {
|
||||||
|
await req.session.destroy()
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// byggd frontend (finns bara i containern / efter npm run build)
|
||||||
|
const publicDir = path.resolve(__dirname, '../public')
|
||||||
|
if (existsSync(publicDir)) {
|
||||||
|
app.register(fastifyStatic, { root: publicDir })
|
||||||
|
app.setNotFoundHandler((req, reply) => {
|
||||||
|
if (req.url.startsWith('/api/')) {
|
||||||
|
return reply.code(404).send({ error: 'finns inte' })
|
||||||
|
}
|
||||||
|
return reply.sendFile('index.html')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
21
backend/src/config.js
Normal file
21
backend/src/config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function loadConfig(env = process.env) {
|
||||||
|
return {
|
||||||
|
port: Number(env.PORT ?? 3000),
|
||||||
|
host: env.HOST ?? '0.0.0.0',
|
||||||
|
appPassword: env.APP_PASSWORD ?? '',
|
||||||
|
sessionSecret: env.SESSION_SECRET ?? '',
|
||||||
|
dataDir: env.DATA_DIR ?? '/data',
|
||||||
|
lidarr: {
|
||||||
|
url: env.LIDARR_URL ?? 'http://lidarr:8686',
|
||||||
|
apiKey: env.LIDARR_API_KEY ?? '',
|
||||||
|
},
|
||||||
|
jellyfin: {
|
||||||
|
url: env.JELLYFIN_URL ?? 'http://jellyfin:8096',
|
||||||
|
apiKey: env.JELLYFIN_API_KEY ?? '',
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
apiKey: env.GEMINI_API_KEY ?? '',
|
||||||
|
model: env.GEMINI_MODEL ?? 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/server.js
Normal file
16
backend/src/server.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { loadConfig } from './config.js'
|
||||||
|
import { buildApp } from './app.js'
|
||||||
|
|
||||||
|
const config = loadConfig()
|
||||||
|
|
||||||
|
if (!config.sessionSecret || config.sessionSecret.length < 32) {
|
||||||
|
console.error('SESSION_SECRET saknas eller är kortare än 32 tecken')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = buildApp(config)
|
||||||
|
|
||||||
|
app.listen({ port: config.port, host: config.host }).catch((err) => {
|
||||||
|
app.log.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
57
backend/test/app.test.js
Normal file
57
backend/test/app.test.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { buildApp } from '../src/app.js'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
appPassword: 'testlösen',
|
||||||
|
sessionSecret: 'x'.repeat(32),
|
||||||
|
lidarr: { url: '', apiKey: '' },
|
||||||
|
jellyfin: { url: '', apiKey: '' },
|
||||||
|
gemini: { apiKey: '', model: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
test('health svarar utan inloggning', async () => {
|
||||||
|
const app = buildApp(config)
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/health' })
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.deepEqual(res.json(), { status: 'ok' })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fel lösenord ger 401', async () => {
|
||||||
|
const app = buildApp(config)
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/login',
|
||||||
|
payload: { password: 'fel' },
|
||||||
|
})
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rätt lösenord ger session-cookie och åtkomst', async () => {
|
||||||
|
const app = buildApp(config)
|
||||||
|
const login = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/login',
|
||||||
|
payload: { password: 'testlösen' },
|
||||||
|
})
|
||||||
|
assert.equal(login.statusCode, 200)
|
||||||
|
const cookie = login.headers['set-cookie']
|
||||||
|
assert.ok(cookie, 'ingen session-cookie sattes')
|
||||||
|
|
||||||
|
const session = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/session',
|
||||||
|
headers: { cookie },
|
||||||
|
})
|
||||||
|
assert.equal(session.json().authenticated, true)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('skyddad API-rutt utan session ger 401', async () => {
|
||||||
|
const app = buildApp(config)
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/whatever' })
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
@@ -38,7 +38,7 @@ stack `.env`, signed session cookie).
|
|||||||
| **YouTube Music** | unofficial YT Music search from Node → browse/video IDs for "open in YT Music" deep links, thumbnails for the cards, inline playback via YouTube IFrame embed |
|
| **YouTube Music** | unofficial YT Music search from Node → browse/video IDs for "open in YT Music" deep links, thumbnails for the cards, inline playback via YouTube IFrame embed |
|
||||||
| **Gemini API** | suggestion generation; `GEMINI_API_KEY` + model via env |
|
| **Gemini API** | suggestion generation; `GEMINI_API_KEY` + model via env |
|
||||||
| **Youtubarr** | read-only mount of its SQLite (`/srv/docker/youtubarr/data/db.sqlite3`) → "recently added to playlists" signal |
|
| **Youtubarr** | read-only mount of its SQLite (`/srv/docker/youtubarr/data/db.sqlite3`) → "recently added to playlists" signal |
|
||||||
| **Jellyfin** | play-history signal — blocked until a music library exists in Jellyfin (milestone 6) |
|
| **Jellyfin** | play-history signal (prioritised by Björn 2026-07-31) — infra prerequisite: mount `/srv/storage1/Music` into the jellyfin container + create a music library (container recreate, needs Björn's OK) |
|
||||||
|
|
||||||
## UI sketch (Jellyseerr-like)
|
## UI sketch (Jellyseerr-like)
|
||||||
|
|
||||||
@@ -59,10 +59,11 @@ stack `.env`, signed session cookie).
|
|||||||
3. **Search page** — MusicBrainz search enriched with YT Music
|
3. **Search page** — MusicBrainz search enriched with YT Music
|
||||||
thumbnails/links, add/play/open actions
|
thumbnails/links, add/play/open actions
|
||||||
4. **On-demand suggestions** — "generera nu": Gemini prompt built from
|
4. **On-demand suggestions** — "generera nu": Gemini prompt built from
|
||||||
Lidarr library + Youtubarr playlist adds
|
Lidarr library + Youtubarr playlist adds + Jellyfin play history
|
||||||
|
(used as soon as it's configured)
|
||||||
5. **Scheduler + history** — cron-generated batches, history page
|
5. **Scheduler + history** — cron-generated batches, history page
|
||||||
6. **Jellyfin signal** — recent music plays feed the prompt (requires
|
6. **Jellyfin music library** — infra step on the Pi5 (mount + library)
|
||||||
music library in Jellyfin)
|
so the play-history signal has data
|
||||||
|
|
||||||
## Roadmap / expansions (proposed — Björn confirms/strikes)
|
## Roadmap / expansions (proposed — Björn confirms/strikes)
|
||||||
|
|
||||||
|
|||||||
24
docker/Dockerfile
Normal file
24
docker/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Multi-stage: frontend-bygge -> backend-deps -> slim runtime (arm64 på Pi5)
|
||||||
|
ARG BUILD_CPUS=2
|
||||||
|
|
||||||
|
FROM node:22-slim AS frontend
|
||||||
|
WORKDIR /f
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-slim AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/package.json backend/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
COPY backend/src ./src
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /app ./
|
||||||
|
COPY --from=frontend /f/dist ./public
|
||||||
|
EXPOSE 3000
|
||||||
|
USER node
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>lyssnarr</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1383
frontend/package-lock.json
generated
Normal file
1383
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "lyssnarr-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "lyssnarr",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Musik-discovery för Pi5 — Jellyseerr-lik UI, Gemini-förslag, Lidarr/YT Music/Jellyfin",
|
||||||
|
"scripts": {
|
||||||
|
"setup": "npm --prefix backend install && npm --prefix frontend install",
|
||||||
|
"dev:backend": "npm --prefix backend run dev",
|
||||||
|
"dev:frontend": "npm --prefix frontend run dev",
|
||||||
|
"build": "npm --prefix frontend run build && rm -rf build && mkdir -p build && cp -r frontend/dist build/public && cp -r backend/src build/server",
|
||||||
|
"test": "npm --prefix backend test"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user