YT Music-koppling i settings: Googles device-flöde inbyggt
All checks were successful
build-and-push / build (push) Successful in 16s
All checks were successful
build-and-push / build (push) Successful in 16s
- POST /api/ytmusic/start + /poll: kör device-flödet mot Google och skriver ytmusicapi-kompatibel oauth.json till Youtubarrs datamapp (mounten byts till rw i stacken) - Settings-sektion: Client ID/secret, 'Spara & koppla' visar kod + länk och pollar tills godkänt; 'Testa kopplingen' verifierar med en token-förnyelse mot Google - 4 nya tester, totalt 21 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01824ZrvG2mDYYrmwqypLNup
This commit is contained in:
@@ -11,6 +11,7 @@ import { mbClient } from './musicbrainz.js'
|
|||||||
import { mapLidarrSearch } from './mapping.js'
|
import { mapLidarrSearch } from './mapping.js'
|
||||||
import { settingsStore } from './settings.js'
|
import { settingsStore } from './settings.js'
|
||||||
import { makeTesters } from './testers.js'
|
import { makeTesters } from './testers.js'
|
||||||
|
import { ytmusicFlow } from './ytmusic.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export function buildApp(config, deps = {}) {
|
|||||||
const mb = deps.musicbrainz ?? mbClient()
|
const mb = deps.musicbrainz ?? mbClient()
|
||||||
const store = deps.store ?? settingsStore(config.dataDir ?? '/data')
|
const store = deps.store ?? settingsStore(config.dataDir ?? '/data')
|
||||||
const testers = deps.testers ?? makeTesters({ config, store })
|
const testers = deps.testers ?? makeTesters({ config, store })
|
||||||
|
const ytmusic = deps.ytmusic ?? ytmusicFlow({ config, store })
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
|
||||||
app.register(fastifyCookie)
|
app.register(fastifyCookie)
|
||||||
@@ -157,6 +159,11 @@ export function buildApp(config, deps = {}) {
|
|||||||
userId: s.jellyfin?.userId ?? '',
|
userId: s.jellyfin?.userId ?? '',
|
||||||
userName: s.jellyfin?.userName ?? '',
|
userName: s.jellyfin?.userName ?? '',
|
||||||
},
|
},
|
||||||
|
ytmusic: {
|
||||||
|
clientIdSet: Boolean(s.ytmusic?.clientId),
|
||||||
|
clientSecretSet: Boolean(s.ytmusic?.clientSecret),
|
||||||
|
connected: existsSync(ytmusic.oauthPath),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,10 +178,36 @@ export function buildApp(config, deps = {}) {
|
|||||||
userId: body.jellyfin?.userId,
|
userId: body.jellyfin?.userId,
|
||||||
userName: body.jellyfin?.userName,
|
userName: body.jellyfin?.userName,
|
||||||
},
|
},
|
||||||
|
ytmusic: {
|
||||||
|
clientId: body.ytmusic?.clientId,
|
||||||
|
clientSecret: body.ytmusic?.clientSecret,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/api/ytmusic/start', async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const out = await ytmusic.start()
|
||||||
|
if (out.error) return reply.code(400).send(out)
|
||||||
|
return out
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'device-flödet kunde inte startas')
|
||||||
|
return reply.code(502).send({ error: 'kunde inte nå Google' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/ytmusic/poll', async (req, reply) => {
|
||||||
|
const deviceCode = req.body?.deviceCode
|
||||||
|
if (!deviceCode) return reply.code(400).send({ error: 'deviceCode saknas' })
|
||||||
|
try {
|
||||||
|
return await ytmusic.poll(deviceCode)
|
||||||
|
} catch (err) {
|
||||||
|
req.log.error({ err }, 'device-poll misslyckades')
|
||||||
|
return reply.code(502).send({ error: 'kunde inte nå Google' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/settings/test', async (req, reply) => {
|
app.post('/api/settings/test', async (req, reply) => {
|
||||||
const service = req.body?.service
|
const service = req.body?.service
|
||||||
if (!testers[service]) return reply.code(400).send({ error: 'okänd tjänst' })
|
if (!testers[service]) return reply.code(400).send({ error: 'okänd tjänst' })
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function loadConfig(env = process.env) {
|
|||||||
},
|
},
|
||||||
youtubarr: {
|
youtubarr: {
|
||||||
url: env.YOUTUBARR_URL ?? 'http://youtubarr',
|
url: env.YOUTUBARR_URL ?? 'http://youtubarr',
|
||||||
|
dataDir: env.YOUTUBARR_DATA ?? '/youtubarr-data',
|
||||||
dbPath: env.YOUTUBARR_DB ?? '/youtubarr-data/db.sqlite3',
|
dbPath: env.YOUTUBARR_DB ?? '/youtubarr-data/db.sqlite3',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
import { effectiveConfig } from './settings.js'
|
import { effectiveConfig } from './settings.js'
|
||||||
|
|
||||||
// Live-tester för varje integration — används av "Spara & testa" på
|
// Live-tester för varje integration — används av "Spara & testa" på
|
||||||
@@ -56,6 +57,40 @@ export function makeTesters({ config, store, fetchImpl = fetch }) {
|
|||||||
return { ok: true, message: `ok — Lidarr ${js.version}` }
|
return { ok: true, message: `ok — Lidarr ${js.version}` }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async ytmusic() {
|
||||||
|
const s = store.load()
|
||||||
|
if (!s.ytmusic?.clientId || !s.ytmusic?.clientSecret) {
|
||||||
|
return { ok: false, message: 'Client ID/secret saknas — spara dem först' }
|
||||||
|
}
|
||||||
|
const oauthPath = path.join(config.youtubarr?.dataDir ?? '/youtubarr-data', 'oauth.json')
|
||||||
|
if (!existsSync(oauthPath)) {
|
||||||
|
return { ok: false, message: 'inte kopplad än — klicka "Koppla YT Music"' }
|
||||||
|
}
|
||||||
|
let tok
|
||||||
|
try {
|
||||||
|
tok = JSON.parse(readFileSync(oauthPath, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: 'oauth.json går inte att läsa' }
|
||||||
|
}
|
||||||
|
let res
|
||||||
|
try {
|
||||||
|
res = await fetchImpl('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: s.ytmusic.clientId,
|
||||||
|
client_secret: s.ytmusic.clientSecret,
|
||||||
|
refresh_token: tok.refresh_token,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: 'kunde inte nå Google (nätfel)' }
|
||||||
|
}
|
||||||
|
if (!res.ok) return { ok: false, message: `Google avvisade token-förnyelsen (${res.status}) — koppla om` }
|
||||||
|
return { ok: true, message: 'ok — YT Music-kopplingen fungerar' }
|
||||||
|
},
|
||||||
|
|
||||||
async youtubarr() {
|
async youtubarr() {
|
||||||
let res
|
let res
|
||||||
try {
|
try {
|
||||||
|
|||||||
67
backend/src/ytmusic.js
Normal file
67
backend/src/ytmusic.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const SCOPE = 'https://www.googleapis.com/auth/youtube'
|
||||||
|
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
|
||||||
|
// Googles device-flöde för YT Music (ytmusicapi-kompatibel oauth.json).
|
||||||
|
// Klienten (settings-sidan) visar kod + länk och pollar tills godkänt.
|
||||||
|
export function ytmusicFlow({ config, store, fetchImpl = fetch }) {
|
||||||
|
const dataDir = config.youtubarr?.dataDir ?? '/youtubarr-data'
|
||||||
|
|
||||||
|
function creds() {
|
||||||
|
const s = store.load()
|
||||||
|
return { clientId: s.ytmusic?.clientId ?? '', clientSecret: s.ytmusic?.clientSecret ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oauthPath: path.join(dataDir, 'oauth.json'),
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
const { clientId } = creds()
|
||||||
|
if (!clientId) return { error: 'spara Client ID först' }
|
||||||
|
const res = await fetchImpl('https://oauth2.googleapis.com/device/code', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FORM,
|
||||||
|
body: new URLSearchParams({ client_id: clientId, scope: SCOPE }).toString(),
|
||||||
|
})
|
||||||
|
const js = await res.json()
|
||||||
|
if (!res.ok) return { error: js.error_description ?? js.error ?? 'okänt fel från Google' }
|
||||||
|
return {
|
||||||
|
deviceCode: js.device_code,
|
||||||
|
userCode: js.user_code,
|
||||||
|
url: js.verification_url ?? 'https://www.google.com/device',
|
||||||
|
interval: js.interval ?? 5,
|
||||||
|
expiresIn: js.expires_in,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async poll(deviceCode) {
|
||||||
|
const { clientId, clientSecret } = creds()
|
||||||
|
if (!clientId || !clientSecret) return { status: 'error', message: 'client id/secret saknas' }
|
||||||
|
const res = await fetchImpl('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FORM,
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
device_code: deviceCode,
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
const js = await res.json()
|
||||||
|
if (js.error === 'authorization_pending' || js.error === 'slow_down') return { status: 'pending' }
|
||||||
|
if (js.error) return { status: 'error', message: js.error_description ?? js.error }
|
||||||
|
const token = {
|
||||||
|
scope: js.scope ?? SCOPE,
|
||||||
|
token_type: js.token_type ?? 'Bearer',
|
||||||
|
access_token: js.access_token,
|
||||||
|
refresh_token: js.refresh_token,
|
||||||
|
expires_in: js.expires_in,
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + (js.expires_in ?? 0),
|
||||||
|
}
|
||||||
|
writeFileSync(this.oauthPath, JSON.stringify(token, null, 2))
|
||||||
|
return { status: 'klar' }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/test/ytmusic.test.js
Normal file
89
backend/test/ytmusic.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { existsSync, mkdtempSync, readFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { settingsStore } from '../src/settings.js'
|
||||||
|
import { ytmusicFlow } from '../src/ytmusic.js'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-yt-'))
|
||||||
|
const store = settingsStore(dataDir)
|
||||||
|
store.save({ ytmusic: { clientId: 'klient-id', clientSecret: 'klient-hemlis' } })
|
||||||
|
const config = { youtubarr: { dataDir } }
|
||||||
|
return { dataDir, store, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('start returnerar kod och länk från Google', async () => {
|
||||||
|
const { store, config } = setup()
|
||||||
|
const flow = ytmusicFlow({
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
fetchImpl: async (url, opts) => {
|
||||||
|
assert.match(url, /device\/code/)
|
||||||
|
assert.match(opts.body, /client_id=klient-id/)
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
device_code: 'dev-123',
|
||||||
|
user_code: 'ABCD-EFGH',
|
||||||
|
verification_url: 'https://www.google.com/device',
|
||||||
|
interval: 5,
|
||||||
|
expires_in: 1800,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const out = await flow.start()
|
||||||
|
assert.equal(out.userCode, 'ABCD-EFGH')
|
||||||
|
assert.equal(out.deviceCode, 'dev-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('start utan client id ger fel', async () => {
|
||||||
|
const dataDir = mkdtempSync(path.join(tmpdir(), 'lyssnarr-yt-'))
|
||||||
|
const flow = ytmusicFlow({ config: { youtubarr: { dataDir } }, store: settingsStore(dataDir) })
|
||||||
|
const out = await flow.start()
|
||||||
|
assert.match(out.error, /Client ID/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('poll: pending -> klar skriver ytmusicapi-kompatibel oauth.json', async () => {
|
||||||
|
const { dataDir, store, config } = setup()
|
||||||
|
let calls = 0
|
||||||
|
const flow = ytmusicFlow({
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
fetchImpl: async () => {
|
||||||
|
calls++
|
||||||
|
if (calls === 1) return { ok: false, json: async () => ({ error: 'authorization_pending' }) }
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'acc',
|
||||||
|
refresh_token: 'ref',
|
||||||
|
expires_in: 3600,
|
||||||
|
scope: 'https://www.googleapis.com/auth/youtube',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(await flow.poll('dev-123'), { status: 'pending' })
|
||||||
|
assert.deepEqual(await flow.poll('dev-123'), { status: 'klar' })
|
||||||
|
const p = path.join(dataDir, 'oauth.json')
|
||||||
|
assert.ok(existsSync(p))
|
||||||
|
const tok = JSON.parse(readFileSync(p, 'utf8'))
|
||||||
|
assert.equal(tok.access_token, 'acc')
|
||||||
|
assert.equal(tok.refresh_token, 'ref')
|
||||||
|
assert.equal(tok.token_type, 'Bearer')
|
||||||
|
assert.ok(tok.expires_at > Math.floor(Date.now() / 1000))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('poll: avslag ger felstatus', async () => {
|
||||||
|
const { store, config } = setup()
|
||||||
|
const flow = ytmusicFlow({
|
||||||
|
config,
|
||||||
|
store,
|
||||||
|
fetchImpl: async () => ({ ok: false, json: async () => ({ error: 'access_denied', error_description: 'nekad' }) }),
|
||||||
|
})
|
||||||
|
assert.deepEqual(await flow.poll('dev-123'), { status: 'error', message: 'nekad' })
|
||||||
|
})
|
||||||
@@ -3,10 +3,12 @@ import { onMounted, ref } from 'vue'
|
|||||||
|
|
||||||
const gemini = ref({ apiKey: '', model: '' })
|
const gemini = ref({ apiKey: '', model: '' })
|
||||||
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
|
const jellyfin = ref({ url: '', apiKey: '', userId: '' })
|
||||||
const saved = ref({ gemini: {}, jellyfin: {} })
|
const ytmusic = ref({ clientId: '', clientSecret: '' })
|
||||||
|
const saved = ref({ gemini: {}, jellyfin: {}, ytmusic: {} })
|
||||||
const results = ref({}) // service -> {ok, message}
|
const results = ref({}) // service -> {ok, message}
|
||||||
const busy = ref({})
|
const busy = ref({})
|
||||||
const jfUsers = ref([])
|
const jfUsers = ref([])
|
||||||
|
const device = ref(null) // {url, userCode} under pågående koppling
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const res = await fetch('/api/settings')
|
const res = await fetch('/api/settings')
|
||||||
@@ -74,6 +76,60 @@ async function test(service) {
|
|||||||
async function pickUser() {
|
async function pickUser() {
|
||||||
await saveAndTest('jellyfin')
|
await saveAndTest('jellyfin')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectYtMusic() {
|
||||||
|
busy.value = { ...busy.value, ytmusic: true }
|
||||||
|
results.value = { ...results.value, ytmusic: null }
|
||||||
|
device.value = null
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ytmusic: {
|
||||||
|
clientId: ytmusic.value.clientId || null,
|
||||||
|
clientSecret: ytmusic.value.clientSecret || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const startRes = await fetch('/api/ytmusic/start', { method: 'POST' })
|
||||||
|
const start = await startRes.json()
|
||||||
|
if (!startRes.ok) {
|
||||||
|
results.value = { ...results.value, ytmusic: { ok: false, message: start.error } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device.value = { url: start.url, userCode: start.userCode }
|
||||||
|
const deadline = Date.now() + (start.expiresIn ?? 600) * 1000
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, (start.interval ?? 5) * 1000))
|
||||||
|
const pollRes = await fetch('/api/ytmusic/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
||||||
|
})
|
||||||
|
const poll = await pollRes.json()
|
||||||
|
if (poll.status === 'klar') {
|
||||||
|
device.value = null
|
||||||
|
ytmusic.value = { clientId: '', clientSecret: '' }
|
||||||
|
results.value = { ...results.value, ytmusic: { ok: true, message: 'kopplad ✓ — Liked Music kan nu läsas' } }
|
||||||
|
saved.value = await (await fetch('/api/settings')).json()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (poll.status === 'error') {
|
||||||
|
device.value = null
|
||||||
|
results.value = { ...results.value, ytmusic: { ok: false, message: poll.message } }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device.value = null
|
||||||
|
results.value = { ...results.value, ytmusic: { ok: false, message: 'koden hann gå ut — prova igen' } }
|
||||||
|
} catch {
|
||||||
|
device.value = null
|
||||||
|
results.value = { ...results.value, ytmusic: { ok: false, message: 'något gick fel' } }
|
||||||
|
} finally {
|
||||||
|
busy.value = { ...busy.value, ytmusic: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -124,6 +180,37 @@ async function pickUser() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<h3>
|
||||||
|
YouTube Music
|
||||||
|
<span v-if="saved.ytmusic.connected" class="badge">kopplad ✓</span>
|
||||||
|
<span v-else-if="saved.ytmusic.clientIdSet" class="badge warn">ej kopplad</span>
|
||||||
|
</h3>
|
||||||
|
<p class="dim">
|
||||||
|
Ger åtkomst till dina gillade låtar och privata spellistor. Kräver en
|
||||||
|
OAuth-klient av typen "TVs and Limited Input devices" i Google Cloud
|
||||||
|
Console (projektet youtubarr → Credentials).
|
||||||
|
</p>
|
||||||
|
<label>Client ID
|
||||||
|
<input v-model="ytmusic.clientId" type="text" :placeholder="saved.ytmusic.clientIdSet ? '(sparad — fyll i för att byta)' : '….apps.googleusercontent.com'" />
|
||||||
|
</label>
|
||||||
|
<label>Client secret
|
||||||
|
<input v-model="ytmusic.clientSecret" type="password" :placeholder="saved.ytmusic.clientSecretSet ? '•••••• (sparad — fyll i för att byta)' : 'GOCSPX-…'" />
|
||||||
|
</label>
|
||||||
|
<div v-if="device" class="device">
|
||||||
|
Öppna <a :href="device.url" target="_blank" rel="noopener">{{ device.url }}</a>
|
||||||
|
och ange koden: <strong class="code">{{ device.userCode }}</strong>
|
||||||
|
<span class="dim">(väntar på godkännande…)</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button :disabled="busy.ytmusic" @click="connectYtMusic">
|
||||||
|
{{ busy.ytmusic ? 'Väntar på Google…' : 'Spara & koppla YT Music' }}
|
||||||
|
</button>
|
||||||
|
<button class="ghostbtn" :disabled="busy.ytmusic" @click="test('ytmusic')">Testa kopplingen</button>
|
||||||
|
<span v-if="results.ytmusic" :class="results.ytmusic.ok ? 'ok' : 'fail'">{{ results.ytmusic.message }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<h3>Status — fasta kopplingar</h3>
|
<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>
|
<p class="dim">Lidarr och Youtubarr konfigureras i stackens .env på Pi5:an; här kan du testa att de svarar.</p>
|
||||||
@@ -214,4 +301,27 @@ select {
|
|||||||
.fail {
|
.fail {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
background: rgba(251, 191, 36, 0.15);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghostbtn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user