chore: bootstrap lean sysadmin-chronicles repo
Import the runnable game code, content, docs, scripts, and repo guidance while leaving local agent state, dependency installs, build output, and backup copies out of the published tree.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sysadmin Chronicles</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "sysadmin-chronicles-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.55.5",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import HeaderBar from './components/HeaderBar.svelte';
|
||||
import SidebarTabs from './components/SidebarTabs.svelte';
|
||||
import TicketsPanel from './components/TicketsPanel.svelte';
|
||||
import MailPanel from './components/MailPanel.svelte';
|
||||
import DocsPanel from './components/DocsPanel.svelte';
|
||||
import VmsPanel from './components/VmsPanel.svelte';
|
||||
import ProfilePanel from './components/ProfilePanel.svelte';
|
||||
import { api } from './lib/api.js';
|
||||
import DebugPanel from './components/DebugPanel.svelte';
|
||||
|
||||
const showDebug = new URLSearchParams(window.location.search).has('debug');
|
||||
let activeTab = 'tickets';
|
||||
let state = null;
|
||||
let tickets = [];
|
||||
let selectedTicket = null;
|
||||
let mail = [];
|
||||
let selectedMail = null;
|
||||
let docs = [];
|
||||
let selectedDoc = null;
|
||||
let vms = [];
|
||||
let connection = 'offline';
|
||||
let loading = true;
|
||||
let busy = false;
|
||||
let error = '';
|
||||
let alerts = [];
|
||||
let playerPortrait = 'player-silhouette';
|
||||
$: unreadMailCount = mail.filter((item) => !item.read).length;
|
||||
|
||||
function syncSelectedRecord(collection, selected, setter) {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = collection.find((item) => item.id === selected.id);
|
||||
if (!match) {
|
||||
setter(null);
|
||||
}
|
||||
}
|
||||
|
||||
function attachmentToDocId(attachment) {
|
||||
return String(attachment ?? '')
|
||||
.replace(/^docs\//, '')
|
||||
.replace(/\.json$/, '');
|
||||
}
|
||||
|
||||
async function refreshCore() {
|
||||
const [nextState, nextTickets, nextMail, nextDocs, nextVms] = await Promise.all([
|
||||
api.getState(),
|
||||
api.getTickets(),
|
||||
api.getMail(),
|
||||
api.getDocs(),
|
||||
api.getVms()
|
||||
]);
|
||||
|
||||
state = nextState;
|
||||
tickets = nextTickets;
|
||||
mail = nextMail;
|
||||
docs = nextDocs;
|
||||
vms = nextVms;
|
||||
|
||||
if (!selectedTicket && tickets[0]) {
|
||||
await selectTicket(tickets[0].id);
|
||||
} else {
|
||||
syncSelectedRecord(tickets, selectedTicket, (value) => selectedTicket = value);
|
||||
}
|
||||
|
||||
if (!selectedDoc) {
|
||||
const firstUnlocked = docs.find((doc) => !doc.locked);
|
||||
if (firstUnlocked) {
|
||||
await selectDoc(firstUnlocked.id);
|
||||
}
|
||||
} else {
|
||||
syncSelectedRecord(docs, selectedDoc, (value) => selectedDoc = value);
|
||||
}
|
||||
|
||||
if (selectedMail) {
|
||||
const stillExists = mail.find((item) => item.id === selectedMail.id);
|
||||
if (stillExists) {
|
||||
selectedMail = await api.getMailById(selectedMail.id);
|
||||
} else {
|
||||
selectedMail = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTicket(id) {
|
||||
selectedTicket = await api.getTicket(id);
|
||||
}
|
||||
|
||||
async function selectMail(id) {
|
||||
const detail = await api.getMailById(id);
|
||||
if (!detail.read) {
|
||||
await api.markMailRead(id);
|
||||
mail = mail.map((item) => item.id === id ? { ...item, read: true } : item);
|
||||
selectedMail = { ...detail, read: true };
|
||||
return;
|
||||
}
|
||||
|
||||
selectedMail = detail;
|
||||
}
|
||||
|
||||
async function selectDoc(id) {
|
||||
selectedDoc = await api.getDoc(id);
|
||||
}
|
||||
|
||||
async function completeTicket(event) {
|
||||
try {
|
||||
busy = true;
|
||||
error = '';
|
||||
const result = await api.completeTicket(event.detail);
|
||||
if (!result?.passed) {
|
||||
const failureText = Array.isArray(result?.failures) && result.failures.length > 0
|
||||
? result.failures.join('\n')
|
||||
: result?.reason ?? 'Validation failed';
|
||||
error = `Ticket could not be completed.\n${failureText}`;
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshCore();
|
||||
if (tickets[0]) {
|
||||
await selectTicket(tickets[0].id);
|
||||
}
|
||||
} catch (nextError) {
|
||||
error = nextError.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function replyToMail(event) {
|
||||
const { id, choice } = event.detail;
|
||||
try {
|
||||
busy = true;
|
||||
error = '';
|
||||
await api.replyMail(id, choice);
|
||||
await refreshCore();
|
||||
await selectMail(id);
|
||||
} catch (nextError) {
|
||||
error = nextError.message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachment(event) {
|
||||
const docId = attachmentToDocId(event.detail);
|
||||
activeTab = 'docs';
|
||||
try {
|
||||
await selectDoc(docId);
|
||||
} catch (nextError) {
|
||||
error = nextError.message;
|
||||
}
|
||||
}
|
||||
|
||||
function connectSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
connection = 'connected';
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
connection = 'offline';
|
||||
window.setTimeout(connectSocket, 1500);
|
||||
});
|
||||
|
||||
socket.addEventListener('message', async (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'shift:tick') {
|
||||
state = {
|
||||
...(state ?? {}),
|
||||
shift: message.payload
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'incident:alert') {
|
||||
alerts = [message.payload, ...alerts].slice(0, 3);
|
||||
}
|
||||
|
||||
if (['trust:changed', 'progression:changed', 'mail:new', 'quest:completed', 'quest:activated', 'incident:alert', 'shift:ended', 'certification:awarded'].includes(message.type)) {
|
||||
await refreshCore();
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed socket events in the HUD.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function savePortrait(portrait) {
|
||||
playerPortrait = portrait;
|
||||
try {
|
||||
await api.setProfile(portrait);
|
||||
} catch {
|
||||
// portrait is already set locally; persist failure is non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await api.ensureSession();
|
||||
const profile = await api.getProfile();
|
||||
playerPortrait = profile.portrait ?? 'player-silhouette';
|
||||
await refreshCore();
|
||||
connectSocket();
|
||||
} catch (nextError) {
|
||||
error = nextError.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sysadmin Chronicles HUD</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<main class="shell centered">
|
||||
<p>Bringing the desk online…</p>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="shell">
|
||||
<HeaderBar {state} {connection} {playerPortrait} />
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if alerts.length > 0}
|
||||
<section class="alerts">
|
||||
{#each alerts as alert}
|
||||
<article class={`alert ${alert.severity ?? 'warning'}`}>
|
||||
<strong>{alert.subject}</strong>
|
||||
<p>{alert.message}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="workspace">
|
||||
<SidebarTabs
|
||||
active={activeTab}
|
||||
unreadMailCount={unreadMailCount}
|
||||
on:change={(event) => activeTab = event.detail}
|
||||
/>
|
||||
|
||||
<div class="content">
|
||||
{#if activeTab === 'tickets'}
|
||||
<TicketsPanel
|
||||
{tickets}
|
||||
{selectedTicket}
|
||||
{busy}
|
||||
on:select={(event) => selectTicket(event.detail)}
|
||||
on:complete={completeTicket}
|
||||
/>
|
||||
{:else if activeTab === 'mail'}
|
||||
<MailPanel
|
||||
{mail}
|
||||
{selectedMail}
|
||||
{busy}
|
||||
on:select={(event) => selectMail(event.detail)}
|
||||
on:reply={replyToMail}
|
||||
on:attachment={openAttachment}
|
||||
/>
|
||||
{:else if activeTab === 'docs'}
|
||||
<DocsPanel
|
||||
{docs}
|
||||
{selectedDoc}
|
||||
on:select={(event) => selectDoc(event.detail)}
|
||||
/>
|
||||
{:else if activeTab === 'vms'}
|
||||
<VmsPanel {vms} />
|
||||
{:else if activeTab === 'profile'}
|
||||
<ProfilePanel
|
||||
certifications={state?.certifications ?? []}
|
||||
shiftHistory={state?.shiftHistory ?? []}
|
||||
currentShiftStats={state?.currentShiftStats ?? null}
|
||||
{playerPortrait}
|
||||
on:portrait={(event) => savePortrait(event.detail)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{#if import.meta.env.DEV && showDebug}<DebugPanel />{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.alert.warning {
|
||||
border-color: rgba(213, 166, 75, 0.35);
|
||||
}
|
||||
|
||||
.alert.error,
|
||||
.alert.critical {
|
||||
border-color: rgba(200, 101, 101, 0.4);
|
||||
}
|
||||
|
||||
.alert strong {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.alert p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(200, 101, 101, 0.35);
|
||||
border-radius: 16px;
|
||||
background: rgba(200, 101, 101, 0.12);
|
||||
color: #ffddd8;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
:root {
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: #f2efe9;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 104, 56, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, #121315 0%, #171a1d 100%);
|
||||
color-scheme: dark;
|
||||
--panel: rgba(18, 20, 24, 0.82);
|
||||
--panel-strong: rgba(24, 27, 31, 0.94);
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--text-muted: #bdb6ab;
|
||||
--accent: #d66838;
|
||||
--accent-soft: rgba(214, 104, 56, 0.18);
|
||||
--good: #63b67a;
|
||||
--warn: #d5a64b;
|
||||
--bad: #c86565;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 844 KiB |
@@ -0,0 +1,134 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { ensureSession } from '../lib/api.js';
|
||||
|
||||
const PHASES = ['normal_work', 'unease', 'suspicion', 'investigation', 'conflict', 'resolution'];
|
||||
|
||||
let snapshot = null;
|
||||
let ending = null;
|
||||
let phaseInput = 'normal_work';
|
||||
let behaviorInput = { curiosity: 50, obedience: 50, risk: 50, suspicion: 0 };
|
||||
let status = '';
|
||||
|
||||
async function dbgGet(path) {
|
||||
const token = await ensureSession();
|
||||
const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function dbgPost(path, body) {
|
||||
const token = await ensureSession();
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [raw, e] = await Promise.all([
|
||||
dbgGet('/api/debug/state'),
|
||||
dbgGet('/api/debug/ending')
|
||||
]);
|
||||
snapshot = raw;
|
||||
ending = e;
|
||||
if (raw.behavior) behaviorInput = { ...raw.behavior };
|
||||
phaseInput = raw.narrative_phase ?? 'normal_work';
|
||||
status = '';
|
||||
} catch (err) {
|
||||
status = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideBehavior() {
|
||||
try {
|
||||
await dbgPost('/api/debug/behavior', {
|
||||
curiosity: Number(behaviorInput.curiosity),
|
||||
obedience: Number(behaviorInput.obedience),
|
||||
risk: Number(behaviorInput.risk),
|
||||
suspicion: Number(behaviorInput.suspicion)
|
||||
});
|
||||
await refresh();
|
||||
status = 'Behavior updated.';
|
||||
} catch (err) {
|
||||
status = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function forcePhase() {
|
||||
try {
|
||||
await dbgPost('/api/debug/phase', { phase: phaseInput });
|
||||
await refresh();
|
||||
status = `Phase set to ${phaseInput}.`;
|
||||
} catch (err) {
|
||||
status = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(refresh);
|
||||
</script>
|
||||
|
||||
<aside class="dbg">
|
||||
<h3>Debug</h3>
|
||||
<button on:click={refresh}>Refresh</button>
|
||||
{#if status}<p class="st">{status}</p>{/if}
|
||||
|
||||
{#if snapshot}
|
||||
<section>
|
||||
<h4>Phase</h4>
|
||||
<p>{snapshot.narrative_phase}</p>
|
||||
<select bind:value={phaseInput}>
|
||||
{#each PHASES as p}<option value={p}>{p}</option>{/each}
|
||||
</select>
|
||||
<button on:click={forcePhase}>Force</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Behavior</h4>
|
||||
{#each ['curiosity', 'obedience', 'risk', 'suspicion'] as field}
|
||||
<label>{field}<input type="number" min="0" max="100" bind:value={behaviorInput[field]} /></label>
|
||||
{/each}
|
||||
<button on:click={overrideBehavior}>Apply</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Ending</h4>
|
||||
<p>active: <strong>{ending?.active}</strong></p>
|
||||
<p>candidates: {ending?.candidates?.join(', ') || 'none'}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>Hooks</h4>
|
||||
<p>{snapshot.hidden_hooks_discovered?.join(', ') || 'none'}</p>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.dbg {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: 260px;
|
||||
background: #111;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: #ccc;
|
||||
z-index: 9999;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
h3, h4 { margin: 0.4rem 0 0.2rem; color: #fff; }
|
||||
section { margin-bottom: 0.75rem; }
|
||||
label { display: block; margin-bottom: 0.2rem; }
|
||||
input { width: 55px; margin-left: 0.4rem; }
|
||||
select { width: 100%; margin-bottom: 0.4rem; }
|
||||
button { margin-right: 0.25rem; margin-top: 0.25rem; }
|
||||
.st { color: #8f8; margin: 0.2rem 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let docs = [];
|
||||
export let selectedDoc = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<section class="panel-grid">
|
||||
<aside class="list">
|
||||
<header>
|
||||
<h2>Docs</h2>
|
||||
<span>{docs.length} indexed</span>
|
||||
</header>
|
||||
|
||||
{#each docs as doc}
|
||||
<button class:selected={doc.id === selectedDoc?.id} on:click={() => !doc.locked && dispatch('select', doc.id)} disabled={doc.locked}>
|
||||
<div>{doc.title}</div>
|
||||
<small>{doc.locked ? 'locked' : 'available'}</small>
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<article class="detail">
|
||||
{#if selectedDoc}
|
||||
<h3>{selectedDoc.title}</h3>
|
||||
<div class="body">{selectedDoc.content}</div>
|
||||
{:else}
|
||||
<p class="empty">Select an unlocked document to read it.</p>
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list,
|
||||
.detail {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list button {
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.list button.selected {
|
||||
border-color: rgba(214, 104, 56, 0.45);
|
||||
background: rgba(214, 104, 56, 0.1);
|
||||
}
|
||||
|
||||
.body,
|
||||
.empty,
|
||||
small {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script>
|
||||
import logoUrl from '../assets/logo.png';
|
||||
|
||||
export let state = null;
|
||||
export let connection = 'offline';
|
||||
export let playerPortrait = 'player-silhouette';
|
||||
|
||||
function trustLabel(score) {
|
||||
if (score >= 75) return 'Entrusted';
|
||||
if (score >= 65) return 'Reliable';
|
||||
if (score >= 55) return 'Settling In';
|
||||
return 'Probationary';
|
||||
}
|
||||
|
||||
function formatRemaining(seconds) {
|
||||
const safe = Math.max(0, Number(seconds ?? 0));
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const remainder = safe % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src={logoUrl} alt="Axiom Works">
|
||||
<div>
|
||||
<p class="eyebrow">Axiom Works Internal</p>
|
||||
<h1>Sysadmin Chronicles</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-cluster">
|
||||
<div class="trust-card">
|
||||
<span class="label">Ops Standing</span>
|
||||
<strong>{trustLabel(state?.trust ?? 50)}</strong>
|
||||
<div class="meter">
|
||||
<span style={`width:${Math.max(8, state?.trust ?? 50)}%`}></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-card">
|
||||
<span class="label">Shift #{state?.shiftNumber ?? 1}</span>
|
||||
<strong>{formatRemaining(state?.shift?.remainingSeconds ?? 0)}</strong>
|
||||
<span class:online={connection === 'connected'} class="socket">{connection}</span>
|
||||
</div>
|
||||
|
||||
<div class="meta-card">
|
||||
<span class="label">Certifications</span>
|
||||
<strong>{state?.certifications?.length ?? 0}</strong>
|
||||
<span class="socket">{state?.shiftHistory?.length ?? 0} reviews archived</span>
|
||||
</div>
|
||||
|
||||
<div class="portrait-card">
|
||||
<img
|
||||
class="portrait-avatar"
|
||||
src={`/public/portraits/${playerPortrait}.png`}
|
||||
alt="Your portrait"
|
||||
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.label,
|
||||
.socket {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: clamp(2rem, 3vw, 3rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-cluster {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(170px, 1fr)) 64px;
|
||||
gap: 0.8rem;
|
||||
min-width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.portrait-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.portrait-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.trust-card,
|
||||
.meta-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.meter {
|
||||
margin-top: 0.8rem;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #b06c37, var(--accent));
|
||||
}
|
||||
|
||||
.socket {
|
||||
display: inline-flex;
|
||||
margin-top: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.socket.online {
|
||||
color: #dfeedd;
|
||||
background: rgba(99, 182, 122, 0.16);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-cluster {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let mail = [];
|
||||
export let selectedMail = null;
|
||||
export let busy = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
$: unreadCount = mail.filter((item) => !item.read).length;
|
||||
</script>
|
||||
|
||||
<section class="panel-grid">
|
||||
<aside class="list">
|
||||
<header>
|
||||
<h2>Mail</h2>
|
||||
<div class="mail-meta">
|
||||
<span>{mail.length} threads</span>
|
||||
{#if unreadCount > 0}
|
||||
<span class="badge unread-summary">{unreadCount} unread</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if mail.length === 0}
|
||||
<p class="empty">No mail yet.</p>
|
||||
{:else}
|
||||
{#each mail as item}
|
||||
<button class:selected={item.id === selectedMail?.id} class:has-unread={!item.read} on:click={() => dispatch('select', item.id)}>
|
||||
<div class="topline">
|
||||
<strong>{item.subject}</strong>
|
||||
{#if !item.read}<span class="badge unread">unread</span>{/if}
|
||||
</div>
|
||||
<div>{item.from}</div>
|
||||
<small>{new Date(item.timestamp).toLocaleString()}</small>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<article class="detail">
|
||||
{#if selectedMail}
|
||||
<div class="mail-header">
|
||||
<div>
|
||||
<h3>{selectedMail.subject}</h3>
|
||||
<p>{selectedMail.from}</p>
|
||||
</div>
|
||||
<span class={`badge ${selectedMail.read ? 'read' : 'unread'}`}>
|
||||
{selectedMail.read ? 'Read' : 'Unread'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="body">{selectedMail.body}</div>
|
||||
|
||||
{#if selectedMail.attachments?.length}
|
||||
<section class="attachments">
|
||||
<h4>Attachments</h4>
|
||||
<div class="chips">
|
||||
{#each selectedMail.attachments as attachment}
|
||||
<button on:click={() => dispatch('attachment', attachment)}>{attachment}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if selectedMail.reply_options?.length && !selectedMail.replied}
|
||||
<section class="reply-block">
|
||||
<h4>Reply</h4>
|
||||
<div class="chips">
|
||||
{#each selectedMail.reply_options as option, index}
|
||||
<button disabled={busy} on:click={() => dispatch('reply', { id: selectedMail.id, choice: index })}>
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">Select a message to read it.</p>
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list,
|
||||
.detail {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
header,
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mail-meta,
|
||||
.mail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2, h3, h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list button,
|
||||
.chips button {
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list button.selected {
|
||||
border-color: rgba(214, 104, 56, 0.45);
|
||||
background: rgba(214, 104, 56, 0.1);
|
||||
}
|
||||
|
||||
.list button.has-unread {
|
||||
box-shadow: inset 3px 0 0 rgba(214, 104, 56, 0.75);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.unread,
|
||||
.unread-summary {
|
||||
background: rgba(214, 104, 56, 0.18);
|
||||
color: #f8e5d7;
|
||||
}
|
||||
|
||||
.read {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.body {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
color: #f4eee4;
|
||||
}
|
||||
|
||||
p,
|
||||
small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attachments,
|
||||
.reply-block {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let certifications = [];
|
||||
export let shiftHistory = [];
|
||||
export let currentShiftStats = null;
|
||||
export let playerPortrait = 'player-silhouette';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const PORTRAITS = [
|
||||
'player-silhouette',
|
||||
'player-01',
|
||||
'player-02',
|
||||
'player-03',
|
||||
'player-04',
|
||||
'player-05'
|
||||
];
|
||||
|
||||
const PORTRAIT_LABELS = {
|
||||
'player-silhouette': 'Default',
|
||||
'player-01': 'Option 1',
|
||||
'player-02': 'Option 2',
|
||||
'player-03': 'Option 3',
|
||||
'player-04': 'Option 4',
|
||||
'player-05': 'Option 5'
|
||||
};
|
||||
|
||||
function selectPortrait(id) {
|
||||
dispatch('portrait', id);
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const safe = Number(seconds);
|
||||
if (!Number.isFinite(safe) || safe < 0) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const remainder = safe % 60;
|
||||
return `${minutes}m ${String(remainder).padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
function prettyTier(tier) {
|
||||
if (tier === 'excellent') return 'Excellent';
|
||||
if (tier === 'ok') return 'Solid';
|
||||
if (tier === 'poor') return 'Needs Work';
|
||||
return 'Unknown';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="portrait-section">
|
||||
<div class="portrait-current">
|
||||
<img
|
||||
class="portrait-large"
|
||||
src={`/public/portraits/${playerPortrait}.png`}
|
||||
alt="Your portrait"
|
||||
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
|
||||
>
|
||||
<div>
|
||||
<p class="portrait-heading">Your Portrait</p>
|
||||
<p class="portrait-sub">Select a portrait to use in the HUD header.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="portrait-grid">
|
||||
{#each PORTRAITS as id}
|
||||
<button
|
||||
class={`portrait-option ${playerPortrait === id ? 'selected' : ''}`}
|
||||
onclick={() => selectPortrait(id)}
|
||||
title={PORTRAIT_LABELS[id]}
|
||||
>
|
||||
<img
|
||||
src={`/public/portraits/${id}.png`}
|
||||
alt={PORTRAIT_LABELS[id]}
|
||||
onerror={(e) => { e.currentTarget.src = '/public/portraits/player-silhouette.png'; }}
|
||||
>
|
||||
{#if playerPortrait === id}
|
||||
<span class="portrait-check">✓</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-grid">
|
||||
<article class="panel">
|
||||
<header>
|
||||
<h2>Certifications</h2>
|
||||
<span>{certifications.length} earned</span>
|
||||
</header>
|
||||
|
||||
{#if certifications.length === 0}
|
||||
<p class="empty">No internal certifications awarded yet.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each certifications as certification}
|
||||
<article class="card">
|
||||
<strong>{certification.title}</strong>
|
||||
<p>{certification.description}</p>
|
||||
<small>{new Date(certification.awarded_at).toLocaleString()}</small>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<header>
|
||||
<h2>Shift Reviews</h2>
|
||||
<span>{shiftHistory.length} archived</span>
|
||||
</header>
|
||||
|
||||
{#if currentShiftStats}
|
||||
<section class="current">
|
||||
<h3>Current Shift</h3>
|
||||
<div class="current-stats">
|
||||
<article class="stat">
|
||||
<span>Assigned</span>
|
||||
<strong>{(currentShiftStats.assigned_ticket_ids ?? []).length}</strong>
|
||||
</article>
|
||||
<article class="stat">
|
||||
<span>Resolved</span>
|
||||
<strong>{(currentShiftStats.resolved_tickets ?? []).length}</strong>
|
||||
</article>
|
||||
<article class="stat">
|
||||
<span>Flagged</span>
|
||||
<strong>{(currentShiftStats.flagged_issues ?? []).length}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if shiftHistory.length === 0}
|
||||
<p class="empty">No reviews yet. Finish a shift to see Priya's assessment.</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each [...shiftHistory].reverse() as review}
|
||||
<article class="card">
|
||||
<div class="topline">
|
||||
<strong>Shift {review.shift_number}</strong>
|
||||
<span class={`tier ${review.performance_tier ?? 'ok'}`}>{prettyTier(review.performance_tier)}</span>
|
||||
</div>
|
||||
<p>Resolved {review.tickets_resolved}/{review.tickets_assigned}</p>
|
||||
<p>Average resolution: {formatDuration(review.average_resolution_seconds)}</p>
|
||||
<p>Flagged issues: {(review.flagged_issues ?? []).length}</p>
|
||||
<small>{new Date(review.ended_at).toLocaleString()}</small>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.portrait-section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.portrait-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.portrait-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.portrait-heading {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #f4eee4;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.portrait-sub {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.portrait-grid {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portrait-option {
|
||||
position: relative;
|
||||
background: none;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
overflow: visible;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.portrait-option img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portrait-option.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.portrait-option:hover:not(.selected) {
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
.portrait-check {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.card,
|
||||
.current {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.topline,
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.current-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: #f4eee4;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
small,
|
||||
span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tier {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tier.excellent {
|
||||
background: rgba(99, 182, 122, 0.16);
|
||||
color: #dfeedd;
|
||||
}
|
||||
|
||||
.tier.poor {
|
||||
background: rgba(200, 101, 101, 0.16);
|
||||
color: #ffd9d4;
|
||||
}
|
||||
|
||||
.tier.ok {
|
||||
background: rgba(214, 104, 56, 0.14);
|
||||
color: #f5ddcf;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.current-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let conversation = [];
|
||||
export let busy = false;
|
||||
|
||||
let draft = '';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function submit(message = draft) {
|
||||
const next = String(message ?? '').trim();
|
||||
if (!next || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('send', next);
|
||||
draft = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="sage-shell">
|
||||
<div class="conversation">
|
||||
{#if conversation.length === 0}
|
||||
<article class="message sage">
|
||||
<strong>Sage</strong>
|
||||
<p>Ask for a hint, a task summary, the target VM, or relevant docs.</p>
|
||||
</article>
|
||||
{:else}
|
||||
{#each conversation as entry}
|
||||
<article class={`message ${entry.role}`}>
|
||||
<strong>{entry.role === 'user' ? 'You' : 'Sage'}</strong>
|
||||
<p>{entry.body}</p>
|
||||
{#if entry.followUps?.length}
|
||||
<div class="chips">
|
||||
{#each entry.followUps as followUp}
|
||||
<button disabled={busy} on:click={() => submit(followUp)}>{followUp}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form class="composer" on:submit|preventDefault={() => submit()}>
|
||||
<textarea bind:value={draft} rows="3" placeholder="Ask Sage for a hint or summary…"></textarea>
|
||||
<button class="primary" disabled={busy}>{busy ? 'Thinking…' : 'Send'}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.sage-shell {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-height: 70vh;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.conversation,
|
||||
.composer {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.conversation {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
align-content: start;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: min(860px, 100%);
|
||||
border-radius: 18px;
|
||||
padding: 0.9rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-self: end;
|
||||
background: rgba(214, 104, 56, 0.14);
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin: 0.4rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
color: #f1ede5;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.chips button,
|
||||
.primary {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(9, 10, 13, 0.5);
|
||||
color: inherit;
|
||||
padding: 0.9rem 1rem;
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.primary {
|
||||
justify-self: start;
|
||||
background: var(--accent);
|
||||
color: #fff7f1;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let active = 'tickets';
|
||||
export let unreadMailCount = 0;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const tabs = [
|
||||
{ id: 'tickets', label: 'Tickets' },
|
||||
{ id: 'mail', label: 'Mail' },
|
||||
{ id: 'docs', label: 'Docs' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'profile', label: 'Profile' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="sidebar">
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
class:active={tab.id === active}
|
||||
on:click={() => dispatch('change', tab.id)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{#if tab.id === 'mail' && unreadMailCount > 0}
|
||||
<span class="badge">{unreadMailCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateX(2px);
|
||||
border-color: rgba(214, 104, 56, 0.35);
|
||||
}
|
||||
|
||||
button.active {
|
||||
background: linear-gradient(135deg, rgba(214, 104, 56, 0.22), rgba(255, 255, 255, 0.04));
|
||||
border-color: rgba(214, 104, 56, 0.45);
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #fff4ed;
|
||||
background: rgba(214, 104, 56, 0.22);
|
||||
border: 1px solid rgba(214, 104, 56, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let tickets = [];
|
||||
export let selectedTicket = null;
|
||||
export let busy = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function badgeTone(priority) {
|
||||
if (priority === 'high') return 'bad';
|
||||
if (priority === 'medium') return 'warn';
|
||||
return 'good';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel-grid">
|
||||
<aside class="list">
|
||||
<header>
|
||||
<h2>Tickets</h2>
|
||||
<span>{tickets.length} active</span>
|
||||
</header>
|
||||
|
||||
{#if tickets.length === 0}
|
||||
<p class="empty">No tickets assigned.</p>
|
||||
{:else}
|
||||
{#each tickets as ticket}
|
||||
<button class:selected={ticket.id === selectedTicket?.id} on:click={() => dispatch('select', ticket.id)}>
|
||||
<div class="topline">
|
||||
<strong>{ticket.id}</strong>
|
||||
<span class={`badge ${badgeTone(ticket.priority)}`}>{ticket.priority}</span>
|
||||
</div>
|
||||
<div>{ticket.subject}</div>
|
||||
<small>{ticket.status}</small>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<article class="detail">
|
||||
{#if selectedTicket}
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<p class="eyebrow">{selectedTicket.id}</p>
|
||||
<h3>{selectedTicket.subject}</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="primary"
|
||||
disabled={busy || selectedTicket.status === 'resolved'}
|
||||
on:click={() => dispatch('complete', selectedTicket.id)}
|
||||
>
|
||||
{busy ? 'Working…' : selectedTicket.status === 'resolved' ? 'Resolved' : 'Mark Complete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{selectedTicket.status}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Priority</dt>
|
||||
<dd>{selectedTicket.current_priority ?? selectedTicket.priority}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Linked Quest</dt>
|
||||
<dd>{selectedTicket.linked_quest}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{#if selectedTicket.body}
|
||||
<div class="body">{selectedTicket.body}</div>
|
||||
{:else}
|
||||
<p class="muted">Quest-linked ticket. Use the workstation and resolve the underlying system state.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty">Select a ticket to inspect its details.</p>
|
||||
{/if}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.list,
|
||||
.detail {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
header,
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
dt,
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list button {
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.list button.selected {
|
||||
border-color: rgba(214, 104, 56, 0.45);
|
||||
background: rgba(214, 104, 56, 0.1);
|
||||
}
|
||||
|
||||
.topline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge.good { background: rgba(99, 182, 122, 0.15); }
|
||||
.badge.warn { background: rgba(213, 166, 75, 0.18); }
|
||||
.badge.bad { background: rgba(200, 101, 101, 0.18); }
|
||||
|
||||
.primary {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem 1rem;
|
||||
background: var(--accent);
|
||||
color: #fff7f1;
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.body,
|
||||
.empty,
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
export let vms = [];
|
||||
|
||||
function tone(state) {
|
||||
if (state === 'running') return 'good';
|
||||
if (state === 'shut off') return 'warn';
|
||||
if (state === 'missing') return 'bad';
|
||||
return 'neutral';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="grid">
|
||||
{#each vms as vm}
|
||||
<article class="card">
|
||||
<div class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">{vm.id}</p>
|
||||
<h3>{vm.hostname}</h3>
|
||||
</div>
|
||||
<span class={`badge ${tone(vm.state)}`}>{vm.state}</span>
|
||||
</div>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Domain</dt>
|
||||
<dd>{vm.domain}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Access</dt>
|
||||
<dd>{vm.unlocked ? 'available' : 'locked'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
dt {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.badge.good { background: rgba(99, 182, 122, 0.15); }
|
||||
.badge.warn { background: rgba(213, 166, 75, 0.18); }
|
||||
.badge.bad { background: rgba(200, 101, 101, 0.18); }
|
||||
.badge.neutral { background: rgba(255, 255, 255, 0.08); }
|
||||
|
||||
dl {
|
||||
margin: 1rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
const STORAGE_KEY = 'sc-session-token';
|
||||
|
||||
function getStoredToken() {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function storeToken(token) {
|
||||
window.localStorage.setItem(STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
async function requestSessionToken() {
|
||||
const response = await fetch('/api/session');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session: ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
storeToken(payload.token);
|
||||
return payload.token;
|
||||
}
|
||||
|
||||
export async function ensureSession() {
|
||||
return getStoredToken() ?? await requestSessionToken();
|
||||
}
|
||||
|
||||
async function authenticatedFetch(path, options = {}, retry = true) {
|
||||
const token = await ensureSession();
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
if (options.body && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401 && retry) {
|
||||
const nextToken = await requestSessionToken();
|
||||
return await authenticatedFetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
Authorization: `Bearer ${nextToken}`
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const payload = await response.json();
|
||||
detail = payload.error ?? JSON.stringify(payload);
|
||||
} catch {
|
||||
detail = await response.text();
|
||||
}
|
||||
|
||||
throw new Error(detail || `Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
ensureSession,
|
||||
getState: () => authenticatedFetch('/api/state'),
|
||||
getTickets: () => authenticatedFetch('/api/tickets'),
|
||||
getTicket: (id) => authenticatedFetch(`/api/tickets/${id}`),
|
||||
completeTicket: (id, branchId = null) =>
|
||||
authenticatedFetch(`/api/tickets/${id}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(branchId ? { branchId } : {})
|
||||
}),
|
||||
getMail: () => authenticatedFetch('/api/mail'),
|
||||
getMailById: (id) => authenticatedFetch(`/api/mail/${id}`),
|
||||
markMailRead: (id) => authenticatedFetch(`/api/mail/${id}/read`, { method: 'POST' }),
|
||||
replyMail: (id, choice) =>
|
||||
authenticatedFetch(`/api/mail/${id}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ choice })
|
||||
}),
|
||||
getDocs: () => authenticatedFetch('/api/docs'),
|
||||
getDoc: (id) => authenticatedFetch(`/api/docs/${id}`),
|
||||
getVms: () => authenticatedFetch('/api/vms'),
|
||||
askSage: (message) =>
|
||||
authenticatedFetch('/api/sage/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message })
|
||||
}),
|
||||
getProfile: () => authenticatedFetch('/api/profile'),
|
||||
setProfile: (portrait) =>
|
||||
authenticatedFetch('/api/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ portrait })
|
||||
})
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({
|
||||
compilerOptions: {
|
||||
compatibility: {
|
||||
componentApi: 4
|
||||
}
|
||||
}
|
||||
})],
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user