From b38a2674f46abdbc838468728f51cfdd687a127b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 22:18:17 +0100 Subject: [PATCH] feat(web): sources: connect from the manifest, device sign-in, file import, schedule, runs Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/DeviceSignIn.tsx | 104 ++++++ apps/web/src/components/SourceForm.tsx | 104 ++++++ apps/web/src/main.tsx | 3 + apps/web/src/routes/sources.tsx | 456 +++++++++++++++++++++++ apps/web/src/theme.css | 80 ++++ 5 files changed, 747 insertions(+) create mode 100644 apps/web/src/components/DeviceSignIn.tsx create mode 100644 apps/web/src/components/SourceForm.tsx create mode 100644 apps/web/src/routes/sources.tsx diff --git a/apps/web/src/components/DeviceSignIn.tsx b/apps/web/src/components/DeviceSignIn.tsx new file mode 100644 index 0000000..36548c0 --- /dev/null +++ b/apps/web/src/components/DeviceSignIn.tsx @@ -0,0 +1,104 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { api, type DeviceStart } from "../api.ts"; +import { friendlyError } from "../format.ts"; + +type Phase = { k: "starting" } | { k: "waiting"; d: DeviceStart } | { k: "connected" } | { k: "expired" } | { k: "error"; msg: string }; + +/** + * Device-code sign-in: shows the code and the link, then polls at the plugin's interval until the user + * approves, the code expires, or the dialog closes. A modal, because the user leaves for another site and + * comes back to exactly this. + */ +export function DeviceSignIn({ connectionId, sourceName, onClose }: { connectionId: string; sourceName: string; onClose: () => void }) { + const qc = useQueryClient(); + const ref = useRef(null); + const [phase, setPhase] = useState({ k: "starting" }); + const [attempt, setAttempt] = useState(0); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const dlg = ref.current; + if (dlg && !dlg.open) dlg.showModal(); + }, []); + + useEffect(() => { + let stopped = false; + let timer: ReturnType | undefined; + setPhase({ k: "starting" }); + api + .deviceStart(connectionId) + .then((d) => { + if (stopped) return; + setPhase({ k: "waiting", d }); + const deadline = Date.now() + d.expiresInSec * 1000; + const every = Math.max(1, d.intervalSec) * 1000; + const tick = async () => { + if (stopped) return; + if (Date.now() > deadline) return setPhase({ k: "expired" }); + try { + const r = await api.devicePoll(connectionId, d.deviceCode); + if (stopped) return; + if (r.status === "connected") { + setPhase({ k: "connected" }); + void qc.invalidateQueries({ queryKey: ["sources"] }); + return; + } + timer = setTimeout(() => void tick(), every); + } catch (e) { + if (!stopped) setPhase({ k: "error", msg: friendlyError(e) }); + } + }; + timer = setTimeout(() => void tick(), every); + }) + .catch((e) => !stopped && setPhase({ k: "error", msg: friendlyError(e) })); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; + }, [connectionId, attempt, qc]); + + async function copy(code: string) { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + } catch { + setCopied(false); + } + } + + return ( + +

Sign in to {sourceName}

+ {phase.k === "starting" &&

Asking {sourceName} for a code…

} + {phase.k === "waiting" && ( + <> +

+ Open the link, sign in to {sourceName} if asked, and enter this code. This window updates by itself once you approve. +

+
+ {phase.d.userCode} + +
+ + Open {phase.d.verificationUrl.replace(/^https?:\/\//, "")} + +

Waiting for you to approve…

+ + )} + {phase.k === "connected" && ( +

Connected. {sourceName} will sync on its schedule, or use “Sync now”.

+ )} + {phase.k === "expired" && ( +

The code expired before it was approved. Get a new one and try again.

+ )} + {phase.k === "error" &&

{phase.msg}

} +
+ {(phase.k === "expired" || phase.k === "error") && ( + + )} + +
+
+ ); +} diff --git a/apps/web/src/components/SourceForm.tsx b/apps/web/src/components/SourceForm.tsx new file mode 100644 index 0000000..9671a1e --- /dev/null +++ b/apps/web/src/components/SourceForm.tsx @@ -0,0 +1,104 @@ +import { useId } from "react"; +import type { ConfigField } from "../api.ts"; + +/** + * Inputs rendered from a plugin manifest's `config` fields. Secrets are password inputs and are never + * filled in: on edit, an empty secret keeps the saved one. + */ +export function ConfigFields({ + fields, + values, + savedSecrets, +}: { + fields: ConfigField[]; + values: Record; + savedSecrets: boolean; +}) { + const base = useId(); + return ( + <> + {fields.map((f) => { + const id = `${base}-${f.key}`; + const help = f.type === "secret" && savedSecrets ? "Saved. Leave empty to keep it." : f.help; + const describedBy = help ? `${id}-help` : undefined; + const v = values[f.key]; + let input; + if (f.type === "boolean") { + return ( +
+ + {help && {help}} +
+ ); + } + if (f.type === "select") { + input = ( + + ); + } else if (f.type === "secret") { + input = ( + + ); + } else { + input = ( + + ); + } + return ( +
+ + {input} + {help && {help}} +
+ ); + })} + + ); +} + +/** + * Read a connect/edit form into the API's `config`. Plain fields are sent whole (the server replaces + * them); secrets only when typed, so an empty one keeps what is saved. + */ +export function readConfig(fields: ConfigField[], form: FormData): Record { + const out: Record = {}; + for (const f of fields) { + const raw = form.get(f.key); + if (f.type === "boolean") out[f.key] = raw === "on"; + else if (f.type === "secret") { + if (typeof raw === "string" && raw !== "") out[f.key] = raw; + } else if (typeof raw === "string" && raw.trim() !== "") out[f.key] = f.type === "number" ? Number(raw) : raw.trim(); + } + return out; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3c680e7..c93d481 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -11,6 +11,7 @@ import { Library } from "./routes/library.tsx"; import { Login } from "./routes/login.tsx"; import { NotYet } from "./routes/not-yet.tsx"; import { Register } from "./routes/register.tsx"; +import { SourceDetail, Sources } from "./routes/sources.tsx"; const queryClient: QueryClient = new QueryClient({ queryCache: new QueryCache({ @@ -58,6 +59,8 @@ function App() { {() => } {(p) => } + + {(p) => } {(p) => } diff --git a/apps/web/src/routes/sources.tsx b/apps/web/src/routes/sources.tsx new file mode 100644 index 0000000..e5eb236 --- /dev/null +++ b/apps/web/src/routes/sources.tsx @@ -0,0 +1,456 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Link, useLocation, useSearch } from "wouter"; +import { api, type Connection, type PluginInfo, type Run } from "../api.ts"; +import { DeviceSignIn } from "../components/DeviceSignIn.tsx"; +import { LoadError } from "../components/LoadError.tsx"; +import { ConfigFields, readConfig } from "../components/SourceForm.tsx"; +import { friendlyError, mediaTypeLabel, timeAgo } from "../format.ts"; +import { useSources } from "../hooks.ts"; +import { scheduleLabel, SCHEDULES } from "../labels.ts"; + +export function usePlugins() { + return useQuery({ queryKey: ["plugins"], queryFn: api.plugins, staleTime: 5 * 60_000 }); +} + +const syncs = (p: PluginInfo | undefined) => !!p && (p.capabilities.includes("pull") || p.capabilities.includes("pullChanges")); +const imports = (p: PluginInfo | undefined) => !!p && p.capabilities.includes("importFile"); + +function StatusBadge({ c }: { c: Connection }) { + if (c.status === "active") return Active; + if (c.status === "error") return Needs attention; + return Paused; +} + +/** What a plugin does, in plain words. */ +function pluginAbilities(p: PluginInfo): string { + const out: string[] = []; + if (syncs(p)) out.push("Syncs on a schedule"); + if (imports(p)) out.push("Imports an export file"); + if (p.deviceAuth) out.push("Signs in with a code"); + return out.join(" · "); +} + +function ScheduleSelect({ id, value, onChange, disabled }: { id: string; value: number | null; onChange: (v: number | null) => void; disabled?: boolean }) { + const options = SCHEDULES.includes(value) ? SCHEDULES : [...SCHEDULES, value]; + return ( + + ); +} + +// --------------------------------------------------------------------------------------------- +// /sources +// --------------------------------------------------------------------------------------------- + +export function Sources() { + const sources = useSources(); + const plugins = usePlugins(); + const [connecting, setConnecting] = useState(null); + const byId = new Map((plugins.data ?? []).map((p) => [p.id, p])); + + return ( +
+
+
+

Sources

+

+ The places STTracker watches for what you read, watch, play and listen to. Sources only read: nothing you do here is sent back to them. +

+
+ + {sources.error && sources.data && void sources.refetch()} stale />} + {sources.error && !sources.data ? ( + void sources.refetch()} /> + ) : (sources.data ?? []).length > 0 ? ( +
+

Your sources

+
    + {sources.data!.map((c) => ( +
  • + + {c.name} + {byId.get(c.pluginId)?.name ?? c.pluginId} + {c.lastRunAt ? `Last sync ${timeAgo(c.lastRunAt)}` : "Never synced"} + {syncs(byId.get(c.pluginId)) ? scheduleLabel(c.scheduleMinutes) : "Import only"} + + +
  • + ))} +
+
+ ) : null} + +
+

Connect a source

+ Each source can be connected more than once, for example for two accounts. + {plugins.error && !plugins.data ? ( + void plugins.refetch()} /> + ) : ( +
    + {(plugins.data ?? []).map((p) => ( +
  • +
    +
    +

    {p.name}

    +

    {p.mediaTypes.slice(0, 6).map(mediaTypeLabel).join(", ")}{p.mediaTypes.length > 6 ? "…" : ""}

    +
    + {connecting !== p.id && ( + + )} +
    +

    {pluginAbilities(p)}

    + {connecting === p.id && setConnecting(null)} />} +
  • + ))} +
+ )} +
+
+
+ ); +} + +function ConnectForm({ plugin, onCancel }: { plugin: PluginInfo; onCancel: () => void }) { + const qc = useQueryClient(); + const [, navigate] = useLocation(); + const [error, setError] = useState(null); + const [schedule, setSchedule] = useState(syncs(plugin) ? 1440 : null); + const first = useRef(null); + useEffect(() => first.current?.focus(), []); + const create = useMutation({ + mutationFn: api.createSource, + onSuccess: (c) => { + void qc.invalidateQueries({ queryKey: ["sources"] }); + navigate(`/sources/${c.id}${plugin.deviceAuth ? "?signin=1" : ""}`); + }, + onError: (e) => setError(friendlyError(e)), + }); + function submit(e: FormEvent) { + e.preventDefault(); + const f = new FormData(e.currentTarget); + setError(null); + create.mutate({ + pluginId: plugin.id, + name: String(f.get("name") ?? "").trim() || plugin.name, + config: readConfig(plugin.config, f), + scheduleMinutes: syncs(plugin) ? schedule : null, + }); + } + return ( +
+
+ + +
+ + {syncs(plugin) && ( +
+ + +
+ )} + {plugin.deviceAuth &&

Next, you'll sign in to {plugin.name} with a short code.

} + {error &&

{error}

} +
+ + +
+ + ); +} + +// --------------------------------------------------------------------------------------------- +// /sources/:id +// --------------------------------------------------------------------------------------------- + +export function SourceDetail({ id }: { id: string }) { + const qc = useQueryClient(); + const [, navigate] = useLocation(); + const search = new URLSearchParams(useSearch()); + const sources = useSources(); + const plugins = usePlugins(); + const conn = sources.data?.find((c) => c.id === id); + const plugin = plugins.data?.find((p) => p.id === conn?.pluginId); + const [signingIn, setSigningIn] = useState(search.get("signin") === "1"); + const [actionError, setActionError] = useState(null); + const [said, setSaid] = useState(""); + const [pollUntil, setPollUntil] = useState(0); + + const runs = useQuery({ + queryKey: ["runs", id], + queryFn: () => api.runs(id), + enabled: !!conn, + refetchInterval: (q) => (q.state.data?.some((r) => r.status === "running") || Date.now() < pollUntil ? 2000 : false), + }); + // When a run finishes, its results show up elsewhere: refresh them. + const lastFinished = runs.data?.find((r) => r.status !== "running")?.id; + const seen = useRef(undefined); + useEffect(() => { + if (seen.current !== undefined && lastFinished !== seen.current) { + for (const k of ["changes", "entries", "sources", "log"]) void qc.invalidateQueries({ queryKey: [k] }); + setPollUntil(0); + } + seen.current = lastFinished; + }, [lastFinished, qc]); + + const done = (msg: string) => () => { + setActionError(null); + setSaid(msg); + void qc.invalidateQueries({ queryKey: ["sources"] }); + }; + const fail = (e: unknown) => setActionError(friendlyError(e)); + const run = useMutation({ + mutationFn: () => api.runSource(id), + onSuccess: () => { + done("Sync queued.")(); + setPollUntil(Date.now() + 60_000); + void qc.invalidateQueries({ queryKey: ["runs", id] }); + }, + onError: fail, + }); + const patch = useMutation({ + mutationFn: (b: Parameters[1]) => api.patchSource(id, b), + onSuccess: (_c, b) => done(b.status ? (b.status === "paused" ? "Paused." : "Resumed.") : "Saved.")(), + onError: fail, + }); + const remove = useMutation({ + mutationFn: () => api.deleteSource(id), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["sources"] }); + navigate("/sources", { replace: true }); + }, + onError: fail, + }); + const [confirming, setConfirming] = useState(false); + + if (sources.isPending || plugins.isPending) return
; + if (!conn) { + return ( +
+
+ {sources.error ? ( + void sources.refetch()} /> + ) : ( +
+ This source isn't connected +

It may have been disconnected. Anything it imported is still in your library.

+ All sources +
+ )} +
+
+ ); + } + + const canSync = syncs(plugin); + const running = runs.data?.some((r) => r.status === "running") ?? false; + + return ( +
+
+ ‹ All sources + {sources.error && void sources.refetch()} stale />} +
+
+

{conn.name}

+

+ {plugin?.name ?? conn.pluginId} +

+
+
+ {canSync && ( + + )} + {conn.status === "active" ? ( + + ) : ( + + )} +
+
+

{said}

+ {actionError &&

{actionError}

} + +
+
+

Status

+
Last sync{conn.lastRunAt ? timeAgo(conn.lastRunAt) : "Never"}
+ {canSync && ( +
+ + + patch.mutate({ scheduleMinutes: v })} disabled={patch.isPending} /> + +
+ )} +
Sign-in{conn.hasCredentials ? "Saved" : plugin?.deviceAuth ? "Not signed in" : "Not needed"}
+
Write-backNot available in this version
+

STTracker only reads from {conn.name}. Your edits here stay here.

+ {conn.status === "paused" && canSync &&

Paused: scheduled syncs and “Sync now” are off until you resume.

} +
+ + {plugin?.deviceAuth && ( +
+

Sign in

+

+ {conn.hasCredentials ? `Signed in. Sign in again if ${plugin.name} stops syncing.` : `Connect your ${plugin.name} account with a short code.`} +

+ +
+ )} + + {imports(plugin) && (setPollUntil(Date.now() + 120_000), void qc.invalidateQueries({ queryKey: ["runs", id] }))} />} + + + +
+

Recent syncs

+ {runs.error && runs.data && void runs.refetch()} stale />} + {runs.error && !runs.data ? ( + void runs.refetch()} /> + ) : runs.data && runs.data.length === 0 ? ( +

No syncs yet.{canSync ? " Use “Sync now” to run the first one." : ""}

+ ) : ( +
    {(runs.data ?? []).map((r) => )}
+ )} +
+ +
+

Disconnect

+

Stops syncing and forgets the sign-in. Everything it imported stays in your library.

+ {confirming ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ {signingIn && plugin && (setSigningIn(false), navigate(`/sources/${id}`, { replace: true }))} />} +
+ ); +} + +function ImportBox({ conn, onQueued }: { conn: Connection; onQueued: () => void }) { + const [file, setFile] = useState(null); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const input = useRef(null); + const upload = useMutation({ + mutationFn: (f: File) => api.importToSource(conn.id, f), + onSuccess: () => { + setMsg({ ok: true, text: "Import queued. New entries appear in your library as it runs; anything that clashes with your edits waits in Changes." }); + setFile(null); + if (input.current) input.current.value = ""; + onQueued(); + }, + onError: (e) => setMsg({ ok: false, text: friendlyError(e) }), + }); + return ( +
+

Import a file

+
{ + e.preventDefault(); + if (file) upload.mutate(file); + }} + > + + +
+

Up to 25 MB. Importing the same file twice doesn't create duplicates.

+ {msg &&

{msg.text}

} +
+ ); +} + +function SettingsBox({ conn, plugin }: { conn: Connection; plugin: PluginInfo | undefined }) { + const qc = useQueryClient(); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const [formKey, setFormKey] = useState(0); + const save = useMutation({ + mutationFn: (b: Parameters[1]) => api.patchSource(conn.id, b), + onSuccess: () => { + setMsg({ ok: true, text: "Saved." }); + setFormKey((k) => k + 1); + void qc.invalidateQueries({ queryKey: ["sources"] }); + }, + onError: (e) => setMsg({ ok: false, text: friendlyError(e) }), + }); + const fields = plugin?.config ?? []; + return ( +
+

Settings

+
{ + e.preventDefault(); + const f = new FormData(e.currentTarget); + setMsg(null); + save.mutate({ name: String(f.get("name") ?? "").trim() || conn.name, config: readConfig(fields, f) }); + }} + > +
+ + +
+ +
+ +
+ {msg &&

{msg.text}

} + +
+ ); +} + +const COUNT_WORDS: [string, string][] = [ + ["seen", "seen"], + ["added", "added"], + ["changed", "updated"], + ["pending", "waiting in Changes"], + ["conflicts", "conflicts"], + ["removed", "gone at source"], + ["listens", "listens"], +]; + +function RunRow({ r }: { r: Run }) { + const counts = COUNT_WORDS.filter(([k]) => (r.counts[k] ?? 0) > 0).map(([k, w]) => `${r.counts[k]} ${w}`); + const mode = r.mode === "file" ? "File import" : r.mode === "full" ? "Full sync" : "Sync"; + return ( +
  • +
    + + {r.status === "ok" ? "Done" : r.status === "failed" ? "Failed" : "Running"} + + {mode} + +
    + {counts.length > 0 &&

    {counts.join(" · ")}

    } + {r.error &&

    {r.error}

    } + {r.log && ( +
    + Log +
    {r.log}
    +
    + )} +
  • + ); +} diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 7776c8c..a603095 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -473,3 +473,83 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; .change-top { flex-wrap: wrap; } .change-title { white-space: normal; } } + +/* ---------- Forms (shared by the new screens) ---------- */ +.field > label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; } +.field .req { color: var(--text-faint); font-weight: 400; } +.inline-check { display: inline-flex; gap: 8px; align-items: center; font-size: 0.84rem; color: var(--text); } +.form-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.stack-form { display: grid; gap: 12px; } +.ok-text { font-size: 0.8rem; color: var(--ok); } +.box-text { font-size: 0.84rem; color: var(--text-muted); margin-bottom: 10px; max-width: 60ch; } +.muted-val { color: var(--text-muted); } +.back-link { margin: 0 0 10px -8px; } + +/* ---------- Sources ---------- */ +.conn-list { list-style: none; display: grid; gap: 2px; margin-top: 10px; } +.conn-row { + display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 0.8fr) auto; + gap: 14px; align-items: center; padding: 10px 12px; margin: 0 -12px; border-radius: var(--r-control); + transition: background 0.12s; +} +.conn-row:hover { background: var(--surface-2); } +.conn-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conn-meta { color: var(--text-muted); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.plugin-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; } +.plugin { background: var(--surface-2); border-radius: var(--r-box); padding: 13px 14px; display: grid; gap: 6px; align-content: start; } +.plugin.open { grid-column: 1 / -1; } +.plugin-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; } +.plugin h3 { font-size: 0.92rem; font-weight: 600; } +.plugin-types { font-size: 0.76rem; color: var(--text-muted); margin-top: 1px; } +.plugin-does { font-size: 0.76rem; color: var(--text-faint); } +.connect-form { display: grid; gap: 12px; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--surface-3); max-width: 520px; } +.src-sub { display: flex; gap: 10px; align-items: center; } +.head-actions { display: flex; gap: 6px; flex: none; } +.src-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.src-grid .box .r label { color: var(--text-muted); } +.src-grid .box .r .select { width: auto; padding: 4px 28px 4px 9px; font-size: 0.82rem; } +.src-grid .box .hint { display: block; } +.import-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.file-pick input { font-size: 0.8rem; color: var(--text-muted); max-width: 100%; } +.file-pick input::file-selector-button { + font: inherit; font-size: 0.8rem; color: var(--text); background: var(--surface-3); border: 0; + border-radius: var(--r-control); padding: 6px 11px; margin-right: 10px; cursor: pointer; +} +.file-pick input::file-selector-button:hover { background: #3a346a; } +.runs { list-style: none; display: grid; } +.run { padding: 9px 0; border-top: 1px solid var(--surface-3); display: grid; gap: 4px; } +.run:first-child { border-top: 0; padding-top: 2px; } +.run-line { display: flex; gap: 10px; align-items: center; font-size: 0.84rem; } +.run-mode { flex: 1; } +.run-when { color: var(--text-faint); font-size: 0.76rem; } +.run-counts { font-size: 0.78rem; color: var(--text-muted); } +.run details summary { font-size: 0.76rem; color: var(--text-faint); cursor: pointer; width: max-content; } +.run details summary:hover { color: var(--text); } +.run pre { margin-top: 6px; max-height: 220px; overflow: auto; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); background: var(--surface-0); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; } +.danger-zone { background: transparent; border: 1px solid var(--surface-3); } + +/* ---------- Modal (device sign-in) ---------- */ +.modal { + margin: auto; width: min(440px, calc(100vw - 32px)); border: 1px solid var(--surface-3); border-radius: 14px; + background: var(--surface-1); color: var(--text); padding: 22px 22px 18px; box-shadow: 0 24px 60px #000a; +} +.modal::backdrop { background: #0b0916b8; } +@media (prefers-reduced-motion: no-preference) { + .modal[open] { animation: modal-in 0.28s var(--ease-out); } + @keyframes modal-in { from { opacity: 0; translate: 0 8px; } } +} +.modal h2 { font-size: 1.05rem; font-weight: 650; } +.modal .lede { color: var(--text-muted); margin: 6px 0 16px; font-size: 0.86rem; } +.device-code { display: flex; align-items: center; gap: 12px; justify-content: space-between; background: var(--surface-0); border-radius: var(--r-box); padding: 14px 16px; } +.device-code .code { font-size: 1.7rem; font-weight: 650; letter-spacing: 0.14em; color: var(--rating); } +.device-link { margin-top: 12px; width: 100%; justify-content: center; padding: 9px 14px; } +.waiting { margin-top: 10px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 18px; } + +@media (max-width: 860px) { + .src-grid { grid-template-columns: 1fr; } + .screen-head.with-action { flex-direction: column; } + .conn-row { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; } + .conn-row .conn-meta { grid-column: 1; } + .conn-row .badge { grid-row: 1; grid-column: 2; } +}