Initial commit: LotusGuild Terminal Design System v1.0
Unified CSS, JavaScript utilities, HTML template, and framework skeleton files for Tinker Tickets (PHP), PULSE (Node.js), and GANDALF (Flask). Includes aesthetic_diff.md documenting every divergence between the three apps with prioritised recommendations for convergence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
589
README.md
Normal file
589
README.md
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
# LotusGuild Terminal Design System v1.0
|
||||||
|
|
||||||
|
Unified layout, styling, and JavaScript utilities for all LotusGuild web applications.
|
||||||
|
|
||||||
|
**Applies to:** Tinker Tickets (PHP) · PULSE (Node.js) · GANDALF (Flask)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Copy three files into your app's static/public directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
base.css → all variables, components, animations, responsive rules
|
||||||
|
base.js → toast, modal, tabs, CSRF, fetch helpers, keyboard shortcuts
|
||||||
|
base.html → full component reference (static demo, not for production)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then load them in your HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="/web_template/base.css">
|
||||||
|
<!-- app-specific overrides after: -->
|
||||||
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
|
|
||||||
|
<script src="/web_template/base.js"></script>
|
||||||
|
<!-- app-specific JS after: -->
|
||||||
|
<script nonce="NONCE" src="/assets/js/app.js?v=YYYYMMDD"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aesthetic at a Glance
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Background | `#0a0a0a` (near-black) |
|
||||||
|
| Primary accent | `#00ff41` (phosphor green) |
|
||||||
|
| Secondary accent | `#ffb000` (amber) |
|
||||||
|
| Tertiary accent | `#00ffff` (cyan) |
|
||||||
|
| Error / critical | `#ff4444` (red) |
|
||||||
|
| Font | `'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace` |
|
||||||
|
| Border radius | `0` — no rounded corners ever |
|
||||||
|
| Box shadow | Glow halos only (`0 0 Xpx color`) — no drop shadows |
|
||||||
|
| Borders | `2px solid` standard · `3px double` for outer frames / modals |
|
||||||
|
| CRT effects | Scanline animation · screen flicker at 30s · binary corner watermark |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Prefix
|
||||||
|
|
||||||
|
All classes use the `.lt-` prefix (*LotusGuild Terminal*) to prevent collisions with app-specific CSS.
|
||||||
|
|
||||||
|
```css
|
||||||
|
.lt-btn /* buttons */
|
||||||
|
.lt-card /* cards */
|
||||||
|
.lt-frame /* ASCII outer frames */
|
||||||
|
.lt-modal-overlay /* modals */
|
||||||
|
.lt-toast /* toasts */
|
||||||
|
.lt-status-* /* status badges */
|
||||||
|
.lt-p1 … .lt-p5 /* priority badges */
|
||||||
|
/* etc. */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS Custom Properties
|
||||||
|
|
||||||
|
All colour, spacing, and layout values are CSS custom properties on `:root`.
|
||||||
|
Override them per-app in your `app.css`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Example: app-specific accent on top of base */
|
||||||
|
:root {
|
||||||
|
--terminal-amber: #ffd000; /* slightly brighter amber */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Variables
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-primary: #0a0a0a
|
||||||
|
--bg-secondary: #1a1a1a
|
||||||
|
--bg-tertiary: #2a2a2a
|
||||||
|
--bg-terminal: #001a00
|
||||||
|
|
||||||
|
/* Terminal colours */
|
||||||
|
--terminal-green: #00ff41
|
||||||
|
--terminal-amber: #ffb000
|
||||||
|
--terminal-cyan: #00ffff
|
||||||
|
--terminal-red: #ff4444
|
||||||
|
|
||||||
|
/* Glow stacks (text-shadow) */
|
||||||
|
--glow-green
|
||||||
|
--glow-green-intense
|
||||||
|
--glow-amber
|
||||||
|
--glow-amber-intense
|
||||||
|
--glow-cyan
|
||||||
|
--glow-red
|
||||||
|
|
||||||
|
/* Box-shadow halos */
|
||||||
|
--box-glow-green
|
||||||
|
--box-glow-amber
|
||||||
|
--box-glow-red
|
||||||
|
|
||||||
|
/* Z-index ladder */
|
||||||
|
--z-dropdown: 100 --z-modal: 500 --z-toast: 800 --z-overlay: 9999
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="lt-btn">Default</button>
|
||||||
|
<button class="lt-btn lt-btn-primary">Primary</button>
|
||||||
|
<button class="lt-btn lt-btn-danger">Delete</button>
|
||||||
|
<button class="lt-btn lt-btn-sm">Small</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost">Ghost</button>
|
||||||
|
<a href="/create" class="lt-btn lt-btn-primary">Link Button</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
All buttons:
|
||||||
|
- Transparent background, green border, `[ text ]` bracket decoration via `::before`/`::after`
|
||||||
|
- Hover → amber colour, `translateY(-2px)` lift, amber glow
|
||||||
|
- `border-radius: 0` — terminal style, no rounding
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="lt-status lt-status-open">Open</span>
|
||||||
|
<span class="lt-status lt-status-in-progress">In Progress</span>
|
||||||
|
<span class="lt-status lt-status-closed">Closed</span>
|
||||||
|
<span class="lt-status lt-status-online">Online</span>
|
||||||
|
<span class="lt-status lt-status-failed">Failed</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Spinning `◐` indicator on `lt-status-in-progress` and `lt-status-running`.
|
||||||
|
|
||||||
|
### Priority Badges
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="lt-priority lt-p1">P1 Critical</span>
|
||||||
|
<span class="lt-priority lt-p2">P2 High</span>
|
||||||
|
<span class="lt-priority lt-p3">P3 Med</span>
|
||||||
|
<span class="lt-priority lt-p4">P4 Low</span>
|
||||||
|
<span class="lt-priority lt-p5">P5 Min</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
P1 has a pulsing glow animation.
|
||||||
|
|
||||||
|
### Chips (compact inline status)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span class="lt-chip lt-chip-ok">OK</span>
|
||||||
|
<span class="lt-chip lt-chip-warn">Warn</span>
|
||||||
|
<span class="lt-chip lt-chip-critical">Critical</span>
|
||||||
|
<span class="lt-chip lt-chip-info">Info</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Messages
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="lt-msg lt-msg-error">Something went wrong</div>
|
||||||
|
<div class="lt-msg lt-msg-success">Saved successfully</div>
|
||||||
|
<div class="lt-msg lt-msg-warning">Rate limit approaching</div>
|
||||||
|
<div class="lt-msg lt-msg-info">Auto-refresh every 30s</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ASCII Frames
|
||||||
|
|
||||||
|
Outer frame (double-line, for major sections):
|
||||||
|
```html
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Section Title</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Inner frame (single-line, for sub-panels):
|
||||||
|
```html
|
||||||
|
<div class="lt-frame-inner">
|
||||||
|
<div class="lt-subsection-header">Sub Section</div>
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `::before` / `::after` pseudo-elements draw the top-left `╔`/`┌` and top-right `╗`/`┐` corners automatically. Bottom corners require explicit `<span>` children because CSS cannot target `:nth-pseudo-element`.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="lt-card">
|
||||||
|
<div class="lt-card-title">Card Title</div>
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid of cards -->
|
||||||
|
<div class="lt-grid"> <!-- auto-fill, min 300px -->
|
||||||
|
<div class="lt-grid-2"> <!-- 2 columns -->
|
||||||
|
<div class="lt-grid-3"> <!-- 3 columns -->
|
||||||
|
<div class="lt-grid-4"> <!-- 4 columns -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
Full-border table (simple data, terminal look):
|
||||||
|
```html
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table" id="my-table">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort-key="name">Name</th>
|
||||||
|
<th data-sort-key="status">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="lt-row-p1 lt-row-critical">
|
||||||
|
<td>ticket-001</td>
|
||||||
|
<td><span class="lt-status lt-status-open">Open</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wire sorting -->
|
||||||
|
<script>lt.sortTable.init('my-table');</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Data table (compact, row-only separators, for dense data):
|
||||||
|
```html
|
||||||
|
<table class="lt-data-table">…</table>
|
||||||
|
```
|
||||||
|
|
||||||
|
Row classes: `lt-row-p1`–`lt-row-p5` (priority left border) · `lt-row-critical` · `lt-row-warning` · `lt-row-selected` (keyboard nav highlight)
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label lt-label-required" for="title">Title</label>
|
||||||
|
<input id="title" type="text" class="lt-input" placeholder="…">
|
||||||
|
<span class="lt-form-hint">Markdown supported.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="priority">Priority</label>
|
||||||
|
<select id="priority" class="lt-select">
|
||||||
|
<option>P1 — Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> Confidential
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search with prompt prefix -->
|
||||||
|
<div class="lt-search">
|
||||||
|
<input type="search" class="lt-input lt-search-input" placeholder="Search…">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Labels are amber-coloured and uppercase. Inputs: green border, focus → amber border + glow pulse.
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Trigger -->
|
||||||
|
<button class="lt-btn" data-modal-open="my-modal">Open Modal</button>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="my-modal" class="lt-modal-overlay" aria-hidden="true">
|
||||||
|
<div class="lt-modal">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title">Modal Title</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<!-- content -->
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
||||||
|
<button class="lt-btn lt-btn-primary">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `data-modal-open="id"` → opens the overlay
|
||||||
|
- `data-modal-close` → closes the nearest `.lt-modal-overlay`
|
||||||
|
- Click on backdrop (`.lt-modal-overlay`) → closes automatically
|
||||||
|
- ESC key → closes all open modals
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="lt-tabs">
|
||||||
|
<button class="lt-tab active" data-tab="panel-a">Tab A</button>
|
||||||
|
<button class="lt-tab" data-tab="panel-b">Tab B</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="panel-a" class="lt-tab-panel active">…</div>
|
||||||
|
<div id="panel-b" class="lt-tab-panel">…</div>
|
||||||
|
|
||||||
|
<script>lt.tabs.init();</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Active tab persists across page reloads via `localStorage`.
|
||||||
|
|
||||||
|
### Toast Notifications
|
||||||
|
|
||||||
|
```js
|
||||||
|
lt.toast.success('Ticket #123 saved');
|
||||||
|
lt.toast.error('Connection refused', 5000); // custom duration ms
|
||||||
|
lt.toast.warning('Rate limit 80% used');
|
||||||
|
lt.toast.info('Auto-refresh triggered');
|
||||||
|
```
|
||||||
|
|
||||||
|
Toasts appear bottom-right, stack vertically, auto-dismiss (default 3.5s), include an audible beep.
|
||||||
|
|
||||||
|
### Stats Widgets
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="lt-stats-grid">
|
||||||
|
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">
|
||||||
|
<span class="lt-stat-icon">📋</span>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<span class="lt-stat-value">42</span>
|
||||||
|
<span class="lt-stat-label">Open</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking a stat card calls `window.lt_onStatFilter(key, val)` — implement this function in your app JS to wire it to your filter logic.
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
```html
|
||||||
|
<aside class="lt-sidebar" id="lt-sidebar">
|
||||||
|
<div class="lt-sidebar-header">
|
||||||
|
Filters
|
||||||
|
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-sidebar-body">
|
||||||
|
<div class="lt-filter-group">
|
||||||
|
<span class="lt-filter-label">Category</span>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> Hardware
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
```
|
||||||
|
|
||||||
|
Collapse state persists across page reloads via `sessionStorage`.
|
||||||
|
|
||||||
|
### Boot Sequence
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="MY APP" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs once per session. Shows animated ASCII boot sequence. Add `data-app-name` to customise the banner text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript API (`window.lt`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* HTML safety */
|
||||||
|
lt.escHtml(str) // escape for innerHTML
|
||||||
|
|
||||||
|
/* Toasts */
|
||||||
|
lt.toast.success(msg, durationMs?)
|
||||||
|
lt.toast.error(msg, durationMs?)
|
||||||
|
lt.toast.warning(msg, durationMs?)
|
||||||
|
lt.toast.info(msg, durationMs?)
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
lt.modal.open('modal-id')
|
||||||
|
lt.modal.close('modal-id')
|
||||||
|
lt.modal.closeAll()
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
lt.tabs.init()
|
||||||
|
lt.tabs.switch('panel-id')
|
||||||
|
|
||||||
|
/* Boot */
|
||||||
|
lt.boot.run('APP NAME', forceFlag?)
|
||||||
|
|
||||||
|
/* Keyboard shortcuts */
|
||||||
|
lt.keys.on('ctrl+s', handler)
|
||||||
|
lt.keys.off('ctrl+s')
|
||||||
|
lt.keys.initDefaults() // ESC close, Ctrl+K focus search, ? help
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
lt.sidebar.init()
|
||||||
|
|
||||||
|
/* CSRF */
|
||||||
|
lt.csrf.headers() // returns { 'X-CSRF-Token': token } or {}
|
||||||
|
|
||||||
|
/* Fetch helpers */
|
||||||
|
await lt.api.get('/api/tickets')
|
||||||
|
await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' })
|
||||||
|
await lt.api.put('/api/workflows/5', payload)
|
||||||
|
await lt.api.delete('/api/comment', { id: 9 })
|
||||||
|
// All throw Error on non-2xx with the server's error message
|
||||||
|
|
||||||
|
/* Time */
|
||||||
|
lt.time.ago(isoString) // "5m ago"
|
||||||
|
lt.time.uptime(seconds) // "14d 6h 3m"
|
||||||
|
lt.time.format(isoString) // locale datetime string
|
||||||
|
|
||||||
|
/* Bytes */
|
||||||
|
lt.bytes.format(1234567) // "1.18 MB"
|
||||||
|
|
||||||
|
/* Table utilities */
|
||||||
|
lt.tableNav.init('table-id') // j/k/Enter keyboard navigation
|
||||||
|
lt.sortTable.init('table-id') // click-to-sort on data-sort-key headers
|
||||||
|
|
||||||
|
/* Stats widget filtering */
|
||||||
|
lt.statsFilter.init() // wires data-filter-key clicks
|
||||||
|
|
||||||
|
/* Auto-refresh */
|
||||||
|
lt.autoRefresh.start(fn, 30000) // call fn every 30 s
|
||||||
|
lt.autoRefresh.stop()
|
||||||
|
lt.autoRefresh.now() // trigger immediately + restart timer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSRF Integration
|
||||||
|
|
||||||
|
### PHP (Tinker Tickets)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<!-- In your view, using SecurityHeadersMiddleware nonce: -->
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
|
window.APP_TIMEZONE = '<?php echo $config['TIMEZONE']; ?>';
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
`lt.api.*` automatically includes the token as `X-CSRF-Token` header.
|
||||||
|
|
||||||
|
### Node.js (PULSE)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// server.js: generate token and pass to template
|
||||||
|
res.render('index', { csrfToken: req.session.csrfToken });
|
||||||
|
```
|
||||||
|
```html
|
||||||
|
<!-- EJS template -->
|
||||||
|
<script nonce="<%= nonce %>">window.CSRF_TOKEN = '<%= csrfToken %>';</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flask (GANDALF)
|
||||||
|
|
||||||
|
GANDALF uses Authelia SSO with `SameSite=Strict` cookies so CSRF tokens are not required on API endpoints. For new apps using Flask-WTF:
|
||||||
|
```html
|
||||||
|
<script nonce="{{ nonce }}">window.CSRF_TOKEN = '{{ csrf_token() }}';</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Integration Patterns
|
||||||
|
|
||||||
|
### PHP — Authelia headers
|
||||||
|
```php
|
||||||
|
// AuthMiddleware.php
|
||||||
|
$user = [
|
||||||
|
'username' => $_SERVER['HTTP_REMOTE_USER'] ?? '',
|
||||||
|
'name' => $_SERVER['HTTP_REMOTE_NAME'] ?? '',
|
||||||
|
'email' => $_SERVER['HTTP_REMOTE_EMAIL'] ?? '',
|
||||||
|
'groups' => explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? ''),
|
||||||
|
'is_admin' => in_array('admin', explode(',', $_SERVER['HTTP_REMOTE_GROUPS'] ?? '')),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python/Flask — `@require_auth` decorator
|
||||||
|
```python
|
||||||
|
def require_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = request.headers.get('Remote-User', '')
|
||||||
|
if not user:
|
||||||
|
return '401 Not Authenticated', 401
|
||||||
|
groups = [g.strip() for g in request.headers.get('Remote-Groups','').split(',')]
|
||||||
|
if 'admin' not in groups:
|
||||||
|
return '403 Forbidden', 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js — Express middleware
|
||||||
|
```js
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const user = req.headers['remote-user'];
|
||||||
|
if (!user) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
req.user = {
|
||||||
|
username: user,
|
||||||
|
name: req.headers['remote-name'] || user,
|
||||||
|
email: req.headers['remote-email'] || '',
|
||||||
|
groups: (req.headers['remote-groups'] || '').split(',').map(s => s.trim()),
|
||||||
|
isAdmin: (req.headers['remote-groups'] || '').split(',').includes('admin'),
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Framework Skeleton Files
|
||||||
|
|
||||||
|
| File | Framework | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `php/layout.php` | PHP / Tinker Tickets | Complete page layout with CSP nonce, CSRF, session |
|
||||||
|
| `python/base.html` | Flask / Jinja2 | Jinja2 template extending pattern |
|
||||||
|
| `python/auth.py` | Flask | `@require_auth` decorator + `_get_user()` |
|
||||||
|
| `node/middleware.js` | Node.js / Express | Auth + CSRF + CSP nonce middleware |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Layout for New Apps
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── app.css # Extends base.css — app-specific rules only
|
||||||
|
│ ├── js/
|
||||||
|
│ │ └── app.js # App logic — uses lt.* from base.js
|
||||||
|
│ └── images/
|
||||||
|
│ └── favicon.png
|
||||||
|
├── (php|python|node) files…
|
||||||
|
└── README.md # Link back to web_template/README.md for styling docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Width | Changes |
|
||||||
|
|---|---|---|
|
||||||
|
| XL | > 1400px | Full layout, 4-col grids |
|
||||||
|
| LG | ≤ 1400px | 3-col grids, 4-col stats |
|
||||||
|
| MD | ≤ 1200px | 2-col grids, narrow sidebar |
|
||||||
|
| SM (tablet) | ≤ 1024px | Sidebar stacks below content |
|
||||||
|
| XS (phone) | ≤ 768px | Nav hidden, single-col |
|
||||||
|
| XXS | ≤ 480px | Minimal padding, 2-col stats |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Rules
|
||||||
|
|
||||||
|
1. **No rounded corners** — `border-radius: 0` everywhere
|
||||||
|
2. **No drop shadows** — only glow halos (`box-shadow: 0 0 Xpx color`)
|
||||||
|
3. **No sans-serif fonts** — 100% monospace stack
|
||||||
|
4. **ASCII borders** — box-drawing chars via `::before`/`::after`, never images
|
||||||
|
5. **All uppercase** — headings, labels, nav links, button text
|
||||||
|
6. **`[ brackets ]`** — buttons and nav links wrap their text in `[ ]`
|
||||||
|
7. **Green = primary** — informational / active state
|
||||||
|
8. **Amber = secondary** — headings, highlights, hover state
|
||||||
|
9. **Cyan = tertiary** — uplinks, info, POE active, prompts
|
||||||
|
10. **Red = critical** — errors, P1 priority, offline state
|
||||||
|
11. **Glow on everything critical** — `text-shadow` glow stacks for P1 and errors
|
||||||
|
12. **Amber form labels** — labels are amber, inputs are green
|
||||||
|
13. **Scanlines always on** — the `body::before` CRT overlay is non-negotiable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility Notes
|
||||||
|
|
||||||
|
- All interactive elements have `:focus-visible` outlines (amber, 2px)
|
||||||
|
- `@media (prefers-reduced-motion)` disables all animations
|
||||||
|
- Screen-reader utility class: `.lt-sr-only`
|
||||||
|
- Modals set `aria-hidden` and trap focus
|
||||||
|
- Status elements use semantic colour + text (not colour alone)
|
||||||
|
- Minimum contrast: text-muted `#00bb33` on `#0a0a0a` ≈ 5.3:1 (WCAG AA ✓)
|
||||||
437
aesthetic_diff.md
Normal file
437
aesthetic_diff.md
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
# LotusGuild Aesthetic Diff — Convergence Guide
|
||||||
|
|
||||||
|
Cross-app analysis of every divergence between Tinker Tickets (PHP),
|
||||||
|
PULSE (Node.js), and GANDALF (Flask) that prevents a unified aesthetic.
|
||||||
|
Each section lists the current state in each app, then the **target state**
|
||||||
|
from the unified design system (`web_template/base.css`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. CSS Custom Property Names
|
||||||
|
|
||||||
|
The biggest source of divergence. Gandalf uses entirely different variable names.
|
||||||
|
|
||||||
|
| Property | Tinker Tickets | PULSE | GANDALF | **Target** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Main background | `--bg-primary: #0a0a0a` | `--bg-primary: #0a0a0a` | `--bg: #0a0a0a` | `--bg-primary` |
|
||||||
|
| Surface | `--bg-secondary: #1a1a1a` | `--bg-secondary: #1a1a1a` | `--bg2: #1a1a1a` | `--bg-secondary` |
|
||||||
|
| Raised surface | `--bg-tertiary: #2a2a2a` | `--bg-tertiary: #2a2a2a` | `--bg3: #2a2a2a` | `--bg-tertiary` |
|
||||||
|
| Primary green | `--terminal-green: #00ff41` | `--terminal-green: #00ff41` | `--green: #00ff41` | `--terminal-green` |
|
||||||
|
| Green tint | `--terminal-green-dim` | `--terminal-green-dim` | `--green-dim: rgba(0,255,65,.15)` | `--terminal-green-dim` |
|
||||||
|
| Amber | `--terminal-amber: #ffb000` | `--terminal-amber: #ffb000` | `--amber: #ffb000` | `--terminal-amber` |
|
||||||
|
| Amber tint | `--terminal-amber-dim` | `--terminal-amber-dim` | `--amber-dim` | `--terminal-amber-dim` |
|
||||||
|
| Cyan | `--terminal-cyan: #00ffff` | `--terminal-cyan: #00ffff` | `--cyan: #00ffff` | `--terminal-cyan` |
|
||||||
|
| Red | `--terminal-red: #ff4444` | `--terminal-red: #ff4444` | `--red: #ff4444` | `--terminal-red` |
|
||||||
|
| Body text | `--text-primary` | `--text-primary` | `--text` | `--text-primary` |
|
||||||
|
| Dim text | `--text-secondary` | `--text-secondary` | `--text-dim` | `--text-secondary` |
|
||||||
|
| Muted text | `--text-muted: #00bb33` | `--text-muted: #008822` | `--text-muted: #00bb33` | `--text-muted: #00bb33` |
|
||||||
|
| Border | `--border-color` | `--border-color` | `--border: rgba(0,255,65,.35)` | `--border-color` |
|
||||||
|
| Glow (green) | `--glow-green` | `--glow-green` | `--glow` | `--glow-green` |
|
||||||
|
| Glow (amber) | `--glow-amber` | `--glow-amber` | `--glow-amber` | `--glow-amber` ✓ |
|
||||||
|
| Font | `--font-mono` | `--font-mono` | `--font` | `--font-mono` |
|
||||||
|
|
||||||
|
### Fix for GANDALF (`style.css`)
|
||||||
|
|
||||||
|
Add these aliases at the top of `:root` (keeps existing rules working):
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Aliases to match unified naming */
|
||||||
|
--bg-primary: var(--bg);
|
||||||
|
--bg-secondary: var(--bg2);
|
||||||
|
--bg-tertiary: var(--bg3);
|
||||||
|
--terminal-green: var(--green);
|
||||||
|
--terminal-green-dim:var(--green-dim);
|
||||||
|
--terminal-amber: var(--amber);
|
||||||
|
--terminal-amber-dim:var(--amber-dim);
|
||||||
|
--terminal-cyan: var(--cyan);
|
||||||
|
--terminal-red: var(--red);
|
||||||
|
--text-primary: var(--text);
|
||||||
|
--text-secondary: var(--text-dim);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--glow-green: var(--glow);
|
||||||
|
--font-mono: var(--font);
|
||||||
|
--text-muted: #00bb33; /* override GANDALF's #008822 — too dark */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then migrate GANDALF's new code to use unified names. Remove aliases on next major refactor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Border Width & Style
|
||||||
|
|
||||||
|
| Context | Tinker Tickets | PULSE | GANDALF | **Target** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Standard card / panel | `2px solid` | `2px solid` | `1px solid` | `2px solid` |
|
||||||
|
| Modal | `3px double` | `3px double` | `1px solid` | `3px double` |
|
||||||
|
| Table outer | `2px solid` | — | none | `2px solid` |
|
||||||
|
| Input fields | `2px solid` | `2px solid` | `1px solid` | `2px solid` |
|
||||||
|
| Button | `2px solid` | `2px solid` | `1px solid` (`.btn-sm`) | `2px solid` |
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Increase `.modal`, `.host-card`, `.data-table`, `.form-group input/select`, `.btn-sm` border widths from `1px` to `2px`. The `1px` borders make elements look fragile compared to the other apps.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* GANDALF style.css — search & replace */
|
||||||
|
.modal { border: 1px solid → border: 3px double }
|
||||||
|
.host-card { border: 1px solid → border: 2px solid }
|
||||||
|
.form-group input,
|
||||||
|
.form-group select { border: 1px solid → border: 2px solid }
|
||||||
|
.btn-sm { border: 1px solid → border: 2px solid }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Button Pattern
|
||||||
|
|
||||||
|
| Aspect | Tinker Tickets | PULSE | GANDALF | **Target** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Bracket decoration | `[ text ]` via `::before`/`::after` | `[ text ]` via `::before`/`::after` | `> text` (primary only), no brackets on most buttons | `[ text ]` via `::before`/`::after` |
|
||||||
|
| Hover transform | `translateY(-2px)` | `translateY(-2px)` | none | `translateY(-2px)` |
|
||||||
|
| Hover animation | `pulse-glow-box 1.5s infinite` | none | none | none (lift is sufficient) |
|
||||||
|
| Padding (standard) | `10px 20px` | `12px 24px` | `6px 14px` (`.btn`) | `10px 20px` |
|
||||||
|
| Padding (small) | `5px 10px` | `6px 12px` | `2px 8px` | `5px 10px` |
|
||||||
|
| Font size | `0.9rem` | `1em` | `0.72em–0.8em` | `0.9rem` |
|
||||||
|
| Text transform | uppercase | uppercase | uppercase | uppercase |
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Add `::before`/`::after` bracket decorations to all `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` classes. Add `translateY(-2px)` hover transform. Increase padding.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* GANDALF — add to existing .btn rules */
|
||||||
|
.btn::before { content: '[ '; }
|
||||||
|
.btn::after { content: ' ]'; }
|
||||||
|
.btn:hover { transform: translateY(-2px); }
|
||||||
|
/* Adjust .btn-primary::before to '> ' instead of '[ ' for visual differentiation */
|
||||||
|
.btn-primary::before { content: '> '; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Glow Definition Consistency
|
||||||
|
|
||||||
|
| App | Green glow | Amber glow |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41` (3-layer solid) | `0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000` |
|
||||||
|
| PULSE | Identical to Tinker Tickets | Identical |
|
||||||
|
| GANDALF | `0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4)` (2-layer, rgba 2nd) | `0 0 5px #ffb000, 0 0 10px rgba(255,176,0,.4)` |
|
||||||
|
|
||||||
|
GANDALF's 2-layer glow is slightly softer. Both look fine, but they appear different on the same screen if pages are compared side-by-side.
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Update glow definitions to match the 3-layer solid stack:
|
||||||
|
```css
|
||||||
|
/* GANDALF style.css */
|
||||||
|
:root {
|
||||||
|
--glow: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
|
||||||
|
--glow-xl: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0,255,65,.5);
|
||||||
|
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Section Header Pattern
|
||||||
|
|
||||||
|
| App | Syntax | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `╠═══ TITLE ═══╣` | Symmetric, double-bar bookends |
|
||||||
|
| PULSE | `═══ TITLE ═══` (via h3::before/after) | No `╠` character |
|
||||||
|
| GANDALF | `╠══ TITLE` (one-sided) | Only left bookend, no right |
|
||||||
|
|
||||||
|
**Change required in PULSE and GANDALF:** Standardise to `╠═══ TITLE ═══╣`.
|
||||||
|
|
||||||
|
PULSE (`index.html`, all `h3::before`/`h3::after`):
|
||||||
|
```css
|
||||||
|
/* PULSE: update h3 pseudo-elements */
|
||||||
|
h3::before { content: '╠═══ '; color: var(--terminal-green); }
|
||||||
|
h3::after { content: ' ═══╣'; color: var(--terminal-green); }
|
||||||
|
```
|
||||||
|
|
||||||
|
GANDALF (`style.css`, `.section-title`):
|
||||||
|
```css
|
||||||
|
/* GANDALF: add right bookend */
|
||||||
|
.section-title::after { content: ' ═══╣'; color: var(--green); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Modal Border Style
|
||||||
|
|
||||||
|
| App | Border | Box shadow |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `3px double var(--terminal-green)` | none |
|
||||||
|
| PULSE | `3px double var(--terminal-green)` | `0 0 30px rgba(0,255,65,.3)` |
|
||||||
|
| GANDALF | `1px solid var(--green)` | `0 0 30px rgba(0,255,65,.18)` |
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Upgrade to `3px double` and add PULSE-style glow:
|
||||||
|
```css
|
||||||
|
.modal {
|
||||||
|
border: 3px double var(--green);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 255, 65, 0.2), 0 8px 40px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Modal Corner Characters
|
||||||
|
|
||||||
|
| App | Top corners | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `╔ ╗` (double-line) | Matches `3px double` border |
|
||||||
|
| PULSE | `╔ ╗` (double-line) | Matches `3px double` border |
|
||||||
|
| GANDALF | `┌ ┐` (single-line) | Doesn't match — should be `╔ ╗` for modals |
|
||||||
|
|
||||||
|
**Change required in GANDALF:**
|
||||||
|
```css
|
||||||
|
.modal::before { content: '╔'; }
|
||||||
|
.modal::after { content: '╗'; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Toast Position & Animation
|
||||||
|
|
||||||
|
| Aspect | Tinker Tickets | PULSE | GANDALF | **Target** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Position | `bottom: 1rem; right: 1rem` | `top: 80px; right: 20px` | `bottom: 20px; right: 20px` | `bottom: 1rem; right: 1rem` |
|
||||||
|
| Slide direction | from bottom | from top | from right | from right (`translateX(30px)`) |
|
||||||
|
| Animation duration | `0.3s ease` | `0.3s ease-out` | `0.15s ease` | `0.2s ease-out` |
|
||||||
|
| Auto-dismiss | 3500ms | 3000ms | 3500ms | `3500ms` |
|
||||||
|
| Icon format | `>> ` prefix | `✓/✗/ℹ` prefix (inline style) | `>> ` prefix | `>> ` prefix + icon in `.lt-toast-icon` |
|
||||||
|
| Queue system | Yes (serialised) | No (stacks) | No (stacks) | Yes (serialised) |
|
||||||
|
|
||||||
|
**Change required in PULSE:** Move toast position to `bottom: 20px; right: 20px`.
|
||||||
|
Replace inline-style notification function with `.lt-toast` classes.
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Already close — update animation to `slide-in-right` instead of `slide-in` (which slides from left in Gandalf's current implementation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Table Cell Borders
|
||||||
|
|
||||||
|
| App | Approach |
|
||||||
|
|---|---|
|
||||||
|
| Tinker Tickets | `border: 1px solid var(--border-color)` on every `<td>` — full grid |
|
||||||
|
| PULSE | Minimal table usage; when present, `border-bottom` only |
|
||||||
|
| GANDALF | `border-collapse: collapse` + `border-bottom: 1px solid rgba(0,255,65,.08)` row-only |
|
||||||
|
|
||||||
|
Both approaches are valid for different use cases. The design system provides both:
|
||||||
|
- `.lt-table` → full-grid borders (Tinker Tickets style, simple data)
|
||||||
|
- `.lt-data-table` → row-only borders (GANDALF style, dense data)
|
||||||
|
|
||||||
|
**Action:** Migrate existing tables to the appropriate class. No visual breakage, just choose the right variant per context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Form Label Colour
|
||||||
|
|
||||||
|
| App | Label colour |
|
||||||
|
|---|---|
|
||||||
|
| Tinker Tickets | `color: var(--terminal-green)` |
|
||||||
|
| PULSE | `color: var(--terminal-green)` |
|
||||||
|
| GANDALF | `color: var(--amber)` (amber labels) |
|
||||||
|
|
||||||
|
GANDALF's amber labels create a better visual hierarchy (labels stand out from field values). The unified design system adopts **amber labels** for all apps.
|
||||||
|
|
||||||
|
**Change required in Tinker Tickets and PULSE:**
|
||||||
|
```css
|
||||||
|
/* Tinker Tickets: assets/css/dashboard.css + ticket.css */
|
||||||
|
label, .filter-group h4 { color: var(--terminal-amber); text-shadow: var(--glow-amber); }
|
||||||
|
|
||||||
|
/* PULSE: index.html inline CSS */
|
||||||
|
label { color: var(--terminal-amber); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Nav Link Active State
|
||||||
|
|
||||||
|
| Aspect | Tinker Tickets | PULSE (tabs) | GANDALF | **Target** |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Active colour | amber | amber | amber | amber ✓ |
|
||||||
|
| Active background | `rgba(0,255,65,.08)` | `rgba(0,255,65,.2)` | `rgba(0,255,65,.07)` | `rgba(255,176,0,.15)` (amber tint) |
|
||||||
|
| Active border | green | amber | `border-color: var(--border)` (invisible) | amber |
|
||||||
|
| `[ ]` brackets | on `.btn` but not nav | on `.tab` | on `.nav-link` | on nav links |
|
||||||
|
|
||||||
|
**Change required in Tinker Tickets:** Add `[ ]` bracket decoration to nav links to match GANDALF. Currently Tinker Tickets has plain nav links without brackets.
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Use amber tint background on active state instead of green tint:
|
||||||
|
```css
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--amber-dim); /* was: var(--green-dim) */
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `text-muted` Colour Value
|
||||||
|
|
||||||
|
| App | Value | Contrast on #0a0a0a |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `#00bb33` | ~4.8:1 |
|
||||||
|
| PULSE | `#008822` | ~2.9:1 ✗ WCAG AA fail |
|
||||||
|
| GANDALF | `#00bb33` | ~4.8:1 |
|
||||||
|
|
||||||
|
**Change required in PULSE:** Update `--text-muted` from `#008822` to `#00bb33` to pass WCAG AA contrast.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* PULSE index.html :root */
|
||||||
|
--text-muted: #00bb33; /* was: #008822 */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Boot Sequence — Presence & Format
|
||||||
|
|
||||||
|
| App | Boot sequence | App name in banner |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | Yes — `showBootSequence()` in `DashboardView.php` | "TINKER TICKETS TERMINAL v1.0" |
|
||||||
|
| PULSE | Yes — `showBootSequence()` in `index.html` | "PULSE ORCHESTRATION TERMINAL v1.0" |
|
||||||
|
| GANDALF | **No** | — |
|
||||||
|
|
||||||
|
**Change required in GANDALF:** Add boot sequence overlay to `base.html`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Add to base.html <body> -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus add `lt.boot.run('GANDALF');` in `app.js` or inline at end of `base.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Status Badge Format
|
||||||
|
|
||||||
|
| App | Format | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `[● Open]` — `::before`/`::after` brackets | Brackets are pseudo-elements |
|
||||||
|
| PULSE | `[● Online]` — same pattern | Same |
|
||||||
|
| GANDALF | `.chip::before { content: '['; }` + `.chip::after { content: ']'; }` | Same pattern — different class names |
|
||||||
|
|
||||||
|
The pattern is consistent. The only issue is **class names** differ:
|
||||||
|
|
||||||
|
| Component | Tinker Tickets | PULSE | GANDALF |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Full status badge | `.status-Open`, `.status-Closed` | `.status.online`, `.status.failed` | `.chip-critical`, `.chip-ok` |
|
||||||
|
| Small badge | — | `.badge` | `.badge`, `.chip` |
|
||||||
|
|
||||||
|
**Standardise to** `.lt-status-*` (full badge) + `.lt-chip-*` (compact) + `.lt-badge-*` (inline label) going forward. Existing class names can remain as app-internal aliases.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Scanline Effect Differences
|
||||||
|
|
||||||
|
All three apps have the same scanline `body::before` and data-stream `body::after`. Minor differences:
|
||||||
|
|
||||||
|
| Aspect | Tinker Tickets | PULSE | GANDALF |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Scanline opacity | `rgba(0,0,0,0.15)` | `rgba(0,0,0,0.15)` | `rgba(0,0,0,.13)` |
|
||||||
|
| Flicker delay | `30s` | `30s` | `45s` |
|
||||||
|
| Data stream position | `bottom:10px; right:10px` | `bottom:10px; right:10px` | `bottom:10px; right:14px` |
|
||||||
|
|
||||||
|
These are minor. **Standardise to:** opacity `0.15`, flicker delay `30s`, position `bottom:10px; right:14px`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Hover Transform on Cards / Items
|
||||||
|
|
||||||
|
| App | Card hover | List item hover |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | `translateY(-2px)` + glow | `translateY(-2px)` |
|
||||||
|
| PULSE | `translateY(-2px)` | `translateX(3px)` |
|
||||||
|
| GANDALF | `border-color` change only, no transform | `border-left-width` expansion |
|
||||||
|
|
||||||
|
**Standardise to:** Cards use `translateY(-2px)`. List/row items use `border-left-width` expansion (GANDALF approach, less disorienting for dense lists).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. CSS Architecture — Inline vs. External
|
||||||
|
|
||||||
|
| App | CSS location |
|
||||||
|
|---|---|
|
||||||
|
| Tinker Tickets | External: `assets/css/dashboard.css` + `ticket.css` |
|
||||||
|
| PULSE | **All inline** in `index.html` (`<style>` tag, ~800 lines) |
|
||||||
|
| GANDALF | External: `static/style.css` |
|
||||||
|
|
||||||
|
**Change required in PULSE:** Extract the `<style>` block from `index.html` into `public/style.css`. This enables:
|
||||||
|
- Browser caching
|
||||||
|
- Cache-busting with `?v=YYYYMMDD`
|
||||||
|
- Editor syntax highlighting
|
||||||
|
- Easier diff when updating to unified base.css
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. JavaScript Toast Implementation
|
||||||
|
|
||||||
|
| Aspect | Tinker Tickets | PULSE | GANDALF |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Function name | `showToast(msg, type, duration)` | `showTerminalNotification(msg, type)` | `showToast(msg, type)` |
|
||||||
|
| Uses CSS classes | Yes (`.terminal-toast`) | No (inline styles) | Yes (`.toast`) |
|
||||||
|
| Queue system | Yes | No | No |
|
||||||
|
| Plays audio | No | Yes (Web Audio) | No |
|
||||||
|
|
||||||
|
**Standardise all three** to use `lt.toast.*` from `base.js`. The function is already a superset of all three implementations.
|
||||||
|
|
||||||
|
Required changes:
|
||||||
|
- Tinker Tickets: Replace `showToast()` in `toast.js` with `lt.toast.*` calls
|
||||||
|
- PULSE: Replace `showTerminalNotification()` in `index.html` with `lt.toast.*` calls
|
||||||
|
- GANDALF: Replace `showToast()` in `app.js` with `lt.toast.*` calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. CSRF Implementation
|
||||||
|
|
||||||
|
| App | Method | Token location |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | PHP `CsrfMiddleware::getToken()` — `X-CSRF-Token` header | `window.CSRF_TOKEN` via inline script |
|
||||||
|
| PULSE | No CSRF tokens (session cookies + API key auth) | — |
|
||||||
|
| GANDALF | No CSRF tokens (Authelia `SameSite=Strict`) | — |
|
||||||
|
|
||||||
|
No breaking changes needed. `lt.csrf.headers()` gracefully returns `{}` when `window.CSRF_TOKEN` is not set, so PULSE and GANDALF are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Responsive Design Coverage
|
||||||
|
|
||||||
|
| App | Mobile-responsive? | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Tinker Tickets | Yes — extensive breakpoints, card view below 1400px | Most complete |
|
||||||
|
| PULSE | Partial — `auto-fit` grid, 90vw modals | No nav changes on mobile |
|
||||||
|
| GANDALF | Partial — grid wraps, no explicit breakpoints documented | Nav becomes scrollable |
|
||||||
|
|
||||||
|
**Change required in PULSE and GANDALF:** Add mobile nav handling (hamburger / collapsible), hide `.lt-nav` below 768px, ensure tables are horizontally scrollable on mobile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Order for Convergence
|
||||||
|
|
||||||
|
| Priority | Item | Effort | Impact |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 🔴 High | **GANDALF: CSS variable renaming** (§1) | Medium | Eliminates all naming confusion |
|
||||||
|
| 🔴 High | **PULSE: Extract inline CSS to file** (§17) | Low | Enables sharing base.css |
|
||||||
|
| 🔴 High | **All: Standardise toast to `lt.toast`** (§18) | Low | Consistent UX across apps |
|
||||||
|
| 🟡 Medium | **GANDALF: Border widths 1px→2px** (§2) | Low | Visual parity |
|
||||||
|
| 🟡 Medium | **GANDALF: Modal → `3px double`** (§6) | Low | Visual parity |
|
||||||
|
| 🟡 Medium | **PULSE: Fix `--text-muted` contrast** (§12) | Trivial | Accessibility |
|
||||||
|
| 🟡 Medium | **All: Section headers → `╠═══ X ═══╣`** (§5) | Low | Consistent branding |
|
||||||
|
| 🟡 Medium | **GANDALF: Add boot sequence** (§13) | Low | Brand consistency |
|
||||||
|
| 🟢 Low | **GANDALF: Button `[ ]` brackets + hover lift** (§3) | Low | Visual consistency |
|
||||||
|
| 🟢 Low | **TT + PULSE: Amber form labels** (§10) | Low | Better hierarchy |
|
||||||
|
| 🟢 Low | **Scanline exact values** (§15) | Trivial | Micro-consistency |
|
||||||
|
| 🟢 Low | **PULSE: Toast position bottom-right** (§8) | Trivial | UX consistency |
|
||||||
|
| 🟢 Low | **GANDALF: Glow 2-layer → 3-layer** (§4) | Trivial | Visual consistency |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Does NOT Need to Change
|
||||||
|
|
||||||
|
These differences are intentional or platform-specific and should be preserved:
|
||||||
|
|
||||||
|
- **GANDALF's compact font sizes** (`.7em` for table headers) — appropriate for dense network data
|
||||||
|
- **PULSE's tab-based navigation** — appropriate for single-page app structure
|
||||||
|
- **Tinker Tickets' full table cell borders** — appropriate for ticket data with many columns
|
||||||
|
- **PULSE's WebSocket real-time updates** — architecture difference, not aesthetic
|
||||||
|
- **GANDALF's collapse state stored in sessionStorage** vs. Tinker Tickets using CSS class toggle — both work
|
||||||
|
- **Status colour values for tickets vs. network events** — apps use different semantic colours for their domain (ticket statuses vs. UP/DOWN states)
|
||||||
|
- **Priority colour scale** — only Tinker Tickets uses P1–P5; the other apps don't need it
|
||||||
716
base.html
Normal file
716
base.html
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.html
|
||||||
|
Reference template showing every component and layout pattern.
|
||||||
|
|
||||||
|
This file is a STATIC DEMO. Framework-specific wiring is in:
|
||||||
|
php/ → PHP / Tinker Tickets
|
||||||
|
python/ → Flask / Jinja2 / GANDALF
|
||||||
|
node/ → Express / EJS / PULSE
|
||||||
|
|
||||||
|
To build a new app, copy one of the framework skeletons from those
|
||||||
|
sub-directories and reference base.css + base.js.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MY APP — LotusGuild</title>
|
||||||
|
<meta name="description" content="LotusGuild infrastructure application">
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
Security headers are set server-side. CSP nonce is injected
|
||||||
|
by SecurityHeadersMiddleware (PHP) / helmet (Node) / Flask.
|
||||||
|
All <script> tags need: nonce="NONCE_PLACEHOLDER"
|
||||||
|
========================================================= -->
|
||||||
|
|
||||||
|
<!-- Base design system CSS -->
|
||||||
|
<link rel="stylesheet" href="/web_template/base.css">
|
||||||
|
<!--
|
||||||
|
App-specific CSS (override or extend after base.css):
|
||||||
|
<link rel="stylesheet" href="/assets/css/app.css">
|
||||||
|
-->
|
||||||
|
|
||||||
|
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
BOOT SEQUENCE OVERLAY
|
||||||
|
Displays once per session. Remove if not desired.
|
||||||
|
data-app-name → used in the boot text banner.
|
||||||
|
=========================================================== -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="MY APP" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
HEADER
|
||||||
|
=========================================================== -->
|
||||||
|
<header class="lt-header">
|
||||||
|
<div class="lt-header-left">
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="lt-brand">
|
||||||
|
<span class="lt-brand-title">MY APP</span>
|
||||||
|
<span class="lt-brand-subtitle">LotusGuild Infrastructure</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horizontal nav links -->
|
||||||
|
<nav class="lt-nav" aria-label="Main navigation">
|
||||||
|
<a href="/" class="lt-nav-link active">Dashboard</a>
|
||||||
|
<a href="/tickets" class="lt-nav-link">Tickets</a>
|
||||||
|
<a href="/workers" class="lt-nav-link">Workers</a>
|
||||||
|
|
||||||
|
<!-- Dropdown example (admin menu) -->
|
||||||
|
<div class="lt-nav-dropdown">
|
||||||
|
<a href="#" class="lt-nav-link">Admin ▾</a>
|
||||||
|
<ul class="lt-nav-dropdown-menu">
|
||||||
|
<li><a href="/admin/templates">Templates</a></li>
|
||||||
|
<li><a href="/admin/workflow">Workflow</a></li>
|
||||||
|
<li><a href="/admin/audit-log">Audit Log</a></li>
|
||||||
|
<li><a href="/admin/api-keys">API Keys</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-header-right">
|
||||||
|
<!-- Current user -->
|
||||||
|
<span class="lt-header-user">operator</span>
|
||||||
|
<!-- Admin badge (only show for admins) -->
|
||||||
|
<span class="lt-badge-admin">admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
MAIN CONTENT AREA
|
||||||
|
=========================================================== -->
|
||||||
|
<main class="lt-main lt-container">
|
||||||
|
|
||||||
|
<!-- Page title bar -->
|
||||||
|
<div class="lt-page-header">
|
||||||
|
<h1 class="lt-page-title">Dashboard</h1>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<a href="/create" class="lt-btn lt-btn-primary">New Ticket</a>
|
||||||
|
<button class="lt-btn lt-btn-sm" data-modal-open="export-modal">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==========================================================
|
||||||
|
STATS WIDGETS
|
||||||
|
data-filter-key / data-filter-val → wired by lt.statsFilter
|
||||||
|
========================================================== -->
|
||||||
|
<div class="lt-stats-grid">
|
||||||
|
<div class="lt-stat-card active" data-filter-key="status" data-filter-val="Open">
|
||||||
|
<span class="lt-stat-icon">📋</span>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<span class="lt-stat-value">42</span>
|
||||||
|
<span class="lt-stat-label">Open</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card" data-filter-key="priority" data-filter-val="1">
|
||||||
|
<span class="lt-stat-icon">🔴</span>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<span class="lt-stat-value">3</span>
|
||||||
|
<span class="lt-stat-label">Critical</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card" data-filter-key="assigned_to" data-filter-val="0">
|
||||||
|
<span class="lt-stat-icon">👤</span>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<span class="lt-stat-value">11</span>
|
||||||
|
<span class="lt-stat-label">Unassigned</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-stat-card" data-filter-key="created" data-filter-val="today">
|
||||||
|
<span class="lt-stat-icon">📅</span>
|
||||||
|
<div class="lt-stat-info">
|
||||||
|
<span class="lt-stat-value">7</span>
|
||||||
|
<span class="lt-stat-label">Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==========================================================
|
||||||
|
TAB NAVIGATION
|
||||||
|
========================================================== -->
|
||||||
|
<div class="lt-tabs">
|
||||||
|
<button class="lt-tab active" data-tab="tab-table">Table View</button>
|
||||||
|
<button class="lt-tab" data-tab="tab-kanban">Kanban</button>
|
||||||
|
<button class="lt-tab" data-tab="tab-workers">Workers</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==========================================================
|
||||||
|
SIDEBAR + CONTENT LAYOUT
|
||||||
|
========================================================== -->
|
||||||
|
<div class="lt-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar filter panel -->
|
||||||
|
<aside class="lt-sidebar" id="lt-sidebar">
|
||||||
|
<div class="lt-sidebar-header">
|
||||||
|
Filters
|
||||||
|
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar"
|
||||||
|
aria-label="Collapse filters">◀</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-sidebar-body">
|
||||||
|
|
||||||
|
<div class="lt-filter-group">
|
||||||
|
<span class="lt-filter-label">Status</span>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox" checked> Open
|
||||||
|
</label>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> Pending
|
||||||
|
</label>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> In Progress
|
||||||
|
</label>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> Closed
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-filter-group">
|
||||||
|
<span class="lt-filter-label">Priority</span>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> P1 Critical
|
||||||
|
</label>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> P2 High
|
||||||
|
</label>
|
||||||
|
<label class="lt-filter-option">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> P3 Medium
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-filter-group">
|
||||||
|
<span class="lt-filter-label">Assigned To</span>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<select class="lt-select lt-btn-sm">
|
||||||
|
<option value="">All users</option>
|
||||||
|
<option>operator</option>
|
||||||
|
<option>admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button class="lt-btn lt-btn-sm">Apply</button>
|
||||||
|
<button class="lt-btn lt-btn-sm lt-btn-ghost">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div class="lt-content">
|
||||||
|
|
||||||
|
<!-- Toolbar: search + actions -->
|
||||||
|
<div class="lt-toolbar">
|
||||||
|
<div class="lt-toolbar-left">
|
||||||
|
<div class="lt-search">
|
||||||
|
<input type="search" class="lt-input lt-search-input"
|
||||||
|
placeholder="Search tickets..." aria-label="Search">
|
||||||
|
</div>
|
||||||
|
<button class="lt-btn lt-btn-sm">Advanced ▾</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-toolbar-right">
|
||||||
|
<span class="lt-text-muted lt-text-xs">42 results</span>
|
||||||
|
<button class="lt-btn lt-btn-sm lt-btn-ghost">Bulk Actions</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================================================
|
||||||
|
TAB PANEL: TABLE VIEW
|
||||||
|
================================================== -->
|
||||||
|
<div id="tab-table" class="lt-tab-panel active">
|
||||||
|
|
||||||
|
<!-- Outer ASCII frame wrapping the table -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
|
||||||
|
<div class="lt-section-header">Ticket Queue</div>
|
||||||
|
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table" id="ticket-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" class="lt-checkbox" aria-label="Select all"></th>
|
||||||
|
<th data-sort-key="id">ID</th>
|
||||||
|
<th data-sort-key="priority">Priority</th>
|
||||||
|
<th data-sort-key="title">Title</th>
|
||||||
|
<th data-sort-key="status">Status</th>
|
||||||
|
<th data-sort-key="assignee">Assignee</th>
|
||||||
|
<th data-sort-key="created">Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
|
||||||
|
<!-- P1 Critical row -->
|
||||||
|
<tr class="lt-row-p1 lt-row-critical">
|
||||||
|
<td><input type="checkbox" class="lt-checkbox"></td>
|
||||||
|
<td><a href="/ticket/123456789">#123456789</a></td>
|
||||||
|
<td><span class="lt-p1">P1 Critical</span></td>
|
||||||
|
<td>Storage array link-down on compute-storage-01</td>
|
||||||
|
<td><span class="lt-status lt-status-open">Open</span></td>
|
||||||
|
<td class="lt-text-muted">Unassigned</td>
|
||||||
|
<td class="lt-text-xs lt-text-muted">5m ago</td>
|
||||||
|
<td>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<a href="/ticket/123456789" class="lt-btn lt-btn-sm">View</a>
|
||||||
|
<button class="lt-btn lt-btn-sm lt-btn-danger">Close</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- P2 High row -->
|
||||||
|
<tr class="lt-row-p2 lt-row-warning">
|
||||||
|
<td><input type="checkbox" class="lt-checkbox"></td>
|
||||||
|
<td><a href="/ticket/987654321">#987654321</a></td>
|
||||||
|
<td><span class="lt-p2">P2 High</span></td>
|
||||||
|
<td>Switch port flapping on USW-Pro-24</td>
|
||||||
|
<td><span class="lt-status lt-status-in-progress">In Progress</span></td>
|
||||||
|
<td>operator</td>
|
||||||
|
<td class="lt-text-xs lt-text-muted">2h ago</td>
|
||||||
|
<td>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<a href="/ticket/987654321" class="lt-btn lt-btn-sm">View</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- P3 Medium row -->
|
||||||
|
<tr class="lt-row-p3">
|
||||||
|
<td><input type="checkbox" class="lt-checkbox"></td>
|
||||||
|
<td><a href="/ticket/111222333">#111222333</a></td>
|
||||||
|
<td><span class="lt-p3">P3 Med</span></td>
|
||||||
|
<td>Scheduled maintenance: replace SFP+ on large1</td>
|
||||||
|
<td><span class="lt-status lt-status-pending">Pending</span></td>
|
||||||
|
<td>admin</td>
|
||||||
|
<td class="lt-text-xs lt-text-muted">1d ago</td>
|
||||||
|
<td>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<a href="/ticket/111222333" class="lt-btn lt-btn-sm">View</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- P4 closed -->
|
||||||
|
<tr class="lt-row-p4">
|
||||||
|
<td><input type="checkbox" class="lt-checkbox"></td>
|
||||||
|
<td><a href="/ticket/444555666">#444555666</a></td>
|
||||||
|
<td><span class="lt-p4">P4 Low</span></td>
|
||||||
|
<td>Update SSL cert on wiki.lotusguild.org</td>
|
||||||
|
<td><span class="lt-status lt-status-closed">Closed</span></td>
|
||||||
|
<td>operator</td>
|
||||||
|
<td class="lt-text-xs lt-text-muted">3d ago</td>
|
||||||
|
<td>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<a href="/ticket/444555666" class="lt-btn lt-btn-sm">View</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div><!-- /.lt-frame -->
|
||||||
|
|
||||||
|
</div><!-- /#tab-table -->
|
||||||
|
|
||||||
|
<!-- ==================================================
|
||||||
|
TAB PANEL: KANBAN
|
||||||
|
================================================== -->
|
||||||
|
<div id="tab-kanban" class="lt-tab-panel">
|
||||||
|
<div class="lt-grid-4">
|
||||||
|
|
||||||
|
<!-- Kanban column: Open -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Open</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
|
||||||
|
<div class="lt-card lt-mb-md lt-row-p1">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-p1">P1</span>
|
||||||
|
<span class="lt-dot lt-dot-up"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-text-sm">Storage array link-down</div>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">5m ago · Unassigned</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-card lt-row-p3">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-p3">P3</span>
|
||||||
|
<span class="lt-dot lt-dot-up"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-text-sm">Update node_exporter on micro1</div>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">1d ago · operator</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kanban column: Pending -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Pending</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-card lt-row-p2">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-p2">P2</span>
|
||||||
|
<span class="lt-dot lt-dot-warn"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-text-sm">Scheduled maintenance window</div>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">2d ago · admin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kanban column: In Progress -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">In Progress</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-card lt-row-p2 lt-item-running">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-p2">P2</span>
|
||||||
|
<span class="lt-dot lt-dot-warn"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-text-sm">Switch port flapping on USW-Pro</div>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">2h ago · operator</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kanban column: Closed -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Closed</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-card lt-row-p4" style="opacity:0.6">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-p4">P4</span>
|
||||||
|
<span class="lt-dot lt-dot-idle"></span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-text-sm">Update SSL cert on wiki</div>
|
||||||
|
<div class="lt-text-xs lt-text-muted lt-mt-sm">3d ago · operator</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.lt-grid-4 -->
|
||||||
|
</div><!-- /#tab-kanban -->
|
||||||
|
|
||||||
|
<!-- ==================================================
|
||||||
|
TAB PANEL: WORKERS
|
||||||
|
================================================== -->
|
||||||
|
<div id="tab-workers" class="lt-tab-panel">
|
||||||
|
<div class="lt-grid-3">
|
||||||
|
|
||||||
|
<div class="lt-card">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-text-amber lt-text-upper">pulse-worker-01</span>
|
||||||
|
<span class="lt-status lt-status-online">Online</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-data-table-wrapper">
|
||||||
|
<table class="lt-data-table">
|
||||||
|
<tr><td class="lt-text-muted">CPU</td> <td>12%</td></tr>
|
||||||
|
<tr><td class="lt-text-muted">Memory</td> <td>2.1 GB / 8 GB</td></tr>
|
||||||
|
<tr><td class="lt-text-muted">Load</td> <td>0.42 / 0.51 / 0.48</td></tr>
|
||||||
|
<tr><td class="lt-text-muted">Uptime</td> <td>14d 6h</td></tr>
|
||||||
|
<tr><td class="lt-text-muted">Tasks</td> <td>2 / 5</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-card">
|
||||||
|
<div class="lt-flex lt-flex-between lt-mb-md">
|
||||||
|
<span class="lt-text-amber lt-text-upper">pulse-worker-02</span>
|
||||||
|
<span class="lt-status lt-status-offline">Offline</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-msg lt-msg-warning">Last seen 14m ago</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-card">
|
||||||
|
<div class="lt-empty">No more workers registered.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div><!-- /#tab-workers -->
|
||||||
|
|
||||||
|
</div><!-- /.lt-content -->
|
||||||
|
</div><!-- /.lt-layout -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ==========================================================
|
||||||
|
COMPONENT SHOWCASE (remove in production)
|
||||||
|
========================================================== -->
|
||||||
|
<div class="lt-divider"></div>
|
||||||
|
|
||||||
|
<!-- Inner frame + subsection example -->
|
||||||
|
<div class="lt-frame">
|
||||||
|
<span class="lt-frame-bl">╚</span>
|
||||||
|
<span class="lt-frame-br">╝</span>
|
||||||
|
<div class="lt-section-header">Component Reference</div>
|
||||||
|
|
||||||
|
<div class="lt-frame-inner">
|
||||||
|
<div class="lt-subsection-header">Buttons</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button class="lt-btn">Default</button>
|
||||||
|
<button class="lt-btn lt-btn-primary">Primary</button>
|
||||||
|
<button class="lt-btn lt-btn-danger">Danger</button>
|
||||||
|
<button class="lt-btn lt-btn-sm">Small</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost">Ghost</button>
|
||||||
|
<button class="lt-btn" disabled>Disabled</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Status Badges</div>
|
||||||
|
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-md">
|
||||||
|
<span class="lt-status lt-status-open">Open</span>
|
||||||
|
<span class="lt-status lt-status-pending">Pending</span>
|
||||||
|
<span class="lt-status lt-status-in-progress">In Progress</span>
|
||||||
|
<span class="lt-status lt-status-closed">Closed</span>
|
||||||
|
<span class="lt-status lt-status-online">Online</span>
|
||||||
|
<span class="lt-status lt-status-offline">Offline</span>
|
||||||
|
<span class="lt-status lt-status-running">Running</span>
|
||||||
|
<span class="lt-status lt-status-completed">Completed</span>
|
||||||
|
<span class="lt-status lt-status-failed">Failed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Priority Badges</div>
|
||||||
|
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-md">
|
||||||
|
<span class="lt-priority lt-p1">P1 Critical</span>
|
||||||
|
<span class="lt-priority lt-p2">P2 High</span>
|
||||||
|
<span class="lt-priority lt-p3">P3 Med</span>
|
||||||
|
<span class="lt-priority lt-p4">P4 Low</span>
|
||||||
|
<span class="lt-priority lt-p5">P5 Min</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Chips & Badges</div>
|
||||||
|
<div class="lt-section-body lt-flex lt-flex-wrap lt-gap-sm">
|
||||||
|
<span class="lt-chip lt-chip-ok">OK</span>
|
||||||
|
<span class="lt-chip lt-chip-warn">Warning</span>
|
||||||
|
<span class="lt-chip lt-chip-critical">Critical</span>
|
||||||
|
<span class="lt-chip lt-chip-info">Info</span>
|
||||||
|
<span class="lt-badge lt-badge-green">v1.0</span>
|
||||||
|
<span class="lt-badge lt-badge-amber">Beta</span>
|
||||||
|
<span class="lt-badge lt-badge-red">Deprecated</span>
|
||||||
|
<span class="lt-badge-admin">admin</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Status Dots</div>
|
||||||
|
<div class="lt-section-body lt-flex lt-gap-md">
|
||||||
|
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-up"></span> Up</span>
|
||||||
|
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-down"></span> Down</span>
|
||||||
|
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-warn"></span> Degraded</span>
|
||||||
|
<span class="lt-flex lt-gap-sm"><span class="lt-dot lt-dot-idle"></span> Idle</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Inline Messages</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-msg lt-msg-error">Database connection refused on 10.10.10.50</div>
|
||||||
|
<div class="lt-msg lt-msg-success">Ticket #123456789 updated successfully</div>
|
||||||
|
<div class="lt-msg lt-msg-warning">Rate limit: 80% consumed (40/50 req/min)</div>
|
||||||
|
<div class="lt-msg lt-msg-info">Auto-refresh active — updates every 30s</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Forms</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-grid-2">
|
||||||
|
<div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label lt-label-required" for="eg-title">Ticket Title</label>
|
||||||
|
<input id="eg-title" type="text" class="lt-input" placeholder="Describe the issue">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="eg-priority">Priority</label>
|
||||||
|
<select id="eg-priority" class="lt-select">
|
||||||
|
<option>P1 — Critical</option>
|
||||||
|
<option>P2 — High</option>
|
||||||
|
<option selected>P3 — Medium</option>
|
||||||
|
<option>P4 — Low</option>
|
||||||
|
<option>P5 — Minimal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="eg-desc">Description</label>
|
||||||
|
<textarea id="eg-desc" class="lt-textarea" placeholder="Markdown supported..."></textarea>
|
||||||
|
<span class="lt-form-hint">Markdown supported. Use #123456789 to link tickets.</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">
|
||||||
|
<input type="checkbox" class="lt-checkbox"> Confidential ticket
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="lt-btn-group">
|
||||||
|
<button class="lt-btn lt-btn-primary">Submit</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="lt-search lt-form-group">
|
||||||
|
<label class="lt-label" for="eg-search">Search</label>
|
||||||
|
<input id="eg-search" type="search" class="lt-input lt-search-input"
|
||||||
|
placeholder="Ctrl+K to focus">
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">Loading state (skeleton)</label>
|
||||||
|
<div class="lt-skeleton lt-p-md" style="height:40px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">Empty state</label>
|
||||||
|
<div class="lt-empty" style="padding:1rem">No results found</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">Loading indicator</label>
|
||||||
|
<div class="lt-loading" style="padding:1rem"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-subsection-header">Log / Timeline Entries</div>
|
||||||
|
<div class="lt-section-body">
|
||||||
|
<div class="lt-log-entry success">
|
||||||
|
<div class="lt-log-ts">2026-03-14 09:12:33 EDT</div>
|
||||||
|
<strong>Status changed:</strong> Open → In Progress by operator
|
||||||
|
</div>
|
||||||
|
<div class="lt-log-entry warning">
|
||||||
|
<div class="lt-log-ts">2026-03-14 09:10:11 EDT</div>
|
||||||
|
<strong>Priority escalated:</strong> P3 → P1 by GANDALF auto-alert
|
||||||
|
</div>
|
||||||
|
<div class="lt-log-entry error">
|
||||||
|
<div class="lt-log-ts">2026-03-14 09:09:55 EDT</div>
|
||||||
|
<strong>Alert triggered:</strong> NIC link-down on large1:enp35s0
|
||||||
|
<div class="lt-log-output">node_exporter metric: node_network_up{interface="enp35s0"} = 0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /.lt-frame-inner -->
|
||||||
|
</div><!-- /.lt-frame (component showcase) -->
|
||||||
|
|
||||||
|
</main><!-- /.lt-main -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
TOAST DEMO BUTTONS (remove in production)
|
||||||
|
=========================================================== -->
|
||||||
|
<div style="position:fixed;bottom:1rem;left:1rem;display:flex;flex-direction:column;gap:0.5rem;z-index:900">
|
||||||
|
<button class="lt-btn lt-btn-sm" onclick="lt.toast.success('Ticket saved successfully')">✓ Toast</button>
|
||||||
|
<button class="lt-btn lt-btn-sm lt-btn-danger" onclick="lt.toast.error('Network error — retry in 5s', 5000)">✗ Error</button>
|
||||||
|
<button class="lt-btn lt-btn-sm" onclick="lt.toast.warning('Rate limit 80% used')">! Warn</button>
|
||||||
|
<button class="lt-btn lt-btn-sm lt-btn-ghost" onclick="lt.toast.info('Auto-refresh triggered')">i Info</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
MODAL EXAMPLE: Export
|
||||||
|
=========================================================== -->
|
||||||
|
<div id="export-modal" class="lt-modal-overlay" aria-hidden="true">
|
||||||
|
<div class="lt-modal">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title">Export Tickets</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label" for="export-fmt">Format</label>
|
||||||
|
<select id="export-fmt" class="lt-select">
|
||||||
|
<option>CSV</option>
|
||||||
|
<option>JSON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="lt-form-group">
|
||||||
|
<label class="lt-label">
|
||||||
|
<input type="checkbox" class="lt-checkbox" checked> Selected tickets only
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="lt-msg lt-msg-info">Exports include all visible columns.</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
||||||
|
<button class="lt-btn lt-btn-primary" onclick="lt.toast.success('Export started'); lt.modal.close('export-modal')">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
MODAL EXAMPLE: Keyboard shortcuts help
|
||||||
|
=========================================================== -->
|
||||||
|
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
||||||
|
<div class="lt-modal">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title">Keyboard Shortcuts</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<table class="lt-data-table" style="width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Shortcut</th><th>Action</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Ctrl / ⌘ + K</td><td>Focus search box</td></tr>
|
||||||
|
<tr><td>Ctrl / ⌘ + E</td><td>Toggle edit mode (ticket page)</td></tr>
|
||||||
|
<tr><td>Ctrl / ⌘ + S</td><td>Save changes (ticket page)</td></tr>
|
||||||
|
<tr><td>j / ↓</td><td>Select next row</td></tr>
|
||||||
|
<tr><td>k / ↑</td><td>Select previous row</td></tr>
|
||||||
|
<tr><td>Enter</td><td>Open selected ticket</td></tr>
|
||||||
|
<tr><td>n</td><td>New ticket</td></tr>
|
||||||
|
<tr><td>?</td><td>Show this help</td></tr>
|
||||||
|
<tr><td>ESC</td><td>Close modal / cancel</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn" data-modal-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===========================================================
|
||||||
|
SCRIPTS
|
||||||
|
=========================================================== -->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
PHP apps inject CSRF token + config here (with CSP nonce):
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
|
window.APP_TIMEZONE = '<?php echo $config['TIMEZONE']; ?>';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
Node/Express (EJS):
|
||||||
|
<script nonce="<%= nonce %>">
|
||||||
|
window.CSRF_TOKEN = '<%= csrfToken %>';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
Flask/Jinja2:
|
||||||
|
<script nonce="{{ nonce }}">
|
||||||
|
window.CSRF_TOKEN = '{{ csrf_token() }}';
|
||||||
|
</script>
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- Base design system JS -->
|
||||||
|
<script src="/web_template/base.js"></script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
App-specific JS:
|
||||||
|
<script nonce="NONCE" src="/assets/js/app.js?v=20260314"></script>
|
||||||
|
|
||||||
|
Wire up sortable table after load:
|
||||||
|
<script nonce="NONCE">
|
||||||
|
lt.sortTable.init('ticket-table');
|
||||||
|
lt.tableNav.init('ticket-table');
|
||||||
|
lt.keys.on('n', () => window.location.href = '/create');
|
||||||
|
</script>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
794
base.js
Normal file
794
base.js
Normal file
@@ -0,0 +1,794 @@
|
|||||||
|
/**
|
||||||
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.js
|
||||||
|
* Core JavaScript utilities shared across all LotusGuild applications
|
||||||
|
*
|
||||||
|
* Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask)
|
||||||
|
* Namespace: window.lt
|
||||||
|
*
|
||||||
|
* CONTENTS
|
||||||
|
* 1. HTML Escape
|
||||||
|
* 2. Toast Notifications
|
||||||
|
* 3. Terminal Audio (beep)
|
||||||
|
* 4. Modal Management
|
||||||
|
* 5. Tab Management
|
||||||
|
* 6. Boot Sequence Animation
|
||||||
|
* 7. Keyboard Shortcuts
|
||||||
|
* 8. Sidebar Collapse
|
||||||
|
* 9. CSRF Token Helpers
|
||||||
|
* 10. Fetch Helpers (JSON API wrapper)
|
||||||
|
* 11. Time Formatting
|
||||||
|
* 12. Bytes Formatting
|
||||||
|
* 13. Table Keyboard Navigation
|
||||||
|
* 14. Sortable Table Headers
|
||||||
|
* 15. Stats Widget Filtering
|
||||||
|
* 16. Auto-refresh Manager
|
||||||
|
* 17. Initialisation
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
1. HTML ESCAPE
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* Escape a value for safe insertion into innerHTML.
|
||||||
|
* Always prefer textContent/innerText when possible, but use this
|
||||||
|
* when you must build HTML strings (e.g. template literals for lists).
|
||||||
|
*
|
||||||
|
* @param {*} str
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function escHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
2. TOAST NOTIFICATIONS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.toast.success('Ticket saved');
|
||||||
|
lt.toast.error('Network error', 5000);
|
||||||
|
lt.toast.warning('Rate limit approaching');
|
||||||
|
lt.toast.info('Workflow started');
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
const _toastQueue = [];
|
||||||
|
let _toastActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
* @param {'success'|'error'|'warning'|'info'} type
|
||||||
|
* @param {number} [duration=3500] ms before auto-dismiss
|
||||||
|
*/
|
||||||
|
function showToast(message, type, duration) {
|
||||||
|
type = type || 'info';
|
||||||
|
duration = duration || 3500;
|
||||||
|
|
||||||
|
if (_toastActive) {
|
||||||
|
_toastQueue.push({ message, type, duration });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_displayToast(message, type, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _displayToast(message, type, duration) {
|
||||||
|
_toastActive = true;
|
||||||
|
|
||||||
|
let container = document.querySelector('.lt-toast-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.className = 'lt-toast-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = { success: '✓', error: '✗', warning: '!', info: 'i' };
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'lt-toast lt-toast-' + type;
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.setAttribute('aria-live', 'polite');
|
||||||
|
|
||||||
|
const iconEl = document.createElement('span');
|
||||||
|
iconEl.className = 'lt-toast-icon';
|
||||||
|
iconEl.textContent = '[' + (icons[type] || 'i') + ']';
|
||||||
|
|
||||||
|
const msgEl = document.createElement('span');
|
||||||
|
msgEl.className = 'lt-toast-msg';
|
||||||
|
msgEl.textContent = message;
|
||||||
|
|
||||||
|
const closeEl = document.createElement('button');
|
||||||
|
closeEl.className = 'lt-toast-close';
|
||||||
|
closeEl.textContent = '✕';
|
||||||
|
closeEl.setAttribute('aria-label', 'Dismiss');
|
||||||
|
closeEl.addEventListener('click', () => _dismissToast(toast));
|
||||||
|
|
||||||
|
toast.appendChild(iconEl);
|
||||||
|
toast.appendChild(msgEl);
|
||||||
|
toast.appendChild(closeEl);
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
/* Auto-dismiss */
|
||||||
|
const timer = setTimeout(() => _dismissToast(toast), duration);
|
||||||
|
toast._lt_timer = timer;
|
||||||
|
|
||||||
|
/* Optional audio feedback */
|
||||||
|
_beep(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dismissToast(toast) {
|
||||||
|
if (!toast || !toast.parentNode) return;
|
||||||
|
clearTimeout(toast._lt_timer);
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity 0.3s ease';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||||
|
_toastActive = false;
|
||||||
|
if (_toastQueue.length) {
|
||||||
|
const next = _toastQueue.shift();
|
||||||
|
_displayToast(next.message, next.type, next.duration);
|
||||||
|
}
|
||||||
|
}, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
success: (msg, dur) => showToast(msg, 'success', dur),
|
||||||
|
error: (msg, dur) => showToast(msg, 'error', dur),
|
||||||
|
warning: (msg, dur) => showToast(msg, 'warning', dur),
|
||||||
|
info: (msg, dur) => showToast(msg, 'info', dur),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
3. TERMINAL AUDIO
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.beep('success' | 'error' | 'info')
|
||||||
|
Silent-fails if Web Audio API is unavailable.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function _beep(type) {
|
||||||
|
try {
|
||||||
|
const ctx = new (global.AudioContext || global.webkitAudioContext)();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = type === 'success' ? 880
|
||||||
|
: type === 'error' ? 220
|
||||||
|
: 440;
|
||||||
|
osc.type = 'sine';
|
||||||
|
gain.gain.setValueAtTime(0.08, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.12);
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.12);
|
||||||
|
} catch (_) { /* silently fail */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
4. MODAL MANAGEMENT
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.modal.open('my-modal-id');
|
||||||
|
lt.modal.close('my-modal-id');
|
||||||
|
lt.modal.closeAll();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<div id="my-modal-id" class="lt-modal-overlay">
|
||||||
|
<div class="lt-modal">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title">Title</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close>✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">…</div>
|
||||||
|
<div class="lt-modal-footer">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function openModal(id) {
|
||||||
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.add('show');
|
||||||
|
el.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
/* Focus first focusable element */
|
||||||
|
const first = el.querySelector('button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (first) setTimeout(() => first.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
const el = typeof id === 'string' ? document.getElementById(id) : id;
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('show');
|
||||||
|
el.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllModals() {
|
||||||
|
document.querySelectorAll('.lt-modal-overlay.show').forEach(closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delegated close handlers */
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
/* Click on overlay backdrop (outside .lt-modal) */
|
||||||
|
if (e.target.classList.contains('lt-modal-overlay')) {
|
||||||
|
closeModal(e.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* [data-modal-close] button */
|
||||||
|
const closeBtn = e.target.closest('[data-modal-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
const overlay = closeBtn.closest('.lt-modal-overlay');
|
||||||
|
if (overlay) closeModal(overlay);
|
||||||
|
}
|
||||||
|
/* [data-modal-open="id"] trigger */
|
||||||
|
const openBtn = e.target.closest('[data-modal-open]');
|
||||||
|
if (openBtn) openModal(openBtn.dataset.modalOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = { open: openModal, close: closeModal, closeAll: closeAllModals };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
5. TAB MANAGEMENT
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.tabs.init(); // auto-wires all .lt-tab elements
|
||||||
|
lt.tabs.switch('tab-panel-id');
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<div class="lt-tabs">
|
||||||
|
<button class="lt-tab active" data-tab="panel-one">One</button>
|
||||||
|
<button class="lt-tab" data-tab="panel-two">Two</button>
|
||||||
|
</div>
|
||||||
|
<div id="panel-one" class="lt-tab-panel active">…</div>
|
||||||
|
<div id="panel-two" class="lt-tab-panel">…</div>
|
||||||
|
|
||||||
|
Persistence: localStorage key 'lt_activeTab_<page>'
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function switchTab(panelId) {
|
||||||
|
document.querySelectorAll('.lt-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.lt-tab-panel').forEach(p => p.classList.remove('active'));
|
||||||
|
|
||||||
|
const btn = document.querySelector('.lt-tab[data-tab="' + panelId + '"]');
|
||||||
|
const panel = document.getElementById(panelId);
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
if (panel) panel.classList.add('active');
|
||||||
|
|
||||||
|
try { localStorage.setItem('lt_activeTab_' + location.pathname, panelId); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
/* Restore from localStorage */
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('lt_activeTab_' + location.pathname);
|
||||||
|
if (saved && document.getElementById(saved)) { switchTab(saved); return; }
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
/* Wire click handlers */
|
||||||
|
document.querySelectorAll('.lt-tab[data-tab]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = { init: initTabs, switch: switchTab };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
6. BOOT SEQUENCE ANIMATION
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.boot.run('APP NAME'); // shows once per session
|
||||||
|
lt.boot.run('APP NAME', true); // force show even if already seen
|
||||||
|
|
||||||
|
HTML contract (add to <body>, hidden by default):
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function runBoot(appName, force) {
|
||||||
|
const storageKey = 'lt_booted_' + (appName || 'app');
|
||||||
|
if (!force && sessionStorage.getItem(storageKey)) return;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('lt-boot');
|
||||||
|
const pre = document.getElementById('lt-boot-text');
|
||||||
|
if (!overlay || !pre) return;
|
||||||
|
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
overlay.style.opacity = '1';
|
||||||
|
|
||||||
|
const name = (appName || 'TERMINAL').toUpperCase();
|
||||||
|
const bar = '═'.repeat(Math.max(0, 41 - name.length));
|
||||||
|
const pad = ' '.repeat(Math.max(0, Math.floor((39 - name.length) / 2)));
|
||||||
|
const messages = [
|
||||||
|
'╔═══════════════════════════════════════════╗',
|
||||||
|
'║' + pad + name + ' v1.0' + pad + '║',
|
||||||
|
'║ BOOTING SYSTEM... ║',
|
||||||
|
'╚═══════════════════════════════════════════╝',
|
||||||
|
'',
|
||||||
|
'[ OK ] Checking kernel modules...',
|
||||||
|
'[ OK ] Mounting filesystem...',
|
||||||
|
'[ OK ] Initializing database connection...',
|
||||||
|
'[ OK ] Loading user session...',
|
||||||
|
'[ OK ] Applying security headers...',
|
||||||
|
'[ OK ] Rendering terminal interface...',
|
||||||
|
'',
|
||||||
|
'> SYSTEM READY ✓',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
pre.textContent = '';
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (i < messages.length) {
|
||||||
|
pre.textContent += messages[i] + '\n';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
clearInterval(interval);
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.classList.add('fade-out');
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
overlay.classList.remove('fade-out');
|
||||||
|
}, 520);
|
||||||
|
}, 400);
|
||||||
|
sessionStorage.setItem(storageKey, '1');
|
||||||
|
}
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boot = { run: runBoot };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
7. KEYBOARD SHORTCUTS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Register handlers:
|
||||||
|
lt.keys.on('ctrl+k', () => searchBox.focus());
|
||||||
|
lt.keys.on('?', showHelpModal);
|
||||||
|
lt.keys.on('Escape', lt.modal.closeAll);
|
||||||
|
|
||||||
|
Built-in defaults (activate with lt.keys.initDefaults()):
|
||||||
|
ESC → close all modals
|
||||||
|
? → show #lt-keys-help modal if present
|
||||||
|
Ctrl/⌘+K → focus .lt-search-input
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
const _keyHandlers = {};
|
||||||
|
|
||||||
|
function normalizeKey(combo) {
|
||||||
|
return combo
|
||||||
|
.replace(/ctrl\+/i, 'ctrl+')
|
||||||
|
.replace(/cmd\+/i, 'ctrl+') /* treat Cmd as Ctrl */
|
||||||
|
.replace(/meta\+/i, 'ctrl+')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerKey(combo, handler) {
|
||||||
|
_keyHandlers[normalizeKey(combo)] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterKey(combo) {
|
||||||
|
delete _keyHandlers[normalizeKey(combo)];
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
const inInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
|
||||||
|
|| e.target.isContentEditable;
|
||||||
|
|
||||||
|
/* Build the combo string */
|
||||||
|
let combo = '';
|
||||||
|
if (e.ctrlKey || e.metaKey) combo += 'ctrl+';
|
||||||
|
if (e.altKey) combo += 'alt+';
|
||||||
|
if (e.shiftKey) combo += 'shift+';
|
||||||
|
combo += e.key.toLowerCase();
|
||||||
|
|
||||||
|
/* Always fire ESC, Ctrl combos regardless of input focus */
|
||||||
|
const alwaysFire = e.key === 'Escape' || e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
if (inInput && !alwaysFire) return;
|
||||||
|
|
||||||
|
const handler = _keyHandlers[combo];
|
||||||
|
if (handler) {
|
||||||
|
e.preventDefault();
|
||||||
|
handler(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initDefaultKeys() {
|
||||||
|
registerKey('Escape', closeAllModals);
|
||||||
|
registerKey('?', () => {
|
||||||
|
const helpModal = document.getElementById('lt-keys-help');
|
||||||
|
if (helpModal) openModal(helpModal);
|
||||||
|
});
|
||||||
|
registerKey('ctrl+k', () => {
|
||||||
|
const search = document.querySelector('.lt-search-input');
|
||||||
|
if (search) { search.focus(); search.select(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = {
|
||||||
|
on: registerKey,
|
||||||
|
off: unregisterKey,
|
||||||
|
initDefaults: initDefaultKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
8. SIDEBAR COLLAPSE
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.sidebar.init();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<aside class="lt-sidebar" id="lt-sidebar">
|
||||||
|
<div class="lt-sidebar-header">
|
||||||
|
Filters
|
||||||
|
<button class="lt-sidebar-toggle" data-sidebar-toggle="lt-sidebar">◀</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-sidebar-body">…</div>
|
||||||
|
</aside>
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initSidebar() {
|
||||||
|
document.querySelectorAll('[data-sidebar-toggle]').forEach(btn => {
|
||||||
|
const sidebar = document.getElementById(btn.dataset.sidebarToggle);
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
|
/* Restore state */
|
||||||
|
const collapsed = sessionStorage.getItem('lt_sidebar_' + btn.dataset.sidebarToggle) === '1';
|
||||||
|
if (collapsed) {
|
||||||
|
sidebar.classList.add('collapsed');
|
||||||
|
btn.textContent = '▶';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('collapsed');
|
||||||
|
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||||
|
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||||
|
try { sessionStorage.setItem('lt_sidebar_' + btn.dataset.sidebarToggle, isCollapsed ? '1' : '0'); } catch (_) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebar = { init: initSidebar };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
9. CSRF TOKEN HELPERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
PHP apps: window.CSRF_TOKEN is set by the view via:
|
||||||
|
<script nonce="...">window.CSRF_TOKEN = '<?= CsrfMiddleware::getToken() ?>';</script>
|
||||||
|
Node apps: set via: window.CSRF_TOKEN = '<%= csrfToken %>';
|
||||||
|
Flask: use Flask-WTF meta tag or inject via template.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
const headers = lt.csrf.headers();
|
||||||
|
fetch('/api/foo', { method: 'POST', headers: lt.csrf.headers(), body: … });
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function csrfHeaders() {
|
||||||
|
const token = global.CSRF_TOKEN || '';
|
||||||
|
return token ? { 'X-CSRF-Token': token } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrf = { headers: csrfHeaders };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
10. FETCH HELPERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
const data = await lt.api.get('/api/tickets');
|
||||||
|
const res = await lt.api.post('/api/update_ticket.php', { id: 1, status: 'Closed' });
|
||||||
|
const res = await lt.api.delete('/api/ticket_dependencies.php', { id: 5 });
|
||||||
|
|
||||||
|
All methods:
|
||||||
|
- Automatically set Content-Type: application/json
|
||||||
|
- Attach CSRF token header
|
||||||
|
- Parse JSON response
|
||||||
|
- On non-2xx: throw an Error with the server's error message
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
async function apiFetch(method, url, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: Object.assign(
|
||||||
|
{ 'Content-Type': 'application/json' },
|
||||||
|
csrfHeaders()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, opts);
|
||||||
|
} catch (networkErr) {
|
||||||
|
throw new Error('Network error: ' + networkErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await resp.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = { success: resp.ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || data.message || 'HTTP ' + resp.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
get: (url) => apiFetch('GET', url),
|
||||||
|
post: (url, body) => apiFetch('POST', url, body),
|
||||||
|
put: (url, body) => apiFetch('PUT', url, body),
|
||||||
|
patch: (url, body) => apiFetch('PATCH', url, body),
|
||||||
|
delete: (url, body) => apiFetch('DELETE', url, body),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
11. TIME FORMATTING
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* Returns a human-readable relative time string.
|
||||||
|
* @param {string|number|Date} value ISO string, Unix ms, or Date
|
||||||
|
* @returns {string} e.g. "5m ago", "2h ago", "3d ago"
|
||||||
|
*/
|
||||||
|
function timeAgo(value) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(date)) return '—';
|
||||||
|
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
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 Math.floor(diff / 86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds → "1h 23m 45s" style.
|
||||||
|
* @param {number} secs
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatUptime(secs) {
|
||||||
|
secs = Math.floor(secs);
|
||||||
|
const d = Math.floor(secs / 86400);
|
||||||
|
const h = Math.floor((secs % 86400) / 3600);
|
||||||
|
const m = Math.floor((secs % 3600) / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
const parts = [];
|
||||||
|
if (d) parts.push(d + 'd');
|
||||||
|
if (h) parts.push(h + 'h');
|
||||||
|
if (m) parts.push(m + 'm');
|
||||||
|
if (!d) parts.push(s + 's');
|
||||||
|
return parts.join(' ') || '0s';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO datetime string for display.
|
||||||
|
* Uses the timezone configured in window.APP_TIMEZONE (PHP apps)
|
||||||
|
* or falls back to the browser locale.
|
||||||
|
*/
|
||||||
|
function formatDate(value) {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (isNaN(date)) return '—';
|
||||||
|
const tz = global.APP_TIMEZONE || undefined;
|
||||||
|
try {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = { ago: timeAgo, uptime: formatUptime, format: formatDate };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
12. BYTES FORMATTING
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
/**
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string} e.g. "1.23 GB"
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === null || bytes === undefined) return '—';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
||||||
|
return bytes.toFixed(i === 0 ? 0 : 2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
13. TABLE KEYBOARD NAVIGATION (vim-style j/k)
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.tableNav.init('my-table-id');
|
||||||
|
|
||||||
|
Keys registered:
|
||||||
|
j or ArrowDown → move selection down
|
||||||
|
k or ArrowUp → move selection up
|
||||||
|
Enter → follow first <a> in selected row
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initTableNav(tableId) {
|
||||||
|
const table = tableId ? document.getElementById(tableId) : document.querySelector('.lt-table');
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
function rows() { return Array.from(table.querySelectorAll('tbody tr')); }
|
||||||
|
function selected() { return table.querySelector('tbody tr.lt-row-selected'); }
|
||||||
|
|
||||||
|
function move(dir) {
|
||||||
|
const all = rows();
|
||||||
|
if (!all.length) return;
|
||||||
|
const cur = selected();
|
||||||
|
const idx = cur ? all.indexOf(cur) : -1;
|
||||||
|
const next = dir === 'down'
|
||||||
|
? all[idx < all.length - 1 ? idx + 1 : 0]
|
||||||
|
: all[idx > 0 ? idx - 1 : all.length - 1];
|
||||||
|
if (cur) cur.classList.remove('lt-row-selected');
|
||||||
|
next.classList.add('lt-row-selected');
|
||||||
|
next.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.on('j', () => move('down'));
|
||||||
|
keys.on('ArrowDown', () => move('down'));
|
||||||
|
keys.on('k', () => move('up'));
|
||||||
|
keys.on('ArrowUp', () => move('up'));
|
||||||
|
keys.on('Enter', () => {
|
||||||
|
const row = selected();
|
||||||
|
if (!row) return;
|
||||||
|
const link = row.querySelector('a[href]');
|
||||||
|
if (link) global.location.href = link.href;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableNav = { init: initTableNav };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
14. SORTABLE TABLE HEADERS
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.sortTable.init('my-table-id');
|
||||||
|
|
||||||
|
Markup: add data-sort-key="field" to <th> elements.
|
||||||
|
Sorts rows client-side by the text content of the matching column.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initSortTable(tableId) {
|
||||||
|
const table = document.getElementById(tableId);
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const ths = Array.from(table.querySelectorAll('th[data-sort-key]'));
|
||||||
|
ths.forEach((th, colIdx) => {
|
||||||
|
th.style.cursor = 'pointer';
|
||||||
|
let dir = 'asc';
|
||||||
|
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
/* Reset all headers */
|
||||||
|
ths.forEach(h => h.removeAttribute('data-sort'));
|
||||||
|
th.setAttribute('data-sort', dir);
|
||||||
|
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aText = (a.cells[colIdx] || {}).textContent || '';
|
||||||
|
const bText = (b.cells[colIdx] || {}).textContent || '';
|
||||||
|
const n = !isNaN(parseFloat(aText)) && !isNaN(parseFloat(bText));
|
||||||
|
const cmp = n
|
||||||
|
? parseFloat(aText) - parseFloat(bText)
|
||||||
|
: aText.localeCompare(bText);
|
||||||
|
return dir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach(r => tbody.appendChild(r));
|
||||||
|
dir = dir === 'asc' ? 'desc' : 'asc';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortTable = { init: initSortTable };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
15. STATS WIDGET FILTERING
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage: lt.statsFilter.init();
|
||||||
|
|
||||||
|
HTML contract:
|
||||||
|
<div class="lt-stat-card" data-filter-key="status" data-filter-val="Open">…</div>
|
||||||
|
<!-- clicking the card adds ?filter=status:Open to the URL and
|
||||||
|
calls the optional window.lt_onStatFilter(key, val) hook -->
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function initStatsFilter() {
|
||||||
|
document.querySelectorAll('.lt-stat-card[data-filter-key]').forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const key = card.dataset.filterKey;
|
||||||
|
const val = card.dataset.filterVal;
|
||||||
|
|
||||||
|
/* Toggle active state */
|
||||||
|
const wasActive = card.classList.contains('active');
|
||||||
|
document.querySelectorAll('.lt-stat-card').forEach(c => c.classList.remove('active'));
|
||||||
|
if (!wasActive) card.classList.add('active');
|
||||||
|
|
||||||
|
/* Call app-specific filter hook if defined */
|
||||||
|
if (typeof global.lt_onStatFilter === 'function') {
|
||||||
|
global.lt_onStatFilter(wasActive ? null : key, wasActive ? null : val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statsFilter = { init: initStatsFilter };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
16. AUTO-REFRESH MANAGER
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Usage:
|
||||||
|
lt.autoRefresh.start(refreshFn, 30000); // every 30 s
|
||||||
|
lt.autoRefresh.stop();
|
||||||
|
lt.autoRefresh.now(); // trigger immediately + restart timer
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
let _arTimer = null;
|
||||||
|
let _arFn = null;
|
||||||
|
let _arInterval = 30000;
|
||||||
|
|
||||||
|
function arStart(fn, intervalMs) {
|
||||||
|
arStop();
|
||||||
|
_arFn = fn;
|
||||||
|
_arInterval = intervalMs || 30000;
|
||||||
|
_arTimer = setInterval(_arFn, _arInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arStop() {
|
||||||
|
if (_arTimer) { clearInterval(_arTimer); _arTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function arNow() {
|
||||||
|
arStop();
|
||||||
|
if (_arFn) {
|
||||||
|
_arFn();
|
||||||
|
_arTimer = setInterval(_arFn, _arInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoRefresh = { start: arStart, stop: arStop, now: arNow };
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
17. INITIALISATION
|
||||||
|
----------------------------------------------------------------
|
||||||
|
Called automatically on DOMContentLoaded.
|
||||||
|
Each sub-system can also be initialised manually after the DOM
|
||||||
|
has been updated with AJAX content.
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
function init() {
|
||||||
|
initTabs();
|
||||||
|
initSidebar();
|
||||||
|
initDefaultKeys();
|
||||||
|
initStatsFilter();
|
||||||
|
|
||||||
|
/* Boot sequence: runs if #lt-boot element is present */
|
||||||
|
const bootEl = document.getElementById('lt-boot');
|
||||||
|
if (bootEl) {
|
||||||
|
const appName = bootEl.dataset.appName || document.title;
|
||||||
|
runBoot(appName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Public API
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
global.lt = {
|
||||||
|
escHtml,
|
||||||
|
toast,
|
||||||
|
beep: _beep,
|
||||||
|
modal,
|
||||||
|
tabs,
|
||||||
|
boot,
|
||||||
|
keys,
|
||||||
|
sidebar,
|
||||||
|
csrf,
|
||||||
|
api,
|
||||||
|
time,
|
||||||
|
bytes: { format: formatBytes },
|
||||||
|
tableNav,
|
||||||
|
sortTable,
|
||||||
|
statsFilter,
|
||||||
|
autoRefresh,
|
||||||
|
};
|
||||||
|
|
||||||
|
}(window));
|
||||||
243
node/middleware.js
Normal file
243
node/middleware.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM — Node.js / Express Middleware
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - requireAuth Authelia SSO header validation
|
||||||
|
* - requireAdmin Admin group enforcement
|
||||||
|
* - csrfMiddleware CSRF token generation + validation
|
||||||
|
* - cspNonce CSP nonce injection (for use with helmet)
|
||||||
|
* - injectLocals Template locals (user, nonce, config)
|
||||||
|
*
|
||||||
|
* Usage in server.js / app.js:
|
||||||
|
*
|
||||||
|
* const { requireAuth, requireAdmin, cspNonce, injectLocals } = require('./middleware');
|
||||||
|
*
|
||||||
|
* // Apply globally
|
||||||
|
* app.use(cspNonce);
|
||||||
|
* app.use(requireAuth);
|
||||||
|
* app.use(injectLocals);
|
||||||
|
*
|
||||||
|
* // Apply per-route
|
||||||
|
* app.get('/admin', requireAdmin, (req, res) => res.render('admin'));
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Auth — Authelia SSO header parsing
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Authelia remote headers from the request.
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {{ username, name, email, groups, isAdmin }}
|
||||||
|
*/
|
||||||
|
function parseUser(req) {
|
||||||
|
const groupsRaw = req.headers['remote-groups'] || '';
|
||||||
|
const groups = groupsRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
return {
|
||||||
|
username: req.headers['remote-user'] || '',
|
||||||
|
name: req.headers['remote-name'] || '',
|
||||||
|
email: req.headers['remote-email'] || '',
|
||||||
|
groups,
|
||||||
|
isAdmin: groups.includes('admin'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: require authenticated Authelia user.
|
||||||
|
* Allowed groups are read from process.env.ALLOWED_GROUPS (comma-separated),
|
||||||
|
* defaulting to 'admin'.
|
||||||
|
*/
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const user = parseUser(req);
|
||||||
|
if (!user.username) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated — access via Authelia SSO' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = (process.env.ALLOWED_GROUPS || 'admin').split(',').map(s => s.trim());
|
||||||
|
if (!user.groups.some(g => allowed.includes(g))) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Access denied — ${user.username} is not in an allowed group (${allowed.join(', ')})`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: require 'admin' group specifically.
|
||||||
|
* Must be used after requireAuth (or inline it calls parseUser again — cheap).
|
||||||
|
*/
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const user = req.user || parseUser(req);
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
CSRF — double-submit cookie pattern
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: generate and validate CSRF tokens.
|
||||||
|
*
|
||||||
|
* GET/HEAD/OPTIONS → generate a token, store in session, expose on res.locals.
|
||||||
|
* POST/PUT/PATCH/DELETE → validate X-CSRF-Token header against session token.
|
||||||
|
*
|
||||||
|
* Requires express-session to be configured before this middleware.
|
||||||
|
*/
|
||||||
|
function csrfMiddleware(req, res, next) {
|
||||||
|
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
|
||||||
|
|
||||||
|
if (safeMethods.includes(req.method)) {
|
||||||
|
// Generate token if not already set
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
res.locals.csrfToken = req.session.csrfToken;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate on mutating methods
|
||||||
|
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || '';
|
||||||
|
const expected = req.session.csrfToken || '';
|
||||||
|
|
||||||
|
// Constant-time comparison to prevent timing attacks
|
||||||
|
if (!expected || !timingSafeEqual(token, expected)) {
|
||||||
|
return res.status(403).json({ error: 'CSRF token invalid or missing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.csrfToken = expected;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(a, b) {
|
||||||
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
try {
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
CSP Nonce — generate per-request nonce for Content-Security-Policy
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: generate a CSP nonce and attach to res.locals.
|
||||||
|
* Use with helmet's contentSecurityPolicy:
|
||||||
|
*
|
||||||
|
* app.use(helmet({
|
||||||
|
* contentSecurityPolicy: {
|
||||||
|
* directives: {
|
||||||
|
* scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }));
|
||||||
|
*
|
||||||
|
* // In EJS templates: <script nonce="<%= nonce %>">
|
||||||
|
*/
|
||||||
|
function cspNonce(req, res, next) {
|
||||||
|
res.locals.nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Template locals injector
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: inject common locals into every rendered template.
|
||||||
|
* Must be applied after requireAuth (needs req.user).
|
||||||
|
*
|
||||||
|
* Injects:
|
||||||
|
* user → current user object
|
||||||
|
* nonce → CSP nonce (if cspNonce ran first)
|
||||||
|
* csrfToken → CSRF token (if csrfMiddleware ran first)
|
||||||
|
* appName → process.env.APP_NAME or 'PULSE'
|
||||||
|
* appSubtitle → process.env.APP_SUBTITLE or 'LotusGuild Infrastructure'
|
||||||
|
*/
|
||||||
|
function injectLocals(req, res, next) {
|
||||||
|
res.locals.user = req.user || { username: '', name: '', groups: [], isAdmin: false };
|
||||||
|
res.locals.appName = process.env.APP_NAME || 'PULSE';
|
||||||
|
res.locals.appSubtitle = process.env.APP_SUBTITLE || 'LotusGuild Infrastructure';
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
Rate Limiting helper (simple in-memory, use express-rate-limit
|
||||||
|
for production)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: create a simple per-IP rate limiter.
|
||||||
|
* For production use express-rate-limit with a Redis store instead.
|
||||||
|
*
|
||||||
|
* @param {{ windowMs: number, max: number, message: string }} opts
|
||||||
|
*/
|
||||||
|
function createRateLimit(opts) {
|
||||||
|
const { windowMs = 60_000, max = 60, message = 'Rate limit exceeded' } = opts || {};
|
||||||
|
const hits = new Map();
|
||||||
|
|
||||||
|
return function rateLimit(req, res, next) {
|
||||||
|
const key = req.ip || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = hits.get(key) || { count: 0, reset: now + windowMs };
|
||||||
|
|
||||||
|
if (now > entry.reset) {
|
||||||
|
entry.count = 0;
|
||||||
|
entry.reset = now + windowMs;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
hits.set(key, entry);
|
||||||
|
|
||||||
|
if (entry.count > max) {
|
||||||
|
return res.status(429).json({ error: message });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------
|
||||||
|
API Key auth (for worker/external API access)
|
||||||
|
---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: validate Bearer token against WORKER_API_KEY env var.
|
||||||
|
* Used on WebSocket upgrade and worker API endpoints.
|
||||||
|
*/
|
||||||
|
function requireApiKey(req, res, next) {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const [, token] = auth.split(' ');
|
||||||
|
const expected = process.env.WORKER_API_KEY || '';
|
||||||
|
|
||||||
|
if (!expected) {
|
||||||
|
console.warn('[WARN] WORKER_API_KEY not set — API key auth disabled');
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !timingSafeEqual(token, expected)) {
|
||||||
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseUser,
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
csrfMiddleware,
|
||||||
|
cspNonce,
|
||||||
|
injectLocals,
|
||||||
|
createRateLimit,
|
||||||
|
requireApiKey,
|
||||||
|
};
|
||||||
154
php/layout.php
Normal file
154
php/layout.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* LOTUSGUILD TERMINAL DESIGN SYSTEM — PHP Base Layout
|
||||||
|
* Copy this into your PHP app as the base view template.
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - middleware/SecurityHeadersMiddleware.php (provides $nonce)
|
||||||
|
* - middleware/CsrfMiddleware.php (provides CSRF token)
|
||||||
|
* - middleware/AuthMiddleware.php (provides $currentUser)
|
||||||
|
* - config/config.php (provides $config)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Call SecurityHeadersMiddleware::apply() before any output.
|
||||||
|
* Then include this file (or extend it) in your view.
|
||||||
|
*
|
||||||
|
* Variables expected:
|
||||||
|
* $nonce string CSP nonce from SecurityHeadersMiddleware::getNonce()
|
||||||
|
* $currentUser array ['username', 'name', 'is_admin', 'groups']
|
||||||
|
* $pageTitle string Page <title> suffix
|
||||||
|
* $activeNav string Which nav link is active ('dashboard','tickets',etc.)
|
||||||
|
* $config array From config/config.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Defensive defaults
|
||||||
|
$nonce = $nonce ?? '';
|
||||||
|
$currentUser = $currentUser ?? [];
|
||||||
|
$pageTitle = $pageTitle ?? 'Dashboard';
|
||||||
|
$activeNav = $activeNav ?? '';
|
||||||
|
$config = $config ?? [];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo htmlspecialchars($pageTitle); ?> — <?php echo htmlspecialchars($config['APP_NAME'] ?? 'LotusGuild'); ?></title>
|
||||||
|
<meta name="description" content="LotusGuild infrastructure management">
|
||||||
|
|
||||||
|
<!-- Unified design system CSS -->
|
||||||
|
<link rel="stylesheet" href="/web_template/base.css">
|
||||||
|
<!-- App-specific CSS (extends base, never overrides variables without good reason) -->
|
||||||
|
<link rel="stylesheet" href="/assets/css/app.css?v=<?php echo $config['CSS_VERSION'] ?? '20260314'; ?>">
|
||||||
|
|
||||||
|
<link rel="icon" href="/assets/images/favicon.png" type="image/png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Boot overlay — runs once per session via lt.boot.run() -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay"
|
||||||
|
data-app-name="<?php echo htmlspecialchars($config['APP_NAME'] ?? 'APP'); ?>"
|
||||||
|
style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
HEADER
|
||||||
|
========================================================= -->
|
||||||
|
<header class="lt-header">
|
||||||
|
<div class="lt-header-left">
|
||||||
|
|
||||||
|
<div class="lt-brand">
|
||||||
|
<a href="/" class="lt-brand-title" style="text-decoration:none">
|
||||||
|
<?php echo htmlspecialchars($config['APP_NAME'] ?? 'APP'); ?>
|
||||||
|
</a>
|
||||||
|
<span class="lt-brand-subtitle"><?php echo htmlspecialchars($config['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure'); ?></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="lt-nav" aria-label="Main navigation">
|
||||||
|
<a href="/" class="lt-nav-link <?php echo $activeNav === 'dashboard' ? 'active' : ''; ?>">Dashboard</a>
|
||||||
|
<a href="/tickets" class="lt-nav-link <?php echo $activeNav === 'tickets' ? 'active' : ''; ?>">Tickets</a>
|
||||||
|
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<div class="lt-nav-dropdown">
|
||||||
|
<a href="#" class="lt-nav-link <?php echo str_starts_with($activeNav, 'admin') ? 'active' : ''; ?>">
|
||||||
|
Admin ▾
|
||||||
|
</a>
|
||||||
|
<ul class="lt-nav-dropdown-menu">
|
||||||
|
<li><a href="/admin/templates">Templates</a></li>
|
||||||
|
<li><a href="/admin/workflow">Workflow</a></li>
|
||||||
|
<li><a href="/admin/recurring-tickets">Recurring</a></li>
|
||||||
|
<li><a href="/admin/custom-fields">Custom Fields</a></li>
|
||||||
|
<li><a href="/admin/user-activity">User Activity</a></li>
|
||||||
|
<li><a href="/admin/audit-log">Audit Log</a></li>
|
||||||
|
<li><a href="/admin/api-keys">API Keys</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-header-right">
|
||||||
|
<?php if (!empty($currentUser['name'])): ?>
|
||||||
|
<span class="lt-header-user"><?php echo htmlspecialchars($currentUser['name']); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($isAdmin): ?>
|
||||||
|
<span class="lt-badge-admin">admin</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
MAIN CONTENT — provided by the including view
|
||||||
|
========================================================= -->
|
||||||
|
<main class="lt-main lt-container">
|
||||||
|
|
||||||
|
<!-- CONTENT SLOT: the including view outputs its content here -->
|
||||||
|
<?php
|
||||||
|
// If using output buffering pattern:
|
||||||
|
// ob_start(); include 'views/DashboardView.php'; $content = ob_get_clean();
|
||||||
|
// echo $content;
|
||||||
|
//
|
||||||
|
// Or simply include views inline after including this layout.
|
||||||
|
?>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
SCRIPTS
|
||||||
|
All <script> tags MUST include the CSP nonce attribute.
|
||||||
|
========================================================= -->
|
||||||
|
|
||||||
|
<!-- Inject runtime config + CSRF token (nonce required for CSP) -->
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
|
||||||
|
window.APP_TIMEZONE = '<?php echo htmlspecialchars($config['TIMEZONE'] ?? 'UTC'); ?>';
|
||||||
|
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($config['TIMEZONE_ABBREV'] ?? 'UTC'); ?>';
|
||||||
|
window.CURRENT_USER = {
|
||||||
|
id: <?php echo (int)($currentUser['user_id'] ?? 0); ?>,
|
||||||
|
username: <?php echo json_encode($currentUser['username'] ?? ''); ?>,
|
||||||
|
isAdmin: <?php echo $isAdmin ? 'true' : 'false'; ?>,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Unified design system JS -->
|
||||||
|
<script nonce="<?php echo $nonce; ?>" src="/web_template/base.js"></script>
|
||||||
|
|
||||||
|
<!-- App-specific JS (cache-busted) -->
|
||||||
|
<script nonce="<?php echo $nonce; ?>"
|
||||||
|
src="/assets/js/app.js?v=<?php echo $config['JS_VERSION'] ?? '20260314'; ?>">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Per-page inline JS goes here in the including view, e.g.: -->
|
||||||
|
<!--
|
||||||
|
<script nonce="<?php echo $nonce; ?>">
|
||||||
|
lt.sortTable.init('ticket-table');
|
||||||
|
lt.tableNav.init('ticket-table');
|
||||||
|
lt.keys.on('n', () => window.location.href = '/ticket/create');
|
||||||
|
lt.autoRefresh.start(() => fetch('/api/status').then(r=>r.json()).then(updateUI), 30000);
|
||||||
|
</script>
|
||||||
|
-->
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
python/auth.py
Normal file
92
python/auth.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask Auth Helpers
|
||||||
|
Provides Authelia SSO integration via remote headers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from auth import require_auth, get_user
|
||||||
|
|
||||||
|
@app.route('/my-page')
|
||||||
|
@require_auth
|
||||||
|
def my_page():
|
||||||
|
user = get_user()
|
||||||
|
return render_template('page.html', user=user)
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request, g
|
||||||
|
|
||||||
|
|
||||||
|
def get_user() -> dict:
|
||||||
|
"""
|
||||||
|
Parse Authelia SSO headers into a normalised user dict.
|
||||||
|
Authelia injects these headers after validating the session:
|
||||||
|
Remote-User → username / login name
|
||||||
|
Remote-Name → display name
|
||||||
|
Remote-Email → email address
|
||||||
|
Remote-Groups → comma-separated group list
|
||||||
|
"""
|
||||||
|
groups_raw = request.headers.get('Remote-Groups', '')
|
||||||
|
groups = [g.strip() for g in groups_raw.split(',') if g.strip()]
|
||||||
|
return {
|
||||||
|
'username': request.headers.get('Remote-User', ''),
|
||||||
|
'name': request.headers.get('Remote-Name', ''),
|
||||||
|
'email': request.headers.get('Remote-Email', ''),
|
||||||
|
'groups': groups,
|
||||||
|
'is_admin': 'admin' in groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(f):
|
||||||
|
"""
|
||||||
|
Decorator that enforces authentication + group-based authorisation.
|
||||||
|
Reads allowed_groups from app config (key: 'auth.allowed_groups').
|
||||||
|
Falls back to ['admin'] if not configured.
|
||||||
|
|
||||||
|
Returns 401 if no user header present (request bypassed Authelia).
|
||||||
|
Returns 403 if user is not in an allowed group.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
from flask import current_app
|
||||||
|
user = get_user()
|
||||||
|
|
||||||
|
if not user['username']:
|
||||||
|
return (
|
||||||
|
'<h1 style="font-family:monospace;color:#ff4444">401 — Not Authenticated</h1>'
|
||||||
|
'<p style="font-family:monospace">Access this service through Authelia SSO.</p>',
|
||||||
|
401,
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = current_app.config.get('APP_CONFIG', {})
|
||||||
|
allowed = cfg.get('auth', {}).get('allowed_groups', ['admin'])
|
||||||
|
|
||||||
|
if not any(grp in allowed for grp in user['groups']):
|
||||||
|
return (
|
||||||
|
f'<h1 style="font-family:monospace;color:#ff4444">403 — Access Denied</h1>'
|
||||||
|
f'<p style="font-family:monospace">'
|
||||||
|
f'Account <strong>{user["username"]}</strong> is not in an allowed group '
|
||||||
|
f'({", ".join(allowed)}).</p>',
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store user on Flask's request context for convenience
|
||||||
|
g.user = user
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(f):
|
||||||
|
"""
|
||||||
|
Stricter decorator — requires the 'admin' group regardless of config.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
@require_auth
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = get_user()
|
||||||
|
if not user['is_admin']:
|
||||||
|
return (
|
||||||
|
'<h1 style="font-family:monospace;color:#ff4444">403 — Admin Required</h1>',
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
141
python/base.html
Normal file
141
python/base.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{#
|
||||||
|
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask/Jinja2 Base Template
|
||||||
|
Extend this in every page template:
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block active_nav %}dashboard{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
… your page HTML …
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script nonce="{{ nonce }}">
|
||||||
|
lt.sortTable.init('my-table');
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
Required Flask setup (app.py):
|
||||||
|
- Pass `nonce` into every render_template() call via a context processor
|
||||||
|
- Pass `user` dict from _get_user() helper
|
||||||
|
- Pass `config` dict with APP_NAME, etc.
|
||||||
|
|
||||||
|
Context processor example:
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
import base64, os
|
||||||
|
nonce = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
return dict(nonce=nonce, user=_get_user(), config=_config())
|
||||||
|
#}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Dashboard{% endblock %} — {{ config.get('app_name', 'LotusGuild') }}</title>
|
||||||
|
<meta name="description" content="LotusGuild infrastructure management">
|
||||||
|
|
||||||
|
<!-- Unified design system CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='../web_template/base.css') }}">
|
||||||
|
<!-- App-specific CSS -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
|
||||||
|
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Boot overlay -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay"
|
||||||
|
data-app-name="{{ config.get('app_name', 'APP') | upper }}"
|
||||||
|
style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
HEADER
|
||||||
|
========================================================= -->
|
||||||
|
<header class="lt-header">
|
||||||
|
<div class="lt-header-left">
|
||||||
|
|
||||||
|
<div class="lt-brand">
|
||||||
|
<a href="{{ url_for('index') }}" class="lt-brand-title" style="text-decoration:none">
|
||||||
|
{{ config.get('app_name', 'APP') | upper }}
|
||||||
|
</a>
|
||||||
|
<span class="lt-brand-subtitle">{{ config.get('app_subtitle', 'LotusGuild Infrastructure') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="lt-nav" aria-label="Main navigation">
|
||||||
|
{# Each page sets {% block active_nav %}pagename{% endblock %} #}
|
||||||
|
{% set active = self.active_nav() | default('') %}
|
||||||
|
<a href="{{ url_for('index') }}"
|
||||||
|
class="lt-nav-link {% if active == 'dashboard' %}active{% endif %}">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('links_page') }}"
|
||||||
|
class="lt-nav-link {% if active == 'links' %}active{% endif %}">
|
||||||
|
Link Debug
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('inspector') }}"
|
||||||
|
class="lt-nav-link {% if active == 'inspector' %}active{% endif %}">
|
||||||
|
Inspector
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('suppressions_page') }}"
|
||||||
|
class="lt-nav-link {% if active == 'suppressions' %}active{% endif %}">
|
||||||
|
Suppressions
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lt-header-right">
|
||||||
|
{% if user.name or user.username %}
|
||||||
|
<span class="lt-header-user">{{ user.name or user.username }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'admin' in user.groups %}
|
||||||
|
<span class="lt-badge-admin">admin</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
MAIN CONTENT
|
||||||
|
========================================================= -->
|
||||||
|
<main class="lt-main lt-container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- =========================================================
|
||||||
|
SCRIPTS
|
||||||
|
All <script> tags MUST carry the nonce attribute for CSP.
|
||||||
|
========================================================= -->
|
||||||
|
|
||||||
|
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) -->
|
||||||
|
<script nonce="{{ nonce }}">
|
||||||
|
window.APP_CONFIG = {
|
||||||
|
ticketWebUrl: {{ config.get('ticket_api', {}).get('web_url', 'https://t.lotusguild.org/ticket/') | tojson }},
|
||||||
|
};
|
||||||
|
window.CURRENT_USER = {
|
||||||
|
username: {{ user.username | tojson }},
|
||||||
|
name: {{ (user.name or user.username) | tojson }},
|
||||||
|
groups: {{ user.groups | tojson }},
|
||||||
|
isAdmin: {{ ('admin' in user.groups) | lower }},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Unified design system JS -->
|
||||||
|
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='../web_template/base.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- App JS -->
|
||||||
|
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
{# ---------------------------------------------------------------
|
||||||
|
active_nav block — override in each page template:
|
||||||
|
|
||||||
|
{% block active_nav %}dashboard{% endblock %}
|
||||||
|
|
||||||
|
Values: dashboard | links | inspector | suppressions
|
||||||
|
--------------------------------------------------------------- #}
|
||||||
|
{% block active_nav %}{% endblock %}
|
||||||
Reference in New Issue
Block a user