init
This commit is contained in:
12
static/assets/favicon.svg
Normal file
12
static/assets/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<!-- Dark background -->
|
||||
<rect width="32" height="32" rx="6" fill="#0d1117"/>
|
||||
<!-- Monitor icon (scaled from 24×24 to ~20×20, centered) -->
|
||||
<g transform="translate(6, 6) scale(0.833)" stroke="#00d4ff" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 533 B |
BIN
static/assets/fonts/jetbrains-mono.woff2
Normal file
BIN
static/assets/fonts/jetbrains-mono.woff2
Normal file
Binary file not shown.
BIN
static/assets/fonts/syne.woff2
Normal file
BIN
static/assets/fonts/syne.woff2
Normal file
Binary file not shown.
192
static/css/base.css
Normal file
192
static/css/base.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* static/css/base.css
|
||||
* Design tokens, reset, and typographic foundation.
|
||||
*
|
||||
* Aesthetic: Industrial Ops Terminal
|
||||
* Dark graphite workspace with electric cyan as the primary action colour
|
||||
* and amber for warnings/consumed state. Syne for UI chrome, JetBrains Mono
|
||||
* for instruction content. Strong structure, decisive whitespace.
|
||||
*/
|
||||
|
||||
/* ── Fonts ─────────────────────────────────────────────────────────────── */
|
||||
@font-face {
|
||||
font-family: 'Syne';
|
||||
src: url('/static/assets/fonts/syne.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/static/assets/fonts/jetbrains-mono.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ── Design tokens ──────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg-void: #080b0d;
|
||||
--bg-base: #0d1117;
|
||||
--bg-raised: #131920;
|
||||
--bg-overlay: #1a2230;
|
||||
--bg-hover: #1f2a3a;
|
||||
--bg-active: #243040;
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: #1e2c3a;
|
||||
--border-muted: #253345;
|
||||
--border-strong: #2e4058;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e8edf2;
|
||||
--text-secondary:#8da0b3;
|
||||
--text-muted: #4a6175;
|
||||
--text-disabled: #2e4a5e;
|
||||
|
||||
/* Brand / accent */
|
||||
--cyan: #00d4ff;
|
||||
--cyan-dim: #007fa8;
|
||||
--cyan-glow: rgba(0, 212, 255, 0.15);
|
||||
|
||||
/* Status */
|
||||
--green: #00e676;
|
||||
--green-dim: #00703a;
|
||||
--green-glow: rgba(0, 230, 118, 0.15);
|
||||
|
||||
--amber: #ffbe0b;
|
||||
--amber-dim: #8a6500;
|
||||
--amber-glow: rgba(255, 190, 11, 0.12);
|
||||
|
||||
--red: #ff4f4f;
|
||||
--red-dim: #8a0000;
|
||||
--red-glow: rgba(255, 79, 79, 0.15);
|
||||
|
||||
/* Typography */
|
||||
--font-ui: 'Syne', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
|
||||
--text-xs: 0.6875rem; /* 11px */
|
||||
--text-sm: 0.75rem; /* 12px */
|
||||
--text-base: 0.875rem; /* 14px */
|
||||
--text-md: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.375rem; /* 22px */
|
||||
--text-2xl: 1.75rem; /* 28px */
|
||||
--text-3xl: 2.25rem; /* 36px */
|
||||
|
||||
/* Spacing */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.5);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.6);
|
||||
--shadow-cyan: 0 0 20px var(--cyan-glow), 0 0 40px rgba(0,212,255,0.06);
|
||||
|
||||
/* Animation */
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 240ms;
|
||||
--duration-slow: 400ms;
|
||||
}
|
||||
|
||||
/* ── Light theme overrides ───────────────────────────────────────────────── */
|
||||
/* Applied when <html data-theme="light"> is set by theme.js */
|
||||
[data-theme="light"] {
|
||||
--bg-void: #e8e8e3;
|
||||
--bg-base: #f2f2ed;
|
||||
--bg-raised: #ffffff;
|
||||
--bg-overlay: #ebebe6;
|
||||
--bg-hover: #e2e2dc;
|
||||
--bg-active: #d8d8d2;
|
||||
|
||||
--border-subtle: #d0d0ca;
|
||||
--border-muted: #c0c0b8;
|
||||
--border-strong: #a8a8a0;
|
||||
|
||||
--text-primary: #1a1f2e;
|
||||
--text-secondary:#4a5568;
|
||||
--text-muted: #718096;
|
||||
--text-disabled: #a0aec0;
|
||||
|
||||
/* Slightly desaturated accents so they work on a light surface */
|
||||
--cyan: #007fa8;
|
||||
--cyan-dim: #005c7a;
|
||||
--cyan-glow: rgba(0, 127, 168, 0.14);
|
||||
|
||||
--green: #166534;
|
||||
--green-dim: #14532d;
|
||||
--green-glow: rgba(22, 101, 52, 0.12);
|
||||
|
||||
--amber: #92400e;
|
||||
--amber-dim: #78350f;
|
||||
--amber-glow: rgba(146, 64, 14, 0.1);
|
||||
|
||||
--red: #b91c1c;
|
||||
--red-dim: #7f1d1d;
|
||||
--red-glow: rgba(185, 28, 28, 0.1);
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
|
||||
--shadow-cyan: 0 0 20px rgba(0,127,168,0.1);
|
||||
}
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-base);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--cyan);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Typography helpers ──────────────────────────────────────────────────── */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.label { font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.subtle { color: var(--text-secondary); }
|
||||
|
||||
482
static/css/components.css
Normal file
482
static/css/components.css
Normal file
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* static/css/components.css
|
||||
* Reusable UI components: buttons, inputs, badges, status indicators,
|
||||
* instruction cards, and animations.
|
||||
*/
|
||||
|
||||
/* ── Status indicator (LED dot) ─────────────────────────────────────────── */
|
||||
|
||||
.led {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.led__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led--green .led__dot { background: var(--green); box-shadow: 0 0 6px var(--green), 0 0 12px var(--green-glow); }
|
||||
.led--amber .led__dot { background: var(--amber); box-shadow: 0 0 6px var(--amber), 0 0 12px var(--amber-glow); }
|
||||
.led--red .led__dot { background: var(--red); box-shadow: 0 0 6px var(--red), 0 0 12px var(--red-glow); }
|
||||
.led--muted .led__dot { background: var(--text-muted); box-shadow: none; }
|
||||
.led--cyan .led__dot { background: var(--cyan); box-shadow: 0 0 6px var(--cyan), 0 0 12px var(--cyan-glow); }
|
||||
|
||||
.led--green .led__label { color: var(--green); }
|
||||
.led--amber .led__label { color: var(--amber); }
|
||||
.led--red .led__label { color: var(--red); }
|
||||
.led--muted .led__label { color: var(--text-muted); }
|
||||
.led--cyan .led__label { color: var(--cyan); }
|
||||
|
||||
/* Pulse for "connected" / "active" */
|
||||
.led--pulse .led__dot {
|
||||
animation: pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* ── Queue count badge ───────────────────────────────────────────────────── */
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: 10px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge--cyan { background: var(--cyan-glow); color: var(--cyan); border: 1px solid rgba(0,212,255,0.3); }
|
||||
.badge--amber { background: var(--amber-glow); color: var(--amber); border: 1px solid rgba(255,190,11,0.3); }
|
||||
.badge--muted { background: var(--bg-overlay); color: var(--text-muted); border: 1px solid var(--border-subtle); }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background var(--duration-fast) var(--ease-in-out),
|
||||
border-color var(--duration-fast) var(--ease-in-out),
|
||||
box-shadow var(--duration-fast) var(--ease-in-out),
|
||||
transform var(--duration-fast) var(--ease-in-out);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Primary – cyan */
|
||||
.btn--primary {
|
||||
background: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
color: #000;
|
||||
}
|
||||
.btn--primary:hover {
|
||||
background: #1de9ff;
|
||||
box-shadow: 0 0 16px var(--cyan-glow);
|
||||
}
|
||||
|
||||
/* Ghost */
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-muted);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn--ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Danger */
|
||||
.btn--danger {
|
||||
background: transparent;
|
||||
border-color: var(--red-dim);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn--danger:hover {
|
||||
background: var(--red-glow);
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/* Icon-only */
|
||||
.btn--icon {
|
||||
padding: var(--space-2);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Small */
|
||||
.btn--sm {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* ── Form elements ──────────────────────────────────────────────────────── */
|
||||
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-muted);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-in-out),
|
||||
box-shadow var(--duration-fast) var(--ease-in-out);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input::placeholder, .textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input:focus, .textarea:focus {
|
||||
border-color: var(--cyan-dim);
|
||||
box-shadow: 0 0 0 3px rgba(0,212,255,0.08);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 88px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-row .textarea { flex: 1; }
|
||||
|
||||
/* ── Instruction card ─────────────────────────────────────────────────────── */
|
||||
|
||||
.instruction-card {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
align-items: start;
|
||||
transition: background var(--duration-fast) var(--ease-in-out);
|
||||
animation: card-in var(--duration-slow) var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.instruction-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instruction-card:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instruction-card__meta {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.instruction-card__pos {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--cyan);
|
||||
background: var(--cyan-glow);
|
||||
border: 1px solid rgba(0,212,255,0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.instruction-card__content {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.instruction-card__time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instruction-card__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Edit mode */
|
||||
.instruction-card__edit-area {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.instruction-card__edit-area .textarea {
|
||||
flex: 1;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
/* Consumed card */
|
||||
.instruction-card--consumed {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.instruction-card--consumed .instruction-card__content {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--amber-dim);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.instruction-card--consumed .instruction-card__pos {
|
||||
background: var(--amber-glow);
|
||||
border-color: rgba(255,190,11,0.2);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.instruction-card--consumed:hover {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.instruction-card__consumed-by {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--amber);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
padding: var(--space-10) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ── Status panel items ──────────────────────────────────────────────────── */
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.stat-row:last-child { border-bottom: none; }
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-value--cyan { color: var(--cyan); }
|
||||
.stat-value--amber { color: var(--amber); }
|
||||
|
||||
/* ── Config form ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.config-field:last-child { margin-bottom: 0; }
|
||||
|
||||
.config-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.input--sm {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ── Toast notification ──────────────────────────────────────────────────── */
|
||||
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--space-6);
|
||||
right: var(--space-6);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: toast-in var(--duration-normal) var(--ease-out-expo) both;
|
||||
pointer-events: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.toast--success { border-color: var(--green-dim); color: var(--green); }
|
||||
.toast--error { border-color: var(--red-dim); color: var(--red); }
|
||||
.toast--info { border-color: var(--cyan-dim); color: var(--cyan); }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes toast-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(4px) scale(0.97); }
|
||||
}
|
||||
|
||||
/* ── Theme toggle (header) ────────────────────────────────────────────────── */
|
||||
|
||||
#theme-toggle {
|
||||
color: var(--text-secondary);
|
||||
border-color: transparent;
|
||||
transition: color var(--duration-fast) var(--ease-in-out),
|
||||
background var(--duration-fast) var(--ease-in-out);
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* ── SVG icons ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
stroke-width: 2;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.icon--sm { width: 12px; height: 12px; }
|
||||
|
||||
/* ── Page load animation ─────────────────────────────────────────────────── */
|
||||
|
||||
.fade-in {
|
||||
animation: fade-in var(--duration-slow) var(--ease-out-expo) both;
|
||||
}
|
||||
|
||||
.fade-in--delay-1 { animation-delay: 60ms; }
|
||||
.fade-in--delay-2 { animation-delay: 120ms; }
|
||||
.fade-in--delay-3 { animation-delay: 200ms; }
|
||||
.fade-in--delay-4 { animation-delay: 300ms; }
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Loading spinner ─────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border-muted);
|
||||
border-top-color: var(--cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
131
static/css/layout.css
Normal file
131
static/css/layout.css
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* static/css/layout.css
|
||||
* Page-level layout: header, main grid, panels.
|
||||
*/
|
||||
|
||||
/* ── Page structure ─────────────────────────────────────────────────────── */
|
||||
|
||||
.page-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--bg-void);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 var(--space-6);
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.header-brand-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-brand-name span {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.header-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* ── Main layout ────────────────────────────────────────────────────────── */
|
||||
|
||||
.page-main {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6) var(--space-12);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-rows: auto;
|
||||
gap: var(--space-6);
|
||||
grid-template-areas:
|
||||
"composer sidebar"
|
||||
"queue sidebar"
|
||||
"history sidebar";
|
||||
}
|
||||
|
||||
.area-composer { grid-area: composer; }
|
||||
.area-queue { grid-area: queue; }
|
||||
.area-history { grid-area: history; }
|
||||
.area-sidebar { grid-area: sidebar; display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
|
||||
/* ── Panel container ─────────────────────────────────────────────────────── */
|
||||
|
||||
.panel {
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.panel-body--flush {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-main {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar"
|
||||
"composer"
|
||||
"queue"
|
||||
"history";
|
||||
}
|
||||
|
||||
.area-sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.area-sidebar > .panel {
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page-header { padding: 0 var(--space-4); }
|
||||
.page-main { padding: var(--space-5) var(--space-4) var(--space-10); gap: var(--space-4); }
|
||||
}
|
||||
|
||||
186
static/index.html
Normal file
186
static/index.html
Normal file
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>local-mcp — Instruction Queue</title>
|
||||
<meta name="description" content="Localhost MCP instruction queue management" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/assets/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/static/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/components.css" />
|
||||
<!-- Apply theme before paint to avoid flash of wrong theme -->
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('local-mcp-theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = t;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<header class="page-header">
|
||||
<div class="header-brand">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--cyan)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span class="header-brand-name">local<span>-mcp</span></span>
|
||||
</div>
|
||||
|
||||
<div class="header-indicators">
|
||||
<div id="led-server" class="led led--muted">
|
||||
<span class="led__dot"></span>
|
||||
<span class="led__label">Connecting…</span>
|
||||
</div>
|
||||
<div id="led-agent" class="led led--muted">
|
||||
<span class="led__dot"></span>
|
||||
<span class="led__label">Agent Idle</span>
|
||||
</div>
|
||||
<button id="theme-toggle" class="btn btn--ghost btn--icon" title="Toggle theme" aria-label="Toggle theme">
|
||||
<!-- icon injected by theme.js -->
|
||||
<svg class="icon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Main ────────────────────────────────────────────────────────────── -->
|
||||
<main class="page-main">
|
||||
|
||||
<!-- Composer -->
|
||||
<section class="area-composer fade-in">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Instruction
|
||||
</span>
|
||||
<span class="subtle" style="font-size:var(--text-xs)">Enter to send · Shift+Enter for newline</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="composer-form">
|
||||
<div class="form-row">
|
||||
<textarea
|
||||
id="composer-input"
|
||||
class="textarea"
|
||||
placeholder="Type an instruction… (Enter to send, Shift+Enter for newline)"
|
||||
rows="3"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<button id="composer-submit" type="submit" class="btn btn--primary" style="height:fit-content;align-self:flex-end">
|
||||
<svg class="icon" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pending queue -->
|
||||
<section class="area-queue fade-in fade-in--delay-1">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Pending Queue
|
||||
</span>
|
||||
<span id="pending-badge" class="badge badge--muted">0</span>
|
||||
</div>
|
||||
<div class="panel-body panel-body--flush">
|
||||
<div id="pending-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Consumed history -->
|
||||
<section class="area-history fade-in fade-in--delay-2">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
Consumed
|
||||
</span>
|
||||
<span id="consumed-badge" class="badge badge--muted">0</span>
|
||||
</div>
|
||||
<div class="panel-body panel-body--flush">
|
||||
<div id="consumed-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="area-sidebar">
|
||||
|
||||
<!-- Server / Agent Status -->
|
||||
<div class="panel fade-in fade-in--delay-1">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
Status
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body" id="status-panel-body">
|
||||
<div class="stat-row"><span class="stat-label">Loading…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Config -->
|
||||
<div class="panel fade-in fade-in--delay-2">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="config-form">
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-wait">Min Wait (sec)</label>
|
||||
<input id="cfg-wait" class="input input--sm" type="number" min="0" max="300" value="10" />
|
||||
<span class="config-hint">Server enforces this as the minimum wait before returning an empty response. Agent may request longer but never shorter.</span>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-empty">Empty Response</label>
|
||||
<input id="cfg-empty" class="input input--sm" type="text" placeholder="Leave blank for no response" />
|
||||
<span class="config-hint">Default text returned when queue is empty</span>
|
||||
</div>
|
||||
<div class="config-field">
|
||||
<label class="config-label" for="cfg-stale">Agent Stale After (sec)</label>
|
||||
<input id="cfg-stale" class="input input--sm" type="number" min="5" max="600" value="30" />
|
||||
<span class="config-hint">Inactivity before agent shown as idle</span>
|
||||
</div>
|
||||
<button id="cfg-save" type="submit" class="btn btn--ghost btn--sm" style="width:100%">
|
||||
<svg class="icon icon--sm" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
43
static/js/api.js
Normal file
43
static/js/api.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* static/js/api.js
|
||||
* Thin fetch wrappers for all HTTP API endpoints.
|
||||
*/
|
||||
|
||||
const BASE = '';
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(BASE + path, opts);
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const msg = data?.detail || `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Health
|
||||
health: () => request('GET', '/healthz'),
|
||||
|
||||
// Status
|
||||
status: () => request('GET', '/api/status'),
|
||||
|
||||
// Instructions
|
||||
listInstructions: (status='all') => request('GET', `/api/instructions?status=${status}`),
|
||||
createInstruction: (content) => request('POST', '/api/instructions', { content }),
|
||||
updateInstruction: (id, content) => request('PATCH', `/api/instructions/${id}`, { content }),
|
||||
deleteInstruction: (id) => request('DELETE', `/api/instructions/${id}`),
|
||||
|
||||
// Config
|
||||
getConfig: () => request('GET', '/api/config'),
|
||||
updateConfig: (patch) => request('PATCH', '/api/config', patch),
|
||||
};
|
||||
|
||||
108
static/js/app.js
Normal file
108
static/js/app.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* static/js/app.js
|
||||
* Application bootstrap – initialises all modules, kicks off data fetching,
|
||||
* and exports shared utilities like `toast`.
|
||||
*/
|
||||
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { connectSSE } from './events.js';
|
||||
import { initInstructions, initComposer, refreshTimestamps } from './instructions.js';
|
||||
import { initStatus, initConfig, refreshStatusTimestamps } from './status.js';
|
||||
import { initTheme } from './theme.js';
|
||||
|
||||
// ── Toast notification ────────────────────────────────────────────────────
|
||||
|
||||
const _toastContainer = document.getElementById('toast-container');
|
||||
|
||||
export function toast(message, type = 'info') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast toast--${type}`;
|
||||
el.textContent = message;
|
||||
_toastContainer.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.animation = 'toast-out 240ms cubic-bezier(0.4,0,1,1) forwards';
|
||||
el.addEventListener('animationend', () => el.remove());
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ── Document title badge ──────────────────────────────────────────────────
|
||||
|
||||
function updateTitle(instructions) {
|
||||
const pending = (instructions || []).filter(i => i.status === 'pending').length;
|
||||
document.title = pending > 0 ? `(${pending}) local-mcp` : 'local-mcp';
|
||||
}
|
||||
|
||||
// ── Reconnecting indicator ────────────────────────────────────────────────
|
||||
|
||||
function initReconnectingIndicator() {
|
||||
const serverLed = document.getElementById('led-server');
|
||||
state.subscribe('sseReconnecting', (reconnecting) => {
|
||||
if (!serverLed) return;
|
||||
if (reconnecting) {
|
||||
serverLed.className = 'led led--amber led--pulse';
|
||||
serverLed.querySelector('.led__label').textContent = 'Reconnecting…';
|
||||
}
|
||||
// The full status update (onopen) will re-set the correct class
|
||||
});
|
||||
}
|
||||
|
||||
// ── Startup data load ─────────────────────────────────────────────────────
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const [instructions, status, config] = await Promise.all([
|
||||
api.listInstructions('all'),
|
||||
api.status(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
state.set('instructions', instructions.items);
|
||||
state.set('status', status);
|
||||
state.set('config', config);
|
||||
state.set('serverOnline', true);
|
||||
state.set('sseReconnecting', false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial data:', err);
|
||||
state.set('serverOnline', false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic refresh (fallback for SSE gaps) ──────────────────────────────
|
||||
|
||||
function startPolling() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const s = await api.status();
|
||||
state.set('status', s);
|
||||
state.set('serverOnline', true);
|
||||
} catch {
|
||||
state.set('serverOnline', false);
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
// Refresh relative timestamps every 20 seconds
|
||||
setInterval(() => { refreshTimestamps(); refreshStatusTimestamps(); }, 20_000);
|
||||
}
|
||||
|
||||
// ── Subscribe to state changes ────────────────────────────────────────────
|
||||
|
||||
function initGlobalSubscriptions() {
|
||||
state.subscribe('instructions', updateTitle);
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initTheme(); // must run first – sets button icon
|
||||
initReconnectingIndicator();
|
||||
initStatus();
|
||||
initConfig();
|
||||
initInstructions();
|
||||
initComposer();
|
||||
initGlobalSubscriptions();
|
||||
|
||||
await loadInitialData();
|
||||
connectSSE();
|
||||
startPolling();
|
||||
});
|
||||
|
||||
119
static/js/events.js
Normal file
119
static/js/events.js
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* static/js/events.js
|
||||
* Server-Sent Events client – connects to /api/events and dispatches
|
||||
* updates into the central state store.
|
||||
* Uses full item payloads embedded in events to avoid extra re-fetch round-trips.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
let _es = null;
|
||||
let _reconnectTimer = null;
|
||||
let _reconnecting = false;
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
|
||||
export function connectSSE() {
|
||||
if (_es) return;
|
||||
_connect();
|
||||
}
|
||||
|
||||
function _connect() {
|
||||
_es = new EventSource('/api/events');
|
||||
|
||||
_es.onopen = () => {
|
||||
console.debug('[SSE] connected');
|
||||
if (_reconnecting) {
|
||||
_reconnecting = false;
|
||||
state.set('sseReconnecting', false);
|
||||
// Full refresh after reconnect to catch anything we missed
|
||||
_fullRefresh();
|
||||
}
|
||||
state.set('serverOnline', true);
|
||||
};
|
||||
|
||||
_es.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
_handleEvent(event);
|
||||
} catch (err) {
|
||||
console.warn('[SSE] parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
_es.onerror = () => {
|
||||
console.warn('[SSE] connection lost – reconnecting in', RECONNECT_DELAY_MS, 'ms');
|
||||
state.set('serverOnline', false);
|
||||
_reconnecting = true;
|
||||
state.set('sseReconnecting', true);
|
||||
_es.close();
|
||||
_es = null;
|
||||
clearTimeout(_reconnectTimer);
|
||||
_reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS);
|
||||
};
|
||||
}
|
||||
|
||||
async function _fullRefresh() {
|
||||
try {
|
||||
const [instructions, status, config] = await Promise.all([
|
||||
api.listInstructions('all'),
|
||||
api.status(),
|
||||
api.getConfig(),
|
||||
]);
|
||||
state.set('instructions', instructions.items);
|
||||
state.set('status', status);
|
||||
state.set('config', config);
|
||||
} catch (e) {
|
||||
console.error('[SSE] full refresh failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function _applyInstructionPatch(item) {
|
||||
const current = state.get('instructions') || [];
|
||||
const idx = current.findIndex(i => i.id === item.id);
|
||||
if (idx === -1) {
|
||||
// New item – append and re-sort by position
|
||||
const next = [...current, item].sort((a, b) => a.position - b.position);
|
||||
state.set('instructions', next);
|
||||
} else {
|
||||
// Replace in-place, preserving order
|
||||
const next = [...current];
|
||||
next[idx] = item;
|
||||
state.set('instructions', next.sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
|
||||
function _handleEvent(event) {
|
||||
switch (event.type) {
|
||||
case 'instruction.created':
|
||||
case 'instruction.updated':
|
||||
case 'instruction.consumed': {
|
||||
if (event.data?.item) {
|
||||
_applyInstructionPatch(event.data.item);
|
||||
} else {
|
||||
// Fallback: full refresh
|
||||
api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'instruction.deleted': {
|
||||
const id = event.data?.id;
|
||||
if (id) {
|
||||
const next = (state.get('instructions') || []).filter(i => i.id !== id);
|
||||
state.set('instructions', next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'status.changed': {
|
||||
api.status().then(s => state.set('status', s)).catch(console.error);
|
||||
break;
|
||||
}
|
||||
case 'config.updated': {
|
||||
api.getConfig().then(c => state.set('config', c)).catch(console.error);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.debug('[SSE] unknown event type', event.type);
|
||||
}
|
||||
}
|
||||
|
||||
258
static/js/instructions.js
Normal file
258
static/js/instructions.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* static/js/instructions.js
|
||||
* Renders the pending and consumed instruction panels.
|
||||
* Handles add, edit, delete interactions.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
|
||||
// ── SVG icon helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function iconEdit() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`;
|
||||
}
|
||||
|
||||
function iconDelete() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`;
|
||||
}
|
||||
|
||||
function iconCheck() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
}
|
||||
|
||||
function iconX() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
}
|
||||
|
||||
// ── Time formatters ───────────────────────────────────────────────────────
|
||||
|
||||
function fmtRelativeTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function fmtAbsTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
/** Refresh all relative-time spans in the lists (called by app.js on a timer). */
|
||||
export function refreshTimestamps() {
|
||||
document.querySelectorAll('[data-ts]').forEach(el => {
|
||||
el.textContent = fmtRelativeTime(el.dataset.ts);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Card renderer ─────────────────────────────────────────────────────────
|
||||
|
||||
function renderPendingCard(item, index) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instruction-card';
|
||||
card.dataset.id = item.id;
|
||||
card.style.animationDelay = `${index * 30}ms`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instruction-card__meta">
|
||||
<span class="instruction-card__pos">#${item.position}</span>
|
||||
<span class="instruction-card__time" data-ts="${item.created_at}">${fmtRelativeTime(item.created_at)}</span>
|
||||
</div>
|
||||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||||
<div class="instruction-card__actions">
|
||||
<button class="btn btn--ghost btn--icon btn--edit" title="Edit instruction" aria-label="Edit">${iconEdit()}</button>
|
||||
<button class="btn btn--danger btn--icon btn--delete" title="Delete instruction" aria-label="Delete">${iconDelete()}</button>
|
||||
</div>
|
||||
<div class="instruction-card__edit-area" style="display:none; grid-column:1/-1;">
|
||||
<textarea class="textarea edit-textarea" rows="3">${escapeHtml(item.content)}</textarea>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||
<button class="btn btn--primary btn--sm btn--save" title="Save">${iconCheck()}</button>
|
||||
<button class="btn btn--ghost btn--sm btn--cancel" title="Cancel">${iconX()}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const editBtn = card.querySelector('.btn--edit');
|
||||
const deleteBtn = card.querySelector('.btn--delete');
|
||||
const cancelBtn = card.querySelector('.btn--cancel');
|
||||
const saveBtn = card.querySelector('.btn--save');
|
||||
const editArea = card.querySelector('.instruction-card__edit-area');
|
||||
const content = card.querySelector('.instruction-card__content');
|
||||
const actions = card.querySelector('.instruction-card__actions');
|
||||
const editTA = card.querySelector('.edit-textarea');
|
||||
|
||||
function showEdit() {
|
||||
editTA.value = item.content;
|
||||
editArea.style.display = 'flex';
|
||||
content.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
editTA.focus();
|
||||
editTA.setSelectionRange(editTA.value.length, editTA.value.length);
|
||||
}
|
||||
|
||||
function hideEdit() {
|
||||
editArea.style.display = 'none';
|
||||
content.style.display = '';
|
||||
actions.style.display = '';
|
||||
}
|
||||
|
||||
editBtn.addEventListener('click', showEdit);
|
||||
cancelBtn.addEventListener('click', hideEdit);
|
||||
|
||||
editTA.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); hideEdit(); }
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
const newContent = editTA.value.trim();
|
||||
if (!newContent) { toast('Content cannot be empty', 'error'); return; }
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
await api.updateInstruction(item.id, newContent);
|
||||
toast('Instruction updated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this instruction?')) return;
|
||||
deleteBtn.disabled = true;
|
||||
try {
|
||||
await api.deleteInstruction(item.id);
|
||||
toast('Instruction deleted', 'info');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
deleteBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderConsumedCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'instruction-card instruction-card--consumed';
|
||||
card.dataset.id = item.id;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="instruction-card__meta">
|
||||
<span class="instruction-card__pos">#${item.position}</span>
|
||||
<span class="instruction-card__time">${fmtAbsTime(item.consumed_at)}</span>
|
||||
${item.consumed_by_agent_id
|
||||
? `<span class="instruction-card__consumed-by">→ ${escapeHtml(item.consumed_by_agent_id)}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="instruction-card__content">${escapeHtml(item.content)}</div>
|
||||
<div></div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── List renderers ────────────────────────────────────────────────────────
|
||||
|
||||
export function initInstructions() {
|
||||
const pendingList = document.getElementById('pending-list');
|
||||
const pendingBadge = document.getElementById('pending-badge');
|
||||
const consumedList = document.getElementById('consumed-list');
|
||||
const consumedBadge = document.getElementById('consumed-badge');
|
||||
|
||||
function render(instructions) {
|
||||
if (!instructions) return;
|
||||
const pending = instructions.filter(i => i.status === 'pending');
|
||||
const consumed = instructions.filter(i => i.status === 'consumed').reverse();
|
||||
|
||||
// Pending
|
||||
pendingList.innerHTML = '';
|
||||
if (pending.length === 0) {
|
||||
pendingList.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">◈</div>
|
||||
Queue is empty – add an instruction above
|
||||
</div>`;
|
||||
} else {
|
||||
pending.forEach((item, i) => pendingList.appendChild(renderPendingCard(item, i)));
|
||||
}
|
||||
pendingBadge.textContent = pending.length;
|
||||
pendingBadge.className = `badge ${pending.length > 0 ? 'badge--cyan' : 'badge--muted'}`;
|
||||
|
||||
// Consumed
|
||||
consumedList.innerHTML = '';
|
||||
if (consumed.length === 0) {
|
||||
consumedList.innerHTML = `<div class="empty-state"><div class="empty-state__icon">◈</div>No consumed instructions yet</div>`;
|
||||
} else {
|
||||
consumed.forEach(item => consumedList.appendChild(renderConsumedCard(item)));
|
||||
}
|
||||
consumedBadge.textContent = consumed.length;
|
||||
consumedBadge.className = `badge ${consumed.length > 0 ? 'badge--amber' : 'badge--muted'}`;
|
||||
}
|
||||
|
||||
state.subscribe('instructions', render);
|
||||
}
|
||||
|
||||
// ── Composer ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function initComposer() {
|
||||
const form = document.getElementById('composer-form');
|
||||
const textarea = document.getElementById('composer-input');
|
||||
const btn = document.getElementById('composer-submit');
|
||||
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Plain Enter → submit (like a chat box)
|
||||
e.preventDefault();
|
||||
form.requestSubmit();
|
||||
}
|
||||
// Shift+Enter → default browser behaviour (newline)
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const content = textarea.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
try {
|
||||
await api.createInstruction(content);
|
||||
textarea.value = '';
|
||||
textarea.style.height = '';
|
||||
toast('Instruction queued', 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-resize textarea
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
34
static/js/state.js
Normal file
34
static/js/state.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* static/js/state.js
|
||||
* Centralised reactive state store.
|
||||
* Components subscribe to state slices and are notified on change.
|
||||
*/
|
||||
|
||||
const _state = {
|
||||
instructions: [], // InstructionItem[]
|
||||
status: null, // StatusResponse | null
|
||||
config: null, // ConfigResponse | null
|
||||
serverOnline: false,
|
||||
};
|
||||
|
||||
const _listeners = {}; // key -> Set<fn>
|
||||
|
||||
export const state = {
|
||||
get(key) {
|
||||
return _state[key];
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
_state[key] = value;
|
||||
(_listeners[key] || new Set()).forEach(fn => fn(value));
|
||||
},
|
||||
|
||||
subscribe(key, fn) {
|
||||
if (!_listeners[key]) _listeners[key] = new Set();
|
||||
_listeners[key].add(fn);
|
||||
// Immediately call with current value
|
||||
fn(_state[key]);
|
||||
return () => _listeners[key].delete(fn); // unsubscribe
|
||||
},
|
||||
};
|
||||
|
||||
157
static/js/status.js
Normal file
157
static/js/status.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* static/js/status.js
|
||||
* Renders the server status and agent activity panels,
|
||||
* and the config settings panel.
|
||||
*/
|
||||
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { toast } from './app.js';
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function fmtTime(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtRelative(isoStr) {
|
||||
if (!isoStr) return '–';
|
||||
const d = new Date(isoStr);
|
||||
const diff = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
// ── Server online indicator (header) ─────────────────────────────────────
|
||||
|
||||
function updateHeaderLeds(serverOnline, status) {
|
||||
const serverLed = document.getElementById('led-server');
|
||||
const agentLed = document.getElementById('led-agent');
|
||||
|
||||
if (!serverLed || !agentLed) return;
|
||||
|
||||
// Don't overwrite reconnecting state – events.js sets that
|
||||
if (serverOnline && !state.get('sseReconnecting')) {
|
||||
serverLed.className = 'led led--green led--pulse';
|
||||
serverLed.querySelector('.led__label').textContent = 'Server Online';
|
||||
} else if (!serverOnline) {
|
||||
serverLed.className = 'led led--red';
|
||||
serverLed.querySelector('.led__label').textContent = 'Server Offline';
|
||||
}
|
||||
|
||||
if (status?.agent?.connected) {
|
||||
agentLed.className = 'led led--cyan led--pulse';
|
||||
agentLed.querySelector('.led__label').textContent = 'Agent Connected';
|
||||
} else {
|
||||
agentLed.className = 'led led--muted';
|
||||
agentLed.querySelector('.led__label').textContent = 'Agent Idle';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status sidebar panel ──────────────────────────────────────────────────
|
||||
|
||||
function renderStatusPanel(status) {
|
||||
const el = document.getElementById('status-panel-body');
|
||||
if (!el || !status) return;
|
||||
|
||||
const agent = status.agent;
|
||||
const queue = status.queue;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Server Up</span>
|
||||
<span class="stat-value">${fmtTime(status.server?.started_at)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value stat-value--cyan">${queue.pending_count}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Consumed</span>
|
||||
<span class="stat-value stat-value--amber">${queue.consumed_count}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Agent</span>
|
||||
<span class="stat-value">${agent.agent_id ? escapeHtml(agent.agent_id) : '–'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Last Seen</span>
|
||||
<span class="stat-value" data-ts-rel="${agent.last_seen_at || ''}">${fmtRelative(agent.last_seen_at)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Last Fetch</span>
|
||||
<span class="stat-value" data-ts-rel="${agent.last_fetch_at || ''}">${fmtRelative(agent.last_fetch_at)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Called by app.js on a timer to keep relative times fresh. */
|
||||
export function refreshStatusTimestamps() {
|
||||
document.querySelectorAll('[data-ts-rel]').forEach(el => {
|
||||
const iso = el.dataset.tsRel;
|
||||
if (iso) el.textContent = fmtRelative(iso);
|
||||
});
|
||||
}
|
||||
|
||||
export function initStatus() {
|
||||
state.subscribe('serverOnline', (online) => {
|
||||
updateHeaderLeds(online, state.get('status'));
|
||||
});
|
||||
|
||||
state.subscribe('status', (status) => {
|
||||
updateHeaderLeds(state.get('serverOnline'), status);
|
||||
renderStatusPanel(status);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Config panel ──────────────────────────────────────────────────────────
|
||||
|
||||
export function initConfig() {
|
||||
const form = document.getElementById('config-form');
|
||||
const waitInput = document.getElementById('cfg-wait');
|
||||
const emptyInput = document.getElementById('cfg-empty');
|
||||
const staleInput = document.getElementById('cfg-stale');
|
||||
const saveBtn = document.getElementById('cfg-save');
|
||||
|
||||
// Populate from state
|
||||
state.subscribe('config', (cfg) => {
|
||||
if (!cfg) return;
|
||||
waitInput.value = cfg.default_wait_seconds;
|
||||
emptyInput.value = cfg.default_empty_response;
|
||||
staleInput.value = cfg.agent_stale_after_seconds;
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
saveBtn.disabled = true;
|
||||
const original = saveBtn.innerHTML;
|
||||
saveBtn.innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
try {
|
||||
const cfg = await api.updateConfig({
|
||||
default_wait_seconds: parseInt(waitInput.value, 10) || 10,
|
||||
default_empty_response: emptyInput.value,
|
||||
agent_stale_after_seconds: parseInt(staleInput.value, 10) || 30,
|
||||
});
|
||||
state.set('config', cfg);
|
||||
toast('Settings saved', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = original;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
67
static/js/theme.js
Normal file
67
static/js/theme.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* static/js/theme.js
|
||||
* Dark / light theme toggle.
|
||||
* - Defaults to the OS/browser colour-scheme preference.
|
||||
* - User override is persisted in localStorage.
|
||||
* - Applies via <html data-theme="dark|light">.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'local-mcp-theme';
|
||||
|
||||
function systemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function storedTheme() {
|
||||
return localStorage.getItem(STORAGE_KEY); // 'dark' | 'light' | null
|
||||
}
|
||||
|
||||
function iconSun() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function iconMoon() {
|
||||
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
const isDark = theme === 'dark';
|
||||
btn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
btn.setAttribute('title', isDark ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
btn.innerHTML = isDark ? iconMoon() : iconSun();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
const current = document.documentElement.dataset.theme || 'dark';
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
// Apply immediately (before paint) based on stored or system preference
|
||||
const theme = storedTheme() || systemTheme();
|
||||
applyTheme(theme);
|
||||
|
||||
// Follow system changes only when the user hasn't manually overridden
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!storedTheme()) {
|
||||
applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', toggle);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user