ui: add firefly card and onboarding guides

This commit is contained in:
Brad Stein 2026-01-18 00:25:03 -03:00
parent 341e10db3d
commit f08ec8310e
2 changed files with 523 additions and 296 deletions

View File

@ -21,11 +21,78 @@
Change password
</a>
<button v-else class="pill mono" type="button" @click="doLogin">Login</button>
<a class="pill mono" href="/onboarding">Onboarding</a>
</div>
</section>
<section v-if="auth.ready && auth.authenticated">
<div class="account-grid">
<div class="account-column">
<div class="card module">
<div class="module-head">
<h2>Firefly III</h2>
<span
class="pill mono"
:class="
firefly.status === 'ready'
? 'pill-ok'
: firefly.status === 'needs provisioning' || firefly.status === 'login required'
? 'pill-warn'
: firefly.status === 'unavailable' || firefly.status === 'error'
? 'pill-bad'
: ''
"
>
{{ firefly.status }}
</span>
</div>
<p class="muted">
Personal finance manager for budgets and spending. Sign in with the email below and set the server URL to
money.bstein.dev in the Abacus mobile app.
</p>
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://money.bstein.dev" target="_blank" rel="noreferrer">
money.bstein.dev
</a>
</div>
<div class="row">
<span class="k mono">Email</span>
<span class="v mono">{{ firefly.username || auth.email || auth.username }}</span>
</div>
<div class="row">
<span class="k mono">Password updated</span>
<span class="v mono">{{ firefly.passwordUpdatedAt || "unknown" }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="firefly.resetting" @click="resetFirefly">
{{ firefly.resetting ? "Resetting..." : "Reset Firefly password" }}
</button>
</div>
<div v-if="firefly.password" class="secret-box">
<div class="secret-head">
<div class="pill mono">Password</div>
<div class="secret-actions">
<button class="copy mono" type="button" @click="copy('firefly-password', firefly.password)">
copy
<span v-if="copied['firefly-password']" class="copied">copied</span>
</button>
<button class="copy mono" type="button" @click="firefly.revealPassword = !firefly.revealPassword">
{{ firefly.revealPassword ? "hide" : "show" }}
</button>
</div>
</div>
<div class="mono secret">{{ firefly.revealPassword ? firefly.password : "••••••••••••••••" }}</div>
<div class="hint mono">Use this in Firefly III and the Abacus app.</div>
</div>
<div v-else class="hint mono">No password available yet. Try resetting or check back later.</div>
<div v-if="firefly.error" class="error-box">
<div class="mono">{{ firefly.error }}</div>
</div>
</div>
<div class="card module">
<div class="module-head">
<h2>Mail</h2>
@ -122,6 +189,7 @@
<div class="mono">{{ nextcloudMail.error }}</div>
</div>
</div>
</div>
<div class="account-stack">
<div class="card module">
@ -140,10 +208,7 @@
{{ vaultwarden.status }}
</span>
</div>
<p
v-if="vaultwarden.status !== 'ready' && vaultwarden.status !== 'already_present'"
class="muted"
>
<p v-if="vaultwarden.status !== 'ready' && vaultwarden.status !== 'already_present'" class="muted">
Password manager for Atlas accounts. Store your Element recovery key here. Signups are admin-provisioned.
</p>
<div class="kv">
@ -193,7 +258,9 @@
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://health.bstein.dev" target="_blank" rel="noreferrer">health.bstein.dev</a>
<a class="v mono link" href="https://health.bstein.dev" target="_blank" rel="noreferrer">
health.bstein.dev
</a>
</div>
<div class="row">
<span class="k mono">Username</span>
@ -231,69 +298,6 @@
</div>
</div>
<div class="card module">
<div class="module-head">
<h2>Firefly III</h2>
<span
class="pill mono"
:class="
firefly.status === 'ready'
? 'pill-ok'
: firefly.status === 'needs provisioning' || firefly.status === 'login required'
? 'pill-warn'
: firefly.status === 'unavailable' || firefly.status === 'error'
? 'pill-bad'
: ''
"
>
{{ firefly.status }}
</span>
</div>
<p class="muted">
Personal finance manager for budgets and spending. Sign in with the email below and set the server URL to
money.bstein.dev in the Abacus mobile app.
</p>
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://money.bstein.dev" target="_blank" rel="noreferrer">money.bstein.dev</a>
</div>
<div class="row">
<span class="k mono">Email</span>
<span class="v mono">{{ firefly.username || auth.email || auth.username }}</span>
</div>
<div class="row">
<span class="k mono">Password updated</span>
<span class="v mono">{{ firefly.passwordUpdatedAt || "unknown" }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="firefly.resetting" @click="resetFirefly">
{{ firefly.resetting ? "Resetting..." : "Reset Firefly password" }}
</button>
</div>
<div v-if="firefly.password" class="secret-box">
<div class="secret-head">
<div class="pill mono">Password</div>
<div class="secret-actions">
<button class="copy mono" type="button" @click="copy('firefly-password', firefly.password)">
copy
<span v-if="copied['firefly-password']" class="copied">copied</span>
</button>
<button class="copy mono" type="button" @click="firefly.revealPassword = !firefly.revealPassword">
{{ firefly.revealPassword ? "hide" : "show" }}
</button>
</div>
</div>
<div class="mono secret">{{ firefly.revealPassword ? firefly.password : "••••••••••••••••" }}</div>
<div class="hint mono">Use this in Firefly III and the Abacus app.</div>
</div>
<div v-else class="hint mono">No password available yet. Try resetting or check back later.</div>
<div v-if="firefly.error" class="error-box">
<div class="mono">{{ firefly.error }}</div>
</div>
</div>
<div class="card module">
<div class="module-head">
<h2>Jellyfin</h2>
@ -317,7 +321,9 @@
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://stream.bstein.dev" target="_blank" rel="noreferrer">stream.bstein.dev</a>
<a class="v mono link" href="https://stream.bstein.dev" target="_blank" rel="noreferrer">
stream.bstein.dev
</a>
</div>
<div class="row">
<span class="k mono">Username</span>
@ -789,19 +795,20 @@ h1 {
.account-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
margin-top: 12px;
align-items: stretch;
}
.account-column,
.account-stack {
display: grid;
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 12px;
height: 100%;
align-content: start;
}
.account-column .module,
.account-stack .module {
min-height: 0;
display: flex;
@ -944,6 +951,10 @@ button.primary {
.account-stack .module {
flex: none;
}
.account-column .module {
flex: none;
}
}
.admin {

View File

@ -451,6 +451,49 @@
</li>
</ul>
<div class="mobile-guides">
<div class="module-head">
<h3>Mobile app guides</h3>
<span class="pill mono">step-by-step</span>
</div>
<p class="muted">
Each guide expands with screenshots. Set the server URL before logging in so everything points at Atlas.
</p>
<div class="guide-grid">
<div v-for="guide in mobileGuides" :key="guide.id" class="card module guide-card">
<div class="module-head">
<h3>{{ guide.title }}</h3>
<span class="pill mono">{{ guide.app }}</span>
</div>
<p class="muted">{{ guide.description }}</p>
<div class="kv">
<div v-for="field in guide.fields" :key="`${guide.id}-${field.label}`" class="row">
<span class="k mono">{{ field.label }}</span>
<span class="v mono">
<a v-if="field.href" class="guide-link" :href="field.href" target="_blank" rel="noreferrer">
{{ field.value }}
</a>
<span v-else>{{ field.value }}</span>
</span>
</div>
</div>
<div class="guide-actions">
<a class="pill mono guide-link" :href="guide.url" target="_blank" rel="noreferrer">Open</a>
</div>
<details class="guide-details">
<summary class="mono">Photo guide</summary>
<div v-if="guideShots[guide.id] && guideShots[guide.id].length" class="guide-images">
<figure v-for="(shot, index) in guideShots[guide.id]" :key="shot.url" class="guide-shot">
<img :src="shot.url" :alt="`${guide.title} step ${index + 1}`" loading="lazy" />
<figcaption v-if="shot.label" class="mono">{{ shot.label }}</figcaption>
</figure>
</div>
<p v-else class="muted">Guide coming soon.</p>
</details>
</div>
</div>
</div>
<div v-if="status === 'ready'" class="ready-box">
<h3>You're ready</h3>
<p class="muted">
@ -537,6 +580,112 @@ const extraSteps = [
},
];
// Guide images: drop files into src/assets/onboarding/<guideId>/01-step.png to auto-load and order.
const guideShots = buildGuideShots(
import.meta.glob("../assets/onboarding/**/*.{png,jpg,jpeg,webp}", { eager: true, as: "url" }),
);
const mobileGuides = [
{
id: "vaultwarden",
title: "Vaultwarden",
app: "Bitwarden",
description: "Password manager with autofill across devices.",
url: "https://vault.bstein.dev",
fields: [
{ label: "Server", value: "vault.bstein.dev" },
{ label: "Login", value: "Atlas email + password" },
],
},
{
id: "elementx",
title: "Element X",
app: "Matrix chat",
description: "Secure chat for Othrys rooms and direct messages.",
url: "https://live.bstein.dev",
fields: [
{ label: "Homeserver", value: "live.bstein.dev" },
{ label: "Login", value: "Atlas username + password" },
],
},
{
id: "wger",
title: "Wger",
app: "Health tracking",
description: "Workout + nutrition tracking for Atlas.",
url: "https://health.bstein.dev",
fields: [
{ label: "Server", value: "health.bstein.dev" },
{ label: "Login", value: "Use Account page credentials" },
],
},
{
id: "firefly",
title: "Firefly III",
app: "Abacus",
description: "Personal finance manager for budgets and spending.",
url: "https://money.bstein.dev",
fields: [
{ label: "Server", value: "money.bstein.dev" },
{ label: "Login", value: "Use Account page credentials" },
],
},
{
id: "nextcloud",
title: "Nextcloud",
app: "Files + mail",
description: "Files, calendar, and mail on the go.",
url: "https://cloud.bstein.dev",
fields: [
{ label: "Server", value: "cloud.bstein.dev" },
{ label: "Login", value: "Atlas username + password" },
],
},
{
id: "jellyfin",
title: "Jellyfin",
app: "Media streaming",
description: "Personal media library for Atlas.",
url: "https://stream.bstein.dev",
fields: [
{ label: "Server", value: "stream.bstein.dev" },
{ label: "Login", value: "Atlas username + password" },
],
},
];
function buildGuideShots(rawShots) {
const grouped = {};
for (const [path, url] of Object.entries(rawShots)) {
const normalized = path.replace(/\\/g, "/");
const parts = normalized.split("/assets/onboarding/");
if (parts.length < 2) continue;
const [guideId, file] = parts[1].split("/");
if (!guideId || !file) continue;
const order = guideOrder(file);
const label = guideLabel(file);
if (!grouped[guideId]) grouped[guideId] = [];
grouped[guideId].push({ url, order, label, file });
}
Object.values(grouped).forEach((shots) => {
shots.sort((a, b) => (a.order - b.order) || a.file.localeCompare(b.file));
});
return grouped;
}
function guideOrder(filename) {
const prefix = filename.match(/^(\d{1,3})/);
if (prefix) return Number(prefix[1]);
const step = filename.match(/step[-_]?(\d{1,3})/i);
if (step) return Number(step[1]);
return Number.MAX_SAFE_INTEGER;
}
function guideLabel(filename) {
const base = filename.replace(/\.(png|jpe?g|webp)$/i, "");
return base.replace(/^\d+[-_]?/, "").replace(/[-_]/g, " ").trim();
}
function statusLabel(value) {
const key = (value || "").trim();
if (key === "pending_email_verification") return "confirm email";
@ -1228,6 +1377,73 @@ button.secondary {
gap: 6px;
}
.mobile-guides {
margin-top: 18px;
display: grid;
gap: 12px;
}
.guide-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.guide-card {
display: flex;
flex-direction: column;
gap: 8px;
}
.guide-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
.guide-link {
color: rgba(125, 208, 255, 0.9);
text-decoration: none;
}
.guide-details {
margin-top: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.16);
padding: 10px 12px;
}
.guide-details summary {
cursor: pointer;
color: var(--text-muted);
}
.guide-images {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
}
.guide-shot {
margin: 0;
display: grid;
gap: 6px;
}
.guide-shot img {
width: 100%;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.25);
}
.guide-shot figcaption {
color: var(--text-muted);
font-size: 12px;
}
@media (max-width: 560px) {
.recovery-verify {
flex-direction: column;