add platform pages

This commit is contained in:
JCEEE
2026-08-23 15:12:19 +01:00
parent 762fbab4b6
commit bb2b1759dc
11 changed files with 1044 additions and 245 deletions
+190 -41
View File
@@ -1,39 +1,117 @@
import { pbAdmin } from '$lib/server/pocketbase';
import { pbAdmin, createPbClient } from '$lib/server/pocketbase';
import { redirect, fail } from '@sveltejs/kit';
import { PB_EMAIL, PB_PASSWORD } from '$app/env/private';
import type { RequestEvent } from '@sveltejs/kit';
import { setPlatformSession, clearPlatformSession } from '$lib/server/session';
import { getPlatformFlags, setPlatformFlag } from '$lib/server/platform';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const session = cookies.get('platform_session');
if (!session) {
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
function requirePlatform(event: RequestEvent) {
if (!event.locals.platformAdmin) throw redirect(303, '/admin');
}
// Random human-friendly code value: XXXX-XXXX (unambiguous charset).
function genCodeValue(): string {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const pick = () => chars[Math.floor(Math.random() * chars.length)];
return `${Array.from({ length: 4 }, pick).join('')}-${Array.from({ length: 4 }, pick).join('')}`;
}
export const load: PageServerLoad = async (event) => {
const { cookies } = event;
if (!event.locals.platformAdmin) {
return {
authenticated: false,
fams: [],
codes: [],
platformFlags: {},
totalFams: 0,
totalMembers: 0,
totalRewards: 0,
totalChores: 0,
subCount: 0,
gatedCount: 0,
codeCount: 0,
loadError: undefined
};
}
try {
const [fams, platformFlags] = await Promise.all([pbAdmin.getList('fams'), getPlatformFlags()]);
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
const [members, rewards, parents] = await Promise.all([
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`),
]);
return {
id: fam.id, name: fam.name, slug: fam.slug,
memberCount: members.length,
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
totalRewards: rewards.length,
parentEmail: (parents as any[])?.[0]?.email || '',
};
}));
const [fams, codes, completions] = await Promise.all([
pbAdmin.getList('fams'),
pbAdmin.getList('accesscodes'),
pbAdmin.getList('completions')
]);
const famsWithStats = await Promise.all(
fams.map(async (fam: any) => {
const [members, rewards, parents] = await Promise.all([
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'child'`),
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
pbAdmin.getList('users', `famId = '${fam.id}' && role = 'parent'`)
]);
return {
id: fam.id,
name: fam.name,
slug: fam.slug,
memberCount: members.length,
requestedRewards: (rewards as any[]).filter((r: any) => r.status === 'requested').length,
totalRewards: rewards.length,
parentEmail: (parents as any[])?.[0]?.email || '',
paymentMode: fam.paymentMode || 'none',
active: fam.active !== false
};
})
);
const totalFams = fams.length;
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
// Usage map — which fams applied each code.
const usage: Record<string, string[]> = {};
for (const fam of fams as any[]) {
if (fam.paymentMode === 'code' && fam.accessCodeId) {
(usage[fam.accessCodeId] ||= []).push(fam.name);
}
}
const codeList = (codes as any[])
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
.map((c) => ({
id: c.id,
value: c.value,
name: c.name,
duration: Number(c.duration) || 0,
expiry: Number(c.expiry) || 0,
trialDays: Number(c.trialDays) || 0,
active: c.active !== false,
usedBy: usage[c.id] || []
}));
return { authenticated: true, fams: famsWithStats, totalFams, totalMembers, totalRewards, platformFlags };
} catch {
return { authenticated: false, fams: [], totalFams: 0, totalMembers: 0, totalRewards: 0, platformFlags: {} };
return {
authenticated: true,
fams: famsWithStats,
codes: codeList,
totalFams: fams.length,
totalMembers: famsWithStats.reduce((s, f) => s + f.memberCount, 0),
totalRewards: famsWithStats.reduce((s, f) => s + f.totalRewards, 0),
totalChores: completions.length,
subCount: famsWithStats.filter((f) => f.paymentMode === 'sub').length,
gatedCount: famsWithStats.filter(
(f) => !f.active || f.paymentMode === 'none' || f.paymentMode === 'canceled'
).length,
codeCount: codeList.filter((c) => c.active).length
};
} catch (e) {
// Never swallow silently — a failed load must not masquerade as logged-out.
console.error('[admin] load failed:', e);
return {
authenticated: false,
fams: [],
codes: [],
totalFams: 0,
totalMembers: 0,
totalRewards: 0,
totalChores: 0,
subCount: 0,
gatedCount: 0,
codeCount: 0,
loadError: e instanceof Error ? e.message : 'Failed to load platform data'
};
}
};
@@ -43,29 +121,32 @@ export const actions: Actions = {
const email = fd.get('email') as string;
const password = fd.get('password') as string;
if (email === PB_EMAIL && password === PB_PASSWORD) {
cookies.set('platform_session', 'authenticated', {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
});
return { success: true };
if (!email || !password) return fail(400, { error: 'Email and password required' });
// Real PB superuser auth — the minted JWT goes in the cookie and is
// verified per-request in hooks (authRefresh). Forging the cookie
// value gains nothing.
try {
const auth = await createPbClient()
.collection('_superusers')
.authWithPassword(email, password);
setPlatformSession(cookies, auth.token);
} catch {
return fail(400, { error: 'Invalid credentials' });
}
return fail(400, { error: 'Invalid credentials' });
return { success: true };
},
logout: async ({ cookies }) => {
cookies.delete('platform_session', { path: '/' });
clearPlatformSession(cookies);
throw redirect(303, '/admin');
},
// Toggles a platform-level feature flag on the singleton platform record.
togglePlatformFlag: async ({ request, cookies }) => {
const session = cookies.get('platform_session');
if (!session) return fail(401, { error: 'Not authenticated' });
togglePlatformFlag: async (event) => {
requirePlatform(event);
const fd = await request.formData();
const fd = await event.request.formData();
const flag = fd.get('flag') as string;
if (!flag) return fail(400, { error: 'Flag required' });
@@ -77,4 +158,72 @@ export const actions: Actions = {
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update' });
}
},
// Issue a new access/trial code. Blank value → auto-generated. trialDays > 0
// makes it a trial code (maps to Stripe trial_period_days at checkout);
// otherwise it's a platform-access code (duration months, 0 = continuous).
createCode: async (event) => {
requirePlatform(event);
const fd = await event.request.formData();
const name = ((fd.get('name') as string) || '').trim();
const value = ((fd.get('value') as string) || '').trim().toUpperCase() || genCodeValue();
const duration = Math.max(0, parseInt(fd.get('duration') as string, 10) || 0);
const expiry = Math.max(0, parseInt(fd.get('expiry') as string, 10) || 0);
const trialDays = Math.max(0, parseInt(fd.get('trialDays') as string, 10) || 0);
if (!name) return fail(400, { error: 'Name required' });
try {
const existing = await pbAdmin.getList('accesscodes', `value = '${value}'`);
if (existing.length) return fail(400, { error: `Code "${value}" already exists` });
await pbAdmin.create('accesscodes', {
value,
name,
duration,
expiry,
trialDays,
active: true,
createdAt: new Date().toISOString()
});
return { success: true, createdValue: value };
} catch (e) {
return fail(500, { error: e instanceof Error ? e.message : 'Failed to create code' });
}
},
toggleCode: async (event) => {
requirePlatform(event);
const fd = await event.request.formData();
const id = fd.get('id') as string;
try {
const rec = (await pbAdmin.getOne('accesscodes', id)) as any;
await pbAdmin.update('accesscodes', id, { active: rec.active === false });
return { success: true };
} catch (e) {
return fail(500, { error: e instanceof Error ? e.message : 'Failed to update code' });
}
},
deleteCode: async (event) => {
requirePlatform(event);
const fd = await event.request.formData();
const id = fd.get('id') as string;
// Guard: a code still applied to a fam must be disabled, not deleted.
const inUse = await pbAdmin.getList('fams', `accessCodeId = '${id}'`);
if (inUse.length) {
return fail(400, {
error: `In use by ${inUse.length} fam${inUse.length > 1 ? 's' : ''} — disable it instead.`
});
}
try {
await pbAdmin.remove('accesscodes', id);
return { success: true };
} catch (e) {
return fail(500, { error: e instanceof Error ? e.message : 'Failed to delete code' });
}
}
};
+515 -80
View File
@@ -1,12 +1,43 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
import Footer from '$lib/components/Footer.svelte';
let { data, form } = $props();
let copied = $state('');
function copy(v: string) {
navigator.clipboard.writeText(v);
copied = v;
setTimeout(() => (copied = ''), 2000);
}
const modeLabel: Record<string, string> = {
sub: 'Sub',
code: 'Code',
canceled: 'Canceled',
none: 'None'
};
</script>
<div class="shell">
<header class="topbar">
<a class="brand" href="/"><span class="brand-icon">🏠</span> <strong>FamChore</strong></a>
<nav>
{#if data.authenticated}
<form method="POST" action="?/logout" use:enhance>
<button type="submit" class="nav-link link-btn-nav">Sign Out</button>
</form>
{:else}
<a href="/" class="nav-link">Back to site</a>
{/if}
</nav>
</header>
<main>
{#if !data.authenticated}
<div class="login-container">
<div class="login-card">
<h1>Platform Admin</h1>
<p class="subtitle">Sign in to access the admin dashboard</p>
@@ -14,8 +45,27 @@
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
{#if data.loadError}
<p class="error">Data load failed: {data.loadError}</p>
{/if}
<form method="POST" action="?/login" use:enhance>
<!-- Native document POST (no JS enhancement): 303 → full page load.
Browsers reliably store Set-Cookie from navigations, which sidesteps
fetch/HMR cookie weirdness entirely. -->
<form
method="POST"
action="?/login"
use:enhance={() => {
return async ({ result }) => {
// Handles both action shapes: current code throws a redirect;
// stale modules may return { success }. Either way, force the
// load to re-run with the freshly-set cookie.
if (result.type === 'success') {
await goto('/admin', { invalidateAll: true });
}
};
}}
>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
@@ -27,12 +77,11 @@
<Button type="submit" variant="primary" size="lg">Sign In</Button>
</form>
</div>
</div>
{:else}
<ViewHeader title="Platform Admin" subtitle="Manage families and platform settings" hero />
<ViewHeader title="Platform Admin" subtitle="Families, access codes and platform settings" hero />
<CardGrid>
<Card cols={1} title="Overview" accent="#6366f1">
<Card cols={2} title="Overview" accent="#6366f1">
<div class="stats">
<div class="stat">
<span class="stat-value">{data.totalFams}</span>
@@ -43,8 +92,20 @@
<span class="stat-label">Members</span>
</div>
<div class="stat">
<span class="stat-value">{data.totalRewards}</span>
<span class="stat-label">Rewards</span>
<span class="stat-value">{data.totalChores}</span>
<span class="stat-label">Chores completed</span>
</div>
<div class="stat">
<span class="stat-value">{data.subCount}</span>
<span class="stat-label">Active subs</span>
</div>
<div class="stat">
<span class="stat-value">{data.gatedCount}</span>
<span class="stat-label">Paused / gated</span>
</div>
<div class="stat">
<span class="stat-value">{data.codeCount}</span>
<span class="stat-label">Active codes</span>
</div>
</div>
</Card>
@@ -55,44 +116,141 @@
<span class="flag-name">debug</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="debug" />
<button type="submit" class="toggle-btn" class:active={data.platformFlags?.debug}>
{data.platformFlags?.debug ? 'ON' : 'OFF'}
<button type="submit" class="toggle-btn" class:active={page.data.platformFlags?.debug}>
{page.data.platformFlags?.debug ? 'ON' : 'OFF'}
</button>
</form>
</div>
</Card>
</CardGrid>
<Card cols={2} title="Families" accent="#059669">
<CardGrid>
<Card cols={3} title="Access Codes" accent="#059669">
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<!-- Issue a code -->
<form method="POST" action="?/createCode" use:enhance class="code-form">
<div class="code-fields">
<label>Name <input name="name" placeholder="e.g. Launch promo" required /></label>
<label>Value <input name="value" placeholder="Blank = auto" class="mono" /></label>
<label>Duration (mo) <input name="duration" type="number" min="0" value="0" /></label>
<label>Expiry (mo) <input name="expiry" type="number" min="0" value="0" /></label>
<label>Trial days <input name="trialDays" type="number" min="0" value="0" /></label>
</div>
<div class="code-form-foot">
<span class="hint">
Duration 0 = continuous. Trial days &gt; 0 makes it a Stripe trial code instead of
platform access.
</span>
<Button type="submit" variant="primary" size="sm">Issue code</Button>
</div>
</form>
<div class="table-wrap">
<table class="fam-table code-table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th>Used by</th>
<th class="th-actions">Actions</th>
</tr>
</thead>
<tbody>
{#each data.codes as c}
<tr class:inactive={!c.active}>
<td data-label="Code">
<button class="code-value" onclick={() => copy(c.value)} title="Copy"
>{c.value}</button
>
{#if copied === c.value}<span class="copied">copied!</span>{/if}
</td>
<td data-label="Name">{c.name}</td>
<td data-label="Type">
{#if c.trialDays}
<span class="badge trial">{c.trialDays}-day trial</span>
{:else if c.duration}
<span class="badge dur">{c.duration} mo</span>
{:else}
<span class="badge cont">continuous</span>
{/if}
</td>
<td data-label="Status">
<span class="badge {c.active ? 'on' : 'off'}"
>{c.active ? 'active' : 'disabled'}</span
>
</td>
<td data-label="Used by">
{#if c.usedBy.length}
<span class="used-by" title={c.usedBy.join(', ')}
>{c.usedBy.length} fam{c.usedBy.length > 1 ? 's' : ''}</span
>
{:else}
<span class="muted">—</span>
{/if}
</td>
<td class="row-actions" data-label="Actions">
<form method="POST" action="?/toggleCode" use:enhance>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="link-btn">{c.active ? 'Disable' : 'Enable'}</button>
</form>
{#if !c.usedBy.length}
<form method="POST" action="?/deleteCode" use:enhance>
<input type="hidden" name="id" value={c.id} />
<button type="submit" class="link-btn danger">Delete</button>
</form>
{/if}
</td>
</tr>
{:else}
<tr><td colspan="6" class="muted">No codes yet — issue one above.</td></tr>
{/each}
</tbody>
</table>
</div>
</Card>
</CardGrid>
<CardGrid>
<Card cols={3} title="Families" accent="#6366f1">
<div class="table-wrap">
<table class="fam-table">
<thead>
<tr>
<th>Name</th>
<th>Parent</th>
<th>Members</th>
<th>Plan</th>
<th>Claims</th>
<th>Actions</th>
<th class="th-actions">Actions</th>
</tr>
</thead>
<tbody>
{#each data.fams as fam}
<tr>
<td>
<a href="/{fam.slug}">{fam.name}</a>
<td data-label="Name">
<a href="/{fam.slug}" class="fam-link">{fam.name}</a>
<span class="slug">/{fam.slug}</span>
</td>
<td>{fam.parentEmail || '—'}</td>
<td>{fam.memberCount}</td>
<td>
<td data-label="Parent">{fam.parentEmail || '—'}</td>
<td data-label="Members">{fam.memberCount}</td>
<td data-label="Plan">
<span class="badge {fam.paymentMode === 'sub' ? 'on' : fam.active ? 'dur' : 'off'}">
{modeLabel[fam.paymentMode] || fam.paymentMode}{fam.active ? '' : ' · paused'}
</span>
</td>
<td data-label="Claims">
{#if fam.requestedRewards > 0}
<span class="badge pending">{fam.requestedRewards} pending</span>
{:else}
<span class="badge none">None</span>
{/if}
</td>
<td>
<a href="/{fam.slug}" class="link">Dashboard</a>
<a href="/{fam.slug}" class="link">View</a>
</td>
<td class="row-actions" data-label="Actions"><a href="/{fam.slug}" class="link">View →</a></td>
</tr>
{/each}
</tbody>
@@ -100,103 +258,332 @@
</Card>
</CardGrid>
<form method="POST" action="?/logout" class="logout-form">
<Button type="submit" variant="ghost" size="sm">Sign Out</Button>
</form>
{/if}
</main>
<Footer />
</div>
<style>
.login-container {
display: flex;
justify-content: center;
align-items: center;
.shell {
min-height: 100vh;
background: #f3f4f6;
display: grid;
grid-template-rows: auto 1fr auto;
background:
radial-gradient(60rem 30rem at 50% -10rem, #eef2ff 0%, transparent 65%),
#fafafa;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.8rem 2rem;
border-bottom: 1px solid #f3f4f6;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(6px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 1.05rem;
color: #111827;
text-decoration: none;
}
.topbar nav { display: flex; align-items: center; gap: 1rem; }
.nav-link {
font-size: 0.9rem;
color: #4b5563;
text-decoration: none;
}
.link-btn-nav {
background: none;
border: none;
cursor: pointer;
padding: 0;
font-weight: 500;
}
.link-btn-nav:hover { color: #111827; }
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
width: 100%;
max-width: 1100px;
margin: 0 auto;
}
main :global(.card-grid),
main :global(.hero) { width: 100%; }
.head { text-align: center; }
.head h1 {
margin: 0 0 0.35rem;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 800;
color: #111827;
letter-spacing: -0.02em;
}
.head p {
margin: 0;
font-size: 0.95rem;
color: #6b7280;
}
.login-card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 400px;
max-width: 380px;
text-align: center;
}
.login-card h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
margin: 0 0 0.25rem;
}
.subtitle {
color: #6b7280;
font-size: 0.85rem;
margin: 0 0 1.5rem;
}
.form-group {
margin-bottom: 1rem;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.8rem;
font-weight: 500;
font-size: 0.9rem;
color: #374151;
margin-bottom: 0.3rem;
}
.form-group input {
width: 100%;
padding: 0.5rem;
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.9rem;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem;
border-radius: 6px;
margin-bottom: 1rem;
border-radius: 8px;
font-size: 0.9rem;
}
.stats {
display: flex;
gap: 2rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.stat {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 10px;
color: white;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
.stat:nth-child(2) {
background: linear-gradient(135deg, #059669, #10b981);
}
.stat:nth-child(3) {
background: linear-gradient(135deg, #d97706, #f59e0b);
}
.stat:nth-child(4) {
background: linear-gradient(135deg, #2563eb, #3b82f6);
}
.stat:nth-child(5) {
background: linear-gradient(135deg, #dc2626, #ef4444);
}
.stat:nth-child(6) {
background: linear-gradient(135deg, #7c3aed, #a855f7);
}
.stat-value {
display: block;
font-size: 2rem;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.72rem;
opacity: 0.9;
}
.hint {
font-size: 0.8rem;
color: #9ca3af;
line-height: 1.4;
margin: 0;
}
.muted {
color: #9ca3af;
font-size: 0.85rem;
}
/* Codes */
.code-form {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #f3f4f6;
}
.code-fields {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1fr 1fr;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.code-fields label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
}
.code-fields input {
padding: 0.45rem 0.6rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.85rem;
width: 100%;
}
.mono {
font-family: ui-monospace, monospace;
text-transform: uppercase;
}
.code-form-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
/* ── Tables: clean rows, hover, responsive stack ── */
.table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid #eef0f3;
border-radius: 12px;
background: #fff;
}
.fam-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
font-size: 0.88rem;
}
.fam-table th, .fam-table td {
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
.fam-table thead th {
text-align: left;
}
.fam-table th {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
padding: 0.7rem 1rem;
border-bottom: 1px solid #f0f1f4;
background: #fafbfc;
white-space: nowrap;
}
.fam-table tbody td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f6f7f8;
vertical-align: middle;
color: #374151;
}
.slug {
color: #9ca3af;
font-size: 0.8rem;
margin-left: 0.25rem;
.fam-table tbody tr:last-child td { border-bottom: none; }
.fam-table tbody tr { transition: background 0.12s ease; }
.fam-table tbody tr:hover { background: #f8faff; }
.th-actions { text-align: right; }
.code-value {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.04em;
background: #eef2ff;
border: 1px dashed #a5b4fc;
border-radius: 6px;
padding: 0.15rem 0.45rem;
cursor: copy;
}
.copied {
font-size: 0.7rem;
color: #059669;
margin-left: 0.35rem;
}
tr.inactive td {
opacity: 0.55;
}
.badge {
font-size: 0.75rem;
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.badge.pending { background: #fef3c7; color: #92400e; }
.badge.none { background: #f3f4f6; color: #6b7280; }
.badge.on {
background: #d1fae5;
color: #065f46;
}
.badge.off {
background: #fee2e2;
color: #991b1b;
}
.badge.trial {
background: #fef3c7;
color: #92400e;
}
.badge.dur {
background: #e0e7ff;
color: #3730a3;
}
.badge.cont {
background: #f3f4f6;
color: #6b7280;
}
.badge.pending {
background: #fef3c7;
color: #92400e;
}
.badge.none {
background: #f3f4f6;
color: #6b7280;
}
.row-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.row-actions form {
margin: 0;
}
.link-btn {
background: none;
border: none;
padding: 0;
font-size: 0.8rem;
color: #4338ca;
cursor: pointer;
}
.link-btn:hover {
text-decoration: underline;
}
.link-btn.danger {
color: #dc2626;
}
.used-by {
font-size: 0.8rem;
color: #374151;
}
.flag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0;
}
.flag-name {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: #374151;
}
.toggle-btn {
background: #e5e7eb;
border: none;
@@ -210,28 +597,76 @@
background: #059669;
color: white;
}
.flag-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0;
}
.flag-name {
font-family: monospace;
font-size: 0.85rem;
color: #374151;
.fam-link {
color: #111827;
font-weight: 600;
text-decoration: none;
}
.fam-link:hover { color: #4338ca; }
.link {
color: #2563eb;
color: #4338ca;
font-weight: 500;
text-decoration: none;
margin-right: 0.5rem;
font-size: 0.85rem;
}
.link:hover { text-decoration: underline; }
.logout-form {
position: fixed;
top: 1rem;
right: 1rem;
.link:hover {
text-decoration: underline;
}
.slug {
font-size: 0.75rem;
color: #9ca3af;
margin-left: 0.35rem;
}
@media (max-width: 720px) {
.code-fields {
grid-template-columns: 1fr 1fr;
}
}
/* ── Responsive: stack rows into cards with labels ── */
@media (max-width: 720px) {
.table-wrap { border: none; overflow: visible; background: transparent; }
.fam-table,
.fam-table tbody,
.fam-table tr,
.fam-table td {
display: block;
width: 100%;
}
.fam-table thead { display: none; }
.fam-table tbody tr {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
margin-bottom: 0.75rem;
padding: 0.35rem 0.25rem;
}
.fam-table tbody td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px dashed #f0f1f4;
text-align: right;
}
.fam-table tbody td::before {
content: attr(data-label);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
text-align: left;
flex-shrink: 0;
}
.fam-table tbody td:empty { display: none; }
.row-actions {
justify-content: flex-end !important;
border-bottom: none !important;
}
}
</style>