Inställningssida med Spara & testa för Gemini/Jellyfin + statustester
All checks were successful
build-and-push / build (push) Successful in 12s
All checks were successful
build-and-push / build (push) Successful in 12s
- settings.json i /data (UI-värden har företräde framför env), hemligheter maskeras i GET (bara apiKeySet-flagga) - POST /api/settings/test kör live-test per tjänst: Gemini (nyckel+modell), Jellyfin (System/Info + användarlista för dropdown), Lidarr, Youtubarr (healthz + db-mount) - Inställningssida (kugghjulet): Spara & testa i samma steg per sektion, Jellyfin-användarväljare fylls från lyckat test - 6 nya tester, totalt 17 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -33,6 +33,7 @@ async function logout() {
|
||||
</select>
|
||||
<input v-model="q" type="search" placeholder="Sök musik att hämta…" />
|
||||
</form>
|
||||
<router-link class="ghost gear" :to="{ name: 'settings' }" title="Inställningar">⚙</router-link>
|
||||
<button class="ghost" @click="logout">Logga ut</button>
|
||||
</header>
|
||||
<main>
|
||||
@@ -90,6 +91,14 @@ async function logout() {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
}
|
||||
|
||||
.gear {
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
|
||||
217
frontend/src/pages/SettingsPage.vue
Normal file
217
frontend/src/pages/SettingsPage.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const gemini = ref({ apiKey: '', model: '' })
|
||||
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
|
||||
const saved = ref({ gemini: {}, jellyfin: {} })
|
||||
const results = ref({}) // service -> {ok, message}
|
||||
const busy = ref({})
|
||||
const jfUsers = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/settings')
|
||||
saved.value = await res.json()
|
||||
gemini.value.model = saved.value.gemini.model
|
||||
jellyfin.value.url = saved.value.jellyfin.url
|
||||
jellyfin.value.userId = saved.value.jellyfin.userId
|
||||
})
|
||||
|
||||
async function saveAndTest(service) {
|
||||
busy.value = { ...busy.value, [service]: true }
|
||||
results.value = { ...results.value, [service]: null }
|
||||
try {
|
||||
const body =
|
||||
service === 'gemini'
|
||||
? { gemini: { apiKey: gemini.value.apiKey || null, model: gemini.value.model || null } }
|
||||
: {
|
||||
jellyfin: {
|
||||
url: jellyfin.value.url || null,
|
||||
apiKey: jellyfin.value.apiKey || null,
|
||||
userId: jellyfin.value.userId || null,
|
||||
userName: jfUsers.value.find((u) => u.id === jellyfin.value.userId)?.name ?? null,
|
||||
},
|
||||
}
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const res = await fetch('/api/settings/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service }),
|
||||
})
|
||||
const out = await res.json()
|
||||
results.value = { ...results.value, [service]: out }
|
||||
if (service === 'jellyfin' && out.users) jfUsers.value = out.users
|
||||
if (out.ok) {
|
||||
const s = await (await fetch('/api/settings')).json()
|
||||
saved.value = s
|
||||
if (service === 'gemini') gemini.value.apiKey = ''
|
||||
if (service === 'jellyfin') jellyfin.value.apiKey = ''
|
||||
}
|
||||
} catch {
|
||||
results.value = { ...results.value, [service]: { ok: false, message: 'något gick fel' } }
|
||||
} finally {
|
||||
busy.value = { ...busy.value, [service]: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function test(service) {
|
||||
busy.value = { ...busy.value, [service]: true }
|
||||
try {
|
||||
const res = await fetch('/api/settings/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service }),
|
||||
})
|
||||
results.value = { ...results.value, [service]: await res.json() }
|
||||
} finally {
|
||||
busy.value = { ...busy.value, [service]: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function pickUser() {
|
||||
await saveAndTest('jellyfin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings">
|
||||
<h2>Inställningar</h2>
|
||||
|
||||
<article class="card">
|
||||
<h3>Gemini <span v-if="saved.gemini.apiKeySet" class="badge">nyckel sparad ✓</span></h3>
|
||||
<p class="dim">Driver förslagen på Upptäck-sidan. Skapa nyckel på aistudio.google.com/apikey.</p>
|
||||
<label>API-nyckel
|
||||
<input v-model="gemini.apiKey" type="password" :placeholder="saved.gemini.apiKeySet ? '•••••• (sparad — fyll i för att byta)' : 'AIza…'" />
|
||||
</label>
|
||||
<label>Modell
|
||||
<input v-model="gemini.model" type="text" placeholder="gemini-2.5-flash" />
|
||||
</label>
|
||||
<div class="row">
|
||||
<button :disabled="busy.gemini" @click="saveAndTest('gemini')">
|
||||
{{ busy.gemini ? 'Testar…' : 'Spara & testa' }}
|
||||
</button>
|
||||
<span v-if="results.gemini" :class="results.gemini.ok ? 'ok' : 'fail'">{{ results.gemini.message }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Jellyfin <span v-if="saved.jellyfin.apiKeySet" class="badge">nyckel sparad ✓</span></h3>
|
||||
<p class="dim">
|
||||
Läser din lyssningshistorik som förslags-signal. Nyckel skapas i Jellyfin:
|
||||
Instrumentpanelen → API-nycklar.
|
||||
<template v-if="saved.jellyfin.userName"> Vald användare: <strong>{{ saved.jellyfin.userName }}</strong>.</template>
|
||||
</p>
|
||||
<label>URL
|
||||
<input v-model="jellyfin.url" type="text" placeholder="http://jellyfin:8096" />
|
||||
</label>
|
||||
<label>API-nyckel
|
||||
<input v-model="jellyfin.apiKey" type="password" :placeholder="saved.jellyfin.apiKeySet ? '•••••• (sparad — fyll i för att byta)' : ''" />
|
||||
</label>
|
||||
<label v-if="jfUsers.length">Användare (vems historik)
|
||||
<select v-model="jellyfin.userId" @change="pickUser">
|
||||
<option disabled value="">välj användare…</option>
|
||||
<option v-for="u in jfUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="row">
|
||||
<button :disabled="busy.jellyfin" @click="saveAndTest('jellyfin')">
|
||||
{{ busy.jellyfin ? 'Testar…' : 'Spara & testa' }}
|
||||
</button>
|
||||
<span v-if="results.jellyfin" :class="results.jellyfin.ok ? 'ok' : 'fail'">{{ results.jellyfin.message }}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>Status — fasta kopplingar</h3>
|
||||
<p class="dim">Lidarr och Youtubarr konfigureras i stackens .env på Pi5:an; här kan du testa att de svarar.</p>
|
||||
<div class="row">
|
||||
<button :disabled="busy.lidarr" @click="test('lidarr')">{{ busy.lidarr ? 'Testar…' : 'Testa Lidarr' }}</button>
|
||||
<span v-if="results.lidarr" :class="results.lidarr.ok ? 'ok' : 'fail'">{{ results.lidarr.message }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button :disabled="busy.youtubarr" @click="test('youtubarr')">{{ busy.youtubarr ? 'Testar…' : 'Testa Youtubarr' }}</button>
|
||||
<span v-if="results.youtubarr" :class="results.youtubarr.ok ? 'ok' : 'fail'">{{ results.youtubarr.message }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
max-width: 42rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 0.8rem;
|
||||
padding: 1.1rem 1.3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.dim {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
select {
|
||||
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.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.fail {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DiscoverPage from './pages/DiscoverPage.vue'
|
||||
import LoginPage from './pages/LoginPage.vue'
|
||||
import SearchPage from './pages/SearchPage.vue'
|
||||
import SettingsPage from './pages/SettingsPage.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'discover', component: DiscoverPage },
|
||||
{ path: '/search', name: 'search', component: SearchPage },
|
||||
{ path: '/settings', name: 'settings', component: SettingsPage },
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user