From 4a838b68ca876fc5050aa6fcd446e5f02f066ebc Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 17 Mar 2026 23:44:46 -0400 Subject: [PATCH] Move base.js/base.css into assets to fix auth proxy 404 /web_template/ path was being intercepted by the auth proxy at t.lotusguild.org returning HTML instead of the actual files. Moving base.js and base.css into /assets/js/ and /assets/css/ where static assets are already served correctly. Updated all 10 view files and deploy.sh accordingly. Co-Authored-By: Claude Sonnet 4.6 --- assets/css/base.css | 1729 ++++++++++++++++++++++++++ assets/js/base.js | 796 ++++++++++++ deploy.sh | 8 +- views/CreateTicketView.php | 4 +- views/DashboardView.php | 4 +- views/TicketView.php | 4 +- views/admin/ApiKeysView.php | 4 +- views/admin/AuditLogView.php | 4 +- views/admin/CustomFieldsView.php | 4 +- views/admin/RecurringTicketsView.php | 4 +- views/admin/TemplatesView.php | 4 +- views/admin/UserActivityView.php | 4 +- views/admin/WorkflowDesignerView.php | 4 +- 13 files changed, 2549 insertions(+), 24 deletions(-) create mode 100644 assets/css/base.css create mode 100644 assets/js/base.js diff --git a/assets/css/base.css b/assets/css/base.css new file mode 100644 index 0000000..c594a9b --- /dev/null +++ b/assets/css/base.css @@ -0,0 +1,1729 @@ +/* ================================================================ + LOTUSGUILD TERMINAL DESIGN SYSTEM v1.0 — base.css + Unified CSS for all LotusGuild web applications + + Apps: Tinker Tickets (PHP), PULSE (Node.js), GANDALF (Flask) + Aesthetic: Retro CRT terminal, phosphor green, ASCII art borders + Prefix: .lt-* (LotusGuild Terminal) + + TABLE OF CONTENTS + 01. CSS Custom Properties + 02. Reset & Base + 03. CRT & Terminal Effects + 04. Typography + 05. Layout Primitives + 06. Header & Navigation + 07. ASCII Frame System + 08. Card Component + 09. Buttons + 10. Forms & Inputs + 11. Tables + 12. Status Badges, Chips, Priority + 13. Modals + 14. Toast Notifications + 15. Tab Navigation + 16. Sidebar / Filter Panel + 17. Stats Widgets + 18. Inline Messages + 19. Loading & Empty States + 20. Boot Sequence Overlay + 21. Log / Timeline Entries + 22. Animations (keyframes) + 23. Responsive Design + 24. Utility Classes + 25. Print Styles + 26. Accessibility + ================================================================ */ + +/* ---------------------------------------------------------------- + 01. CSS CUSTOM PROPERTIES + ---------------------------------------------------------------- */ +:root { + /* --- Backgrounds --- */ + --bg-primary: #0a0a0a; /* Main background: near-black */ + --bg-secondary: #1a1a1a; /* Surface: dark grey */ + --bg-tertiary: #2a2a2a; /* Raised surface / hover states */ + --bg-terminal: #001a00; /* Deep green-tinted black for terminal panels */ + + /* --- Terminal Green (Primary accent) --- */ + --terminal-green: #00ff41; + --terminal-green-dim: rgba(0, 255, 65, 0.15); + --terminal-green-dark: #00cc33; + --terminal-green-muted: #008822; + + /* --- Amber (Secondary / highlight / headings) --- */ + --terminal-amber: #ffb000; + --terminal-amber-dim: rgba(255, 176, 0, 0.15); + + /* --- Cyan (Tertiary / uplinks / info) --- */ + --terminal-cyan: #00ffff; + --terminal-cyan-dim: rgba(0, 255, 255, 0.12); + + /* --- Red (Error / critical / closed) --- */ + --terminal-red: #ff4444; + --terminal-red-dim: rgba(255, 68, 68, 0.15); + + /* --- Orange (Warning) --- */ + --terminal-orange: #ff8c00; + --terminal-orange-dim: rgba(255, 140, 0, 0.15); + + /* --- Text --- */ + --text-primary: #00ff41; /* Body text */ + --text-secondary: #00cc33; /* Subdued text */ + --text-muted: #00bb33; /* Muted / placeholder text */ + + /* --- Borders --- */ + --border-color: rgba(0, 255, 65, 0.35); /* Default border (semi-transparent) */ + --border-color-hi: #00ff41; /* Highlighted / focused border */ + --border-color-dim: rgba(0, 255, 65, 0.12); /* Subtle row separator */ + + /* --- Ticket / workflow status colours --- */ + --status-open: #28a745; + --status-pending: #9c27b0; + --status-in-progress: #ffc107; + --status-closed: #dc3545; + --status-online: #28a745; + --status-offline: #dc3545; + --status-running: #ffc107; + --status-completed: #28a745; + --status-failed: #dc3545; + --status-waiting: #ffc107; + + /* --- Priority colours (P1 critical → P5 minimal) --- */ + --priority-1: #ff4d4d; /* Critical */ + --priority-2: #ffa726; /* High */ + --priority-3: #42a5f5; /* Medium */ + --priority-4: #66bb6a; /* Low */ + --priority-5: #9e9e9e; /* Minimal */ + + /* --- Glow — text-shadow stacks --- */ + --glow-green: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41; + --glow-green-intense: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, + 0 0 32px rgba(0, 255, 65, 0.5); + --glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000; + --glow-amber-intense: 0 0 8px #ffb000, 0 0 16px #ffb000, 0 0 24px #ffb000; + --glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0, 255, 255, 0.4); + --glow-red: 0 0 5px #ff4444, 0 0 10px #ff4444; + + /* --- Glow — box-shadow (element halos) --- */ + --box-glow-green: 0 0 12px rgba(0, 255, 65, 0.3); + --box-glow-amber: 0 0 12px rgba(255, 176, 0, 0.3); + --box-glow-red: 0 0 12px rgba(255, 68, 68, 0.3); + --box-glow-cyan: 0 0 12px rgba(0, 255, 255, 0.25); + + /* --- Typography --- */ + --font-mono: 'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace; + + /* --- Spacing scale --- */ + --space-xs: 0.25rem; /* 4px */ + --space-sm: 0.5rem; /* 8px */ + --space-md: 1rem; /* 16px */ + --space-lg: 1.5rem; /* 24px */ + --space-xl: 2rem; /* 32px */ + --space-2xl: 3rem; /* 48px */ + + /* --- Layout --- */ + --sidebar-width: 240px; + --sidebar-width-sm: 200px; + --header-height: 58px; + --container-max: 1600px; + + /* --- Transitions --- */ + --transition-fast: all 0.15s ease; + --transition-default: all 0.3s ease; + + /* --- Z-index ladder --- */ + --z-base: 1; + --z-dropdown: 100; + --z-sticky: 200; + --z-fixed: 300; + --z-modal-backdrop: 400; + --z-modal: 500; + --z-popover: 600; + --z-tooltip: 700; + --z-toast: 800; + --z-overlay: 9999; +} + +/* ---------------------------------------------------------------- + 02. RESET & BASE + ---------------------------------------------------------------- */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-mono); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; + /* Subtle screen flicker every 30 s — authentic CRT feel */ + animation: flicker 0.2s ease-in-out 30s infinite; +} + +a { + color: var(--terminal-green); + text-decoration: none; + transition: var(--transition-fast); +} +a:hover { + color: var(--terminal-amber); + text-shadow: var(--glow-amber); +} + +ul, ol { list-style: none; } + +img, svg { display: block; max-width: 100%; } + +/* ---------------------------------------------------------------- + 03. CRT & TERMINAL EFFECTS + ---------------------------------------------------------------- */ + +/* Horizontal scanline overlay — fixed over the entire viewport */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + rgba(0, 0, 0, 0.15) 0px, + rgba(0, 0, 0, 0.15) 1px, + transparent 1px, + transparent 2px + ); + pointer-events: none; + z-index: var(--z-overlay); + animation: scanline 8s linear infinite; +} + +/* Binary data-stream watermark — bottom-right corner */ +body::after { + content: '10101010'; + position: fixed; + bottom: 10px; + right: 14px; + font-family: var(--font-mono); + font-size: 0.55rem; + color: var(--terminal-green); + opacity: 0.07; + pointer-events: none; + letter-spacing: 2px; + animation: data-stream 3s steps(1) infinite; +} + +/* ---------------------------------------------------------------- + 04. TYPOGRAPHY + ---------------------------------------------------------------- */ +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-mono); + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.3; +} + +h1 { + font-size: 1.5rem; + color: var(--terminal-amber); + text-shadow: var(--glow-amber-intense); +} +h1::before { content: '>> '; color: var(--terminal-green); text-shadow: var(--glow-green); } + +h2 { + font-size: 1.2rem; + color: var(--terminal-amber); + text-shadow: var(--glow-amber); +} + +h3 { + font-size: 1rem; + color: var(--terminal-amber); + text-shadow: var(--glow-amber); +} +h3::before { content: '═══ '; color: var(--terminal-green); } +h3::after { content: ' ═══'; color: var(--terminal-green); } + +h4 { + font-size: 0.9rem; + color: var(--terminal-amber); + letter-spacing: 0.07em; +} + +p { + color: var(--text-primary); + margin-bottom: var(--space-md); +} + +code { + font-family: var(--font-mono); + background: var(--bg-terminal); + color: var(--terminal-green); + padding: 2px 6px; + border: 1px solid var(--border-color-dim); + font-size: 0.9em; +} + +pre { + font-family: var(--font-mono); + background: var(--bg-terminal); + color: var(--terminal-green); + padding: var(--space-md); + border: 1px solid var(--border-color); + overflow-x: auto; + line-height: 1.5; +} + +/* ---------------------------------------------------------------- + 05. LAYOUT PRIMITIVES + ---------------------------------------------------------------- */ +.lt-container { + max-width: var(--container-max); + margin: 0 auto; + padding: 0 var(--space-lg); +} + +.lt-main { + padding: var(--space-lg); + min-height: calc(100vh - var(--header-height)); +} + +/* Sidebar + content split */ +.lt-layout { + display: flex; + gap: var(--space-md); + align-items: flex-start; +} + +.lt-content { flex: 1; min-width: 0; } + +/* Page header (below nav, above content) */ +.lt-page-header { + margin-bottom: var(--space-lg); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + flex-wrap: wrap; +} + +.lt-page-title { + font-size: 1.05rem; + font-weight: bold; + color: var(--terminal-amber); + text-shadow: var(--glow-amber); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.lt-page-title::before { content: '>> '; color: var(--terminal-green); } + +/* ---------------------------------------------------------------- + 06. HEADER & NAVIGATION + ---------------------------------------------------------------- */ +.lt-header { + height: var(--header-height); + background: var(--bg-secondary); + border-bottom: 2px solid var(--terminal-green); + box-shadow: 0 2px 16px rgba(0, 255, 65, 0.12); + padding: 0 var(--space-lg); + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: var(--z-fixed); +} + +/* Animated glow underline on header */ +.lt-header::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; right: 0; + height: 2px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--terminal-green) 20%, + var(--terminal-green) 80%, + transparent 100% + ); + box-shadow: var(--glow-green); +} + +.lt-header-left { + display: flex; + align-items: center; + gap: var(--space-xl); +} +.lt-header-right { + display: flex; + align-items: center; + gap: var(--space-md); +} + +/* --- Brand / App Title --- */ +.lt-brand { display: flex; flex-direction: column; } + +.lt-brand-title { + font-size: 1.2rem; + font-weight: bold; + color: var(--terminal-amber); + text-shadow: var(--glow-amber-intense); + letter-spacing: 0.1em; + white-space: nowrap; + animation: subtle-pulse 3s ease-in-out infinite; +} +.lt-brand-title::before { + content: '>> '; + color: var(--terminal-green); + text-shadow: var(--glow-green); +} + +.lt-brand-subtitle { + font-size: 0.6rem; + color: var(--text-muted); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +/* --- Nav links (horizontal bar) --- */ +.lt-nav { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.lt-nav-link { + display: inline-block; + color: var(--text-muted); + padding: 4px 10px; + border: 1px solid transparent; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + transition: var(--transition-fast); + white-space: nowrap; +} +.lt-nav-link::before { content: '[ '; } +.lt-nav-link::after { content: ' ]'; } + +.lt-nav-link:hover { + color: var(--terminal-green); + border-color: var(--border-color); + background: var(--terminal-green-dim); + text-shadow: var(--glow-green); +} +.lt-nav-link.active { + color: var(--terminal-amber); + border-color: var(--terminal-amber); + background: var(--terminal-amber-dim); + text-shadow: var(--glow-amber); +} + +/* --- Dropdown nav --- */ +.lt-nav-dropdown { position: relative; } + +.lt-nav-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 200px; + background: var(--bg-secondary); + border: 1px solid var(--border-color-hi); + box-shadow: var(--box-glow-green), 0 8px 24px rgba(0,0,0,0.6); + z-index: var(--z-dropdown); + list-style: none; +} +.lt-nav-dropdown:hover .lt-nav-dropdown-menu, +.lt-nav-dropdown.open .lt-nav-dropdown-menu { display: block; } + +.lt-nav-dropdown-menu li a { + display: block; + padding: 8px 14px; + color: var(--text-secondary); + font-size: 0.8rem; + border-bottom: 1px solid var(--border-color-dim); + transition: var(--transition-fast); +} +.lt-nav-dropdown-menu li a::before { content: '> '; color: var(--terminal-amber); } +.lt-nav-dropdown-menu li a:hover { + background: var(--terminal-green-dim); + color: var(--terminal-green); + text-shadow: var(--glow-green); +} +.lt-nav-dropdown-menu li:last-child a { border-bottom: none; } + +/* --- User info widget --- */ +.lt-header-user { + font-size: 0.78rem; + color: var(--text-secondary); + white-space: nowrap; +} +.lt-header-user::before { content: '[user: '; color: var(--terminal-amber); } +.lt-header-user::after { content: ']'; color: var(--terminal-amber); } + +/* --- Admin badge --- */ +.lt-badge-admin { + display: inline-block; + color: var(--terminal-red); + border: 1px solid var(--terminal-red); + padding: 2px 8px; + font-size: 0.7rem; + font-weight: bold; + text-shadow: var(--glow-red); + letter-spacing: 0.05em; + text-transform: uppercase; +} +.lt-badge-admin::before { content: '['; } +.lt-badge-admin::after { content: ']'; } + +/* ---------------------------------------------------------------- + 07. ASCII FRAME SYSTEM + ---------------------------------------------------------------- */ + +/* Outer frame — double-line border, for major page sections */ +.lt-frame { + position: relative; + border: 3px double var(--terminal-green); + background: var(--bg-primary); + margin-bottom: var(--space-xl); + animation: boot-up 0.8s ease-out; +} + +.lt-frame::before { + content: '╔'; + position: absolute; + top: -5px; left: -5px; + font-size: 1.3rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + animation: corner-pulse 3s ease-in-out infinite; + line-height: 1; +} +.lt-frame::after { + content: '╗'; + position: absolute; + top: -5px; right: -5px; + font-size: 1.3rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + animation: corner-pulse 3s ease-in-out infinite; + line-height: 1; +} + +/* Bottom corners require explicit child elements: + + */ +.lt-frame-bl, +.lt-frame-br { + position: absolute; + bottom: -5px; + font-size: 1.3rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + line-height: 1; +} +.lt-frame-bl { left: -5px; } +.lt-frame-br { right: -5px; } + +/* Inner frame — single-line, for sub-panels inside a .lt-frame */ +.lt-frame-inner { + position: relative; + border: 2px solid var(--terminal-green); + background: var(--bg-secondary); + padding: var(--space-md); + margin: var(--space-md); +} +.lt-frame-inner::before { + content: '┌'; + position: absolute; + top: -3px; left: -3px; + color: var(--terminal-green); + line-height: 1; +} +.lt-frame-inner::after { + content: '┐'; + position: absolute; + top: -3px; right: -3px; + color: var(--terminal-green); + line-height: 1; +} + +/* Section header — full-width amber title bar inside a frame */ +.lt-section-header { + padding: 0.75rem 1.5rem; + background: var(--bg-primary); + border-bottom: 2px solid var(--terminal-green); + font-weight: bold; + font-size: 0.9rem; + color: var(--terminal-amber); + text-shadow: var(--glow-amber-intense); + text-transform: uppercase; + letter-spacing: 0.1em; + animation: subtle-pulse 3s ease-in-out infinite; +} +.lt-section-header::before { + content: '╠═══ '; + color: var(--terminal-green); + text-shadow: var(--glow-green); +} +.lt-section-header::after { + content: ' ═══╣'; + color: var(--terminal-green); + text-shadow: var(--glow-green); +} + +/* Subsection header */ +.lt-subsection-header { + padding: 0.5rem var(--space-md); + background: var(--bg-secondary); + border-bottom: 1px solid var(--terminal-green); + font-weight: bold; + font-size: 0.85rem; + color: var(--terminal-green); + text-transform: uppercase; + letter-spacing: 0.07em; +} +.lt-subsection-header::before { content: '├─── '; } +.lt-subsection-header::after { content: ' ───┤'; } + +/* Horizontal divider with ASCII end-caps */ +.lt-divider { + height: 1px; + background: var(--border-color); + margin: var(--space-lg) 0; + position: relative; +} +.lt-divider::before { + content: '╞═══'; + position: absolute; + left: -2px; top: -10px; + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1; +} +.lt-divider::after { + content: '═══╡'; + position: absolute; + right: -2px; top: -10px; + font-size: 0.7rem; + color: var(--text-muted); + line-height: 1; +} + +/* Content body inside a frame / section */ +.lt-section-body { padding: var(--space-md) var(--space-lg); } + +/* ---------------------------------------------------------------- + 08. CARD COMPONENT + ---------------------------------------------------------------- */ +.lt-card { + position: relative; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + padding: var(--space-lg); + transition: var(--transition-default); +} +.lt-card::before { + content: '┌'; + position: absolute; + top: -3px; left: -3px; + color: var(--terminal-green); + line-height: 1; +} +.lt-card::after { + content: '┐'; + position: absolute; + top: -3px; right: -3px; + color: var(--terminal-green); + line-height: 1; +} +.lt-card:hover { + border-color: var(--terminal-green); + box-shadow: var(--box-glow-green); +} + +.lt-card-title { + font-size: 0.95rem; + color: var(--terminal-amber); + text-shadow: var(--glow-amber); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: var(--space-md); + padding-bottom: var(--space-sm); + border-bottom: 1px solid var(--border-color); +} +.lt-card-title::before { content: '═══ '; color: var(--terminal-green); } +.lt-card-title::after { content: ' ═══'; color: var(--terminal-green); } + +/* Card grid wrappers */ +.lt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--space-md); } +.lt-grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-md); } +.lt-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-md); } +.lt-grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-md); } + +/* ---------------------------------------------------------------- + 09. BUTTONS + ---------------------------------------------------------------- */ +.lt-btn { + display: inline-flex; + align-items: center; + background: transparent; + color: var(--terminal-green); + border: 2px solid var(--terminal-green); + border-radius: 0; /* NO rounded corners — terminal style */ + padding: 10px 20px; + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.05em; + text-decoration: none; + transition: var(--transition-default); + white-space: nowrap; + user-select: none; +} +.lt-btn::before { content: '[ '; flex-shrink: 0; } +.lt-btn::after { content: ' ]'; flex-shrink: 0; } + +.lt-btn:hover { + background: var(--terminal-green-dim); + color: var(--terminal-amber); + border-color: var(--terminal-amber); + text-shadow: var(--glow-amber-intense); + box-shadow: var(--box-glow-amber); + transform: translateY(-2px); +} +.lt-btn:active { + transform: translateY(0); + box-shadow: var(--box-glow-green); +} +.lt-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +/* Amber (primary call-to-action) */ +.lt-btn-primary { + color: var(--terminal-amber); + border-color: var(--terminal-amber); + text-shadow: var(--glow-amber); +} +.lt-btn-primary::before { content: '> '; } +.lt-btn-primary:hover { + background: var(--terminal-amber-dim); + box-shadow: var(--box-glow-amber); +} + +/* Red (destructive / danger) */ +.lt-btn-danger { + color: var(--terminal-red); + border-color: var(--terminal-red); +} +.lt-btn-danger:hover { + background: var(--terminal-red-dim); + color: var(--terminal-red); + border-color: var(--terminal-red); + text-shadow: var(--glow-red); + box-shadow: var(--box-glow-red); +} + +/* Small variant */ +.lt-btn-sm { padding: 5px 10px; font-size: 0.75rem; border-width: 1px; } + +/* Ghost (border only, no fill on hover either) */ +.lt-btn-ghost { border-color: var(--border-color); color: var(--text-muted); } + +/* Button group */ +.lt-btn-group { display: flex; gap: var(--space-sm); flex-wrap: wrap; align-items: center; } + +/* ---------------------------------------------------------------- + 10. FORMS & INPUTS + ---------------------------------------------------------------- */ +.lt-form-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-bottom: var(--space-md); +} + +.lt-label { + font-size: 0.75rem; + font-weight: bold; + color: var(--terminal-amber); + text-transform: uppercase; + letter-spacing: 0.07em; + text-shadow: var(--glow-amber); +} +.lt-label-required::after { content: ' *'; color: var(--terminal-red); } + +.lt-input, +.lt-textarea, +.lt-select { + font-family: var(--font-mono); + font-size: 0.9rem; + background: var(--bg-primary); + color: var(--terminal-green); + border: 2px solid var(--terminal-green); + border-radius: 0; + padding: 10px var(--space-md); + width: 100%; + transition: var(--transition-fast); + box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5); +} +.lt-input:focus, +.lt-textarea:focus, +.lt-select:focus { + outline: none; + border-color: var(--terminal-amber); + box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5); + background: rgba(0, 255, 65, 0.03); + animation: focus-pulse 2s ease-in-out infinite; +} +.lt-input::placeholder, +.lt-textarea::placeholder { color: var(--text-muted); opacity: 0.7; } + +.lt-textarea { min-height: 100px; resize: vertical; } + +.lt-select option { + background: var(--bg-secondary); + color: var(--terminal-green); +} + +/* Checkbox */ +.lt-checkbox { + appearance: none; + width: 18px; height: 18px; + border: 2px solid var(--terminal-green); + background: var(--bg-primary); + cursor: pointer; + border-radius: 0; + flex-shrink: 0; + transition: var(--transition-fast); +} +.lt-checkbox:checked { + background: var(--terminal-green-dim); + border-color: var(--terminal-amber); +} +.lt-checkbox:checked::before { + content: '✓'; + display: flex; + align-items: center; + justify-content: center; + width: 100%; height: 100%; + color: var(--terminal-amber); + font-size: 0.8rem; + line-height: 1; +} + +/* Search input with prompt prefix */ +.lt-search { position: relative; } +.lt-search-input { padding-left: 2.2rem; } +.lt-search::before { + content: '>'; + position: absolute; + left: 10px; + top: 50%; transform: translateY(-50%); + color: var(--terminal-amber); + font-weight: bold; + pointer-events: none; + z-index: 1; +} + +/* Form hint */ +.lt-form-hint { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: var(--space-xs); +} + +/* Toolbar row */ +.lt-toolbar { + display: flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + margin-bottom: var(--space-md); + justify-content: space-between; +} +.lt-toolbar-left, +.lt-toolbar-right { display: flex; align-items: center; gap: var(--space-sm); } + +/* ---------------------------------------------------------------- + 11. TABLES + ---------------------------------------------------------------- */ + +/* Standard table — full-grid borders, good for simple data */ +.lt-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); +} +.lt-table th { + background: var(--bg-primary); + padding: 10px 14px; + font-size: 0.8rem; + font-weight: bold; + color: var(--terminal-amber); + text-transform: uppercase; + letter-spacing: 0.1em; + text-shadow: var(--glow-amber); + border: 1px solid var(--border-color); + border-bottom: 2px solid var(--terminal-green); + white-space: nowrap; +} +.lt-table th::before { content: '> '; color: var(--terminal-green); } +.lt-table th:has(input[type="checkbox"])::before { content: none; } + +.lt-table td { + padding: 10px 14px; + font-size: 0.88rem; + color: var(--text-primary); + border: 1px solid var(--border-color-dim); + word-break: break-word; +} +.lt-table tbody tr:hover { + background: rgba(0, 255, 65, 0.06); + box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.05); +} + +/* Data table — compact, row-only separators, good for dense data */ +.lt-data-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); } +.lt-data-table th { + padding: 8px 12px; + font-size: 0.72rem; + font-weight: bold; + color: var(--terminal-amber); + text-transform: uppercase; + letter-spacing: 0.08em; + text-shadow: var(--glow-amber); + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); +} +.lt-data-table th::before { content: '> '; color: var(--terminal-green); } +.lt-data-table td { + padding: 7px 12px; + font-size: 0.85rem; + border-bottom: 1px solid rgba(0, 255, 65, 0.08); +} +.lt-data-table tbody tr:hover { background: rgba(0, 255, 65, 0.05); } + +/* Priority left-border accent (apply to ) */ +.lt-table tbody tr td:first-child, +.lt-data-table tbody tr td:first-child { border-left: 4px solid transparent; } +.lt-row-p1 td:first-child { border-left-color: var(--priority-1) !important; } +.lt-row-p2 td:first-child { border-left-color: var(--priority-2) !important; } +.lt-row-p3 td:first-child { border-left-color: var(--priority-3) !important; } +.lt-row-p4 td:first-child { border-left-color: var(--priority-4) !important; } +.lt-row-p5 td:first-child { border-left-color: var(--priority-5) !important; } + +/* Row state tints */ +.lt-row-critical td { background: rgba(255, 68, 68, 0.03); } +.lt-row-warning td { background: rgba(255, 140, 0, 0.03); } +.lt-row-success td { background: rgba(40, 167, 69, 0.03); } + +/* Keyboard-selected row (vim-style navigation) */ +.lt-row-selected { + background: rgba(0, 255, 65, 0.12) !important; + outline: 2px solid var(--terminal-green); + outline-offset: -2px; +} + +/* Scrollable table wrapper */ +.lt-table-wrap { overflow-x: auto; border: 2px solid var(--terminal-green); } + +/* Sortable column header */ +.lt-table th[data-sort] { cursor: pointer; } +.lt-table th[data-sort]:hover { color: var(--terminal-green); text-shadow: var(--glow-green); } +.lt-table th[data-sort="asc"]::after { content: ' ▲'; color: var(--terminal-green); } +.lt-table th[data-sort="desc"]::after { content: ' ▼'; color: var(--terminal-green); } + +/* ---------------------------------------------------------------- + 12. STATUS BADGES, CHIPS, PRIORITY + ---------------------------------------------------------------- */ + +/* Full status badge */ +.lt-status { + display: inline-block; + padding: 4px 12px; + border: 2px solid; + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} +.lt-status::before { content: '['; } +.lt-status::after { content: ']'; } + +.lt-status-open { color: var(--status-open); border-color: var(--status-open); text-shadow: 0 0 5px var(--status-open), 0 0 10px var(--status-open); } +.lt-status-open::before { content: '[●'; } +.lt-status-pending { color: var(--status-pending); border-color: var(--status-pending); } +.lt-status-pending::before { content: '[○'; } +.lt-status-in-progress { color: var(--status-in-progress); border-color: var(--status-in-progress); } +.lt-status-in-progress::before { content: '[◐'; animation: spin-status 2s linear infinite; } +.lt-status-closed { color: var(--status-closed); border-color: var(--status-closed); } +.lt-status-closed::before { content: '[✓'; } +.lt-status-online { color: var(--status-online); border-color: var(--status-online); text-shadow: 0 0 5px var(--status-online); } +.lt-status-online::before { content: '[●'; } +.lt-status-offline { color: var(--status-offline); border-color: var(--status-offline); text-shadow: 0 0 5px var(--status-offline); animation: pulse-red 1.5s infinite; } +.lt-status-offline::before { content: '[○'; } +.lt-status-running { color: var(--status-running); border-color: var(--status-running); } +.lt-status-running::before { content: '[◐'; animation: spin-status 2s linear infinite; } +.lt-status-completed { color: var(--status-completed); border-color: var(--status-completed); } +.lt-status-completed::before { content: '[✓'; } +.lt-status-failed { color: var(--status-failed); border-color: var(--status-failed); text-shadow: 0 0 5px var(--status-failed); } +.lt-status-failed::before { content: '[✗'; } + +/* Chip (small, compact status tag) */ +.lt-chip { + display: inline-block; + padding: 2px 8px; + border: 1px solid; + font-size: 0.75rem; + font-weight: bold; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.lt-chip::before { content: '['; } +.lt-chip::after { content: ']'; } +.lt-chip-ok { color: var(--terminal-green); border-color: var(--terminal-green); text-shadow: var(--glow-green); } +.lt-chip-warn { color: var(--terminal-orange); border-color: var(--terminal-orange); } +.lt-chip-critical { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); animation: pulse-glow 2s infinite; } +.lt-chip-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); text-shadow: var(--glow-cyan); } + +/* Badge (inline label, smallest) */ +.lt-badge { + display: inline-block; + padding: 1px 6px; + border: 1px solid; + font-size: 0.7rem; + font-weight: bold; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.lt-badge::before { content: '['; } +.lt-badge::after { content: ']'; } +.lt-badge-green { color: var(--terminal-green); border-color: var(--terminal-green); } +.lt-badge-amber { color: var(--terminal-amber); border-color: var(--terminal-amber); } +.lt-badge-red { color: var(--terminal-red); border-color: var(--terminal-red); } +.lt-badge-cyan { color: var(--terminal-cyan); border-color: var(--terminal-cyan); } +.lt-badge-muted { color: var(--text-muted); border-color: var(--border-color); } + +/* Priority badge */ +.lt-priority { + display: inline-block; + padding: 3px 10px; + border: 1px solid; + font-size: 0.78rem; + font-weight: bold; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} +.lt-priority::before { content: '['; } +.lt-priority::after { content: ']'; } +.lt-p1 { color: var(--priority-1); border-color: var(--priority-1); text-shadow: 0 0 5px var(--priority-1); animation: pulse-glow 2s infinite; } +.lt-p1::before { content: '[▲▲ P1 CRITICAL'; } +.lt-p2 { color: var(--priority-2); border-color: var(--priority-2); } +.lt-p2::before { content: '[▲ P2 HIGH'; } +.lt-p3 { color: var(--priority-3); border-color: var(--priority-3); } +.lt-p3::before { content: '[● P3 MED'; } +.lt-p4 { color: var(--priority-4); border-color: var(--priority-4); } +.lt-p4::before { content: '[▼ P4 LOW'; } +.lt-p5 { color: var(--priority-5); border-color: var(--priority-5); } +.lt-p5::before { content: '[▼▼ P5 MIN'; } + +/* Inline status dot */ +.lt-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + border: 2px solid; + flex-shrink: 0; +} +.lt-dot-up { background: var(--terminal-green); border-color: var(--terminal-green); box-shadow: 0 0 4px var(--terminal-green); } +.lt-dot-down { background: var(--terminal-red); border-color: var(--terminal-red); animation: pulse-red 1.5s infinite; } +.lt-dot-warn { background: var(--terminal-orange); border-color: var(--terminal-orange); } +.lt-dot-idle { background: transparent; border-color: var(--text-muted); } + +/* ---------------------------------------------------------------- + 13. MODALS + ---------------------------------------------------------------- */ +.lt-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.88); + z-index: var(--z-modal-backdrop); + display: none; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); +} +.lt-modal-overlay.show { display: flex; } + +.lt-modal { + position: relative; + background: var(--bg-secondary); + border: 3px double var(--terminal-green); + box-shadow: 0 0 30px rgba(0, 255, 65, 0.2), 0 8px 40px rgba(0, 0, 0, 0.8); + width: 560px; + max-width: 95vw; + max-height: 90vh; + overflow-y: auto; + animation: modal-slide-in 0.3s ease; +} + +/* Double-line corner decorations */ +.lt-modal::before { + content: '╔'; + position: absolute; + top: -5px; left: -5px; + font-size: 1.2rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + line-height: 1; +} +.lt-modal::after { + content: '╗'; + position: absolute; + top: -5px; right: -5px; + font-size: 1.2rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + line-height: 1; +} + +.lt-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-lg); + border-bottom: 2px solid var(--terminal-green); + background: var(--bg-primary); +} + +.lt-modal-title { + font-size: 0.9rem; + font-weight: bold; + color: var(--terminal-amber); + text-shadow: var(--glow-amber); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.lt-modal-title::before { content: '>> '; color: var(--terminal-green); } + +.lt-modal-close { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-muted); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.85rem; + padding: 2px 8px; + transition: var(--transition-fast); +} +.lt-modal-close::before, +.lt-modal-close::after { content: ''; } /* Override global btn brackets */ +.lt-modal-close:hover { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); transform: none; } + +.lt-modal-body { padding: var(--space-lg); } +.lt-modal-footer { + display: flex; + gap: var(--space-sm); + justify-content: flex-end; + padding: var(--space-md) var(--space-lg); + border-top: 1px solid var(--border-color); + background: var(--bg-primary); +} + +/* Wide variant */ +.lt-modal-lg { width: 800px; } + +/* ---------------------------------------------------------------- + 14. TOAST NOTIFICATIONS + ---------------------------------------------------------------- */ +.lt-toast-container { + position: fixed; + bottom: var(--space-lg); + right: var(--space-lg); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-sm); + pointer-events: none; +} + +.lt-toast { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 10px 16px; + background: var(--bg-secondary); + border: 2px solid; + font-family: var(--font-mono); + font-size: 0.85rem; + font-weight: bold; + max-width: 420px; + pointer-events: all; + animation: slide-in-right 0.2s ease-out; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); +} +.lt-toast::before { content: '>> '; flex-shrink: 0; } +.lt-toast-success { color: var(--terminal-green); border-color: var(--terminal-green); text-shadow: var(--glow-green); } +.lt-toast-error { color: var(--terminal-red); border-color: var(--terminal-red); text-shadow: var(--glow-red); } +.lt-toast-warning { color: var(--terminal-amber); border-color: var(--terminal-amber); text-shadow: var(--glow-amber); } +.lt-toast-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); text-shadow: var(--glow-cyan); } + +.lt-toast-icon { font-size: 1rem; flex-shrink: 0; } +.lt-toast-msg { flex: 1; } +.lt-toast-close { + background: transparent; border: none; + color: inherit; cursor: pointer; + font-family: var(--font-mono); + opacity: 0.6; font-size: 0.85rem; + padding: 0 0 0 var(--space-sm); +} +.lt-toast-close::before, +.lt-toast-close::after { content: ''; } +.lt-toast-close:hover { opacity: 1; transform: none; } + +/* ---------------------------------------------------------------- + 15. TAB NAVIGATION + ---------------------------------------------------------------- */ +.lt-tabs { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + padding: var(--space-sm); + margin-bottom: var(--space-lg); +} + +.lt-tab { + display: inline-flex; + align-items: center; + padding: 8px 18px; + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border-color); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: var(--transition-fast); +} +.lt-tab::before { content: '[ '; } +.lt-tab::after { content: ' ]'; } +.lt-tab:hover { + color: var(--terminal-green); + border-color: var(--border-color-hi); + background: var(--terminal-green-dim); +} +.lt-tab.active { + color: var(--terminal-amber); + border-color: var(--terminal-amber); + background: var(--terminal-amber-dim); + text-shadow: var(--glow-amber); +} + +.lt-tab-panel { display: none; } +.lt-tab-panel.active { display: block; } + +/* ---------------------------------------------------------------- + 16. SIDEBAR / FILTER PANEL + ---------------------------------------------------------------- */ +.lt-sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border: 2px solid var(--terminal-green); + flex-shrink: 0; + overflow-y: auto; + max-height: calc(100vh - var(--header-height) - 2rem); + position: sticky; + top: calc(var(--header-height) + var(--space-md)); +} + +.lt-sidebar-header { + padding: var(--space-sm) var(--space-md); + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + font-size: 0.75rem; + font-weight: bold; + color: var(--terminal-amber); + text-transform: uppercase; + letter-spacing: 0.08em; + display: flex; + align-items: center; + justify-content: space-between; +} +.lt-sidebar-header::before { content: '╠═ '; color: var(--terminal-green); } + +.lt-sidebar-body { padding: var(--space-md); } + +.lt-filter-group { margin-bottom: var(--space-lg); } + +.lt-filter-label { + display: block; + font-size: 0.75rem; + font-weight: bold; + color: var(--terminal-amber); + text-transform: uppercase; + letter-spacing: 0.07em; + margin-bottom: var(--space-sm); + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--border-color-dim); +} + +.lt-filter-option { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) 0; + cursor: pointer; + color: var(--text-primary); + font-size: 0.85rem; + transition: var(--transition-fast); +} +.lt-filter-option:hover { color: var(--terminal-amber); } + +/* Collapse toggle */ +.lt-sidebar.collapsed .lt-sidebar-body { display: none; } +.lt-sidebar-toggle { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-family: var(--font-mono); + font-size: 0.8rem; + padding: 0; +} +.lt-sidebar-toggle::before, +.lt-sidebar-toggle::after { content: ''; } + +/* ---------------------------------------------------------------- + 17. STATS WIDGETS + ---------------------------------------------------------------- */ +.lt-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.lt-stat-card { + background: var(--bg-secondary); + border: 2px solid var(--border-color); + padding: var(--space-md); + display: flex; + align-items: center; + gap: var(--space-md); + position: relative; + transition: var(--transition-default); + cursor: pointer; +} +.lt-stat-card::before { + content: '┌'; + position: absolute; + top: -3px; left: -3px; + color: var(--terminal-green); + line-height: 1; +} +.lt-stat-card::after { + content: '┐'; + position: absolute; + top: -3px; right: -3px; + color: var(--terminal-green); + line-height: 1; +} +.lt-stat-card:hover { + border-color: var(--terminal-amber); + box-shadow: var(--box-glow-amber); + transform: translateY(-2px); +} +.lt-stat-card.active { + background: var(--terminal-amber-dim); + border-color: var(--terminal-amber); +} + +.lt-stat-icon { font-size: 1.8rem; } +.lt-stat-info { display: flex; flex-direction: column; gap: 2px; } +.lt-stat-value { + font-size: 1.6rem; + font-weight: bold; + color: var(--terminal-green); + text-shadow: 0 0 4px currentColor; + line-height: 1; +} +.lt-stat-label { + font-size: 0.68rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +/* ---------------------------------------------------------------- + 18. INLINE MESSAGES + ---------------------------------------------------------------- */ +.lt-msg { + padding: var(--space-md) var(--space-lg); + border: 2px solid; + font-family: var(--font-mono); + margin: var(--space-md) 0; +} +.lt-msg-error { color: var(--terminal-red); border-color: var(--terminal-red); background: var(--terminal-red-dim); } +.lt-msg-success { color: var(--status-open); border-color: var(--status-open); background: rgba(40, 167, 69, 0.08); } +.lt-msg-warning { color: var(--terminal-amber); border-color: var(--terminal-amber); background: var(--terminal-amber-dim); } +.lt-msg-info { color: var(--terminal-cyan); border-color: var(--terminal-cyan); background: var(--terminal-cyan-dim); } +.lt-msg-error::before { content: '[✗] ERROR: '; font-weight: bold; } +.lt-msg-success::before { content: '[✓] SUCCESS: '; font-weight: bold; } +.lt-msg-warning::before { content: '[!] WARNING: '; font-weight: bold; } +.lt-msg-info::before { content: '[i] INFO: '; font-weight: bold; } + +/* ---------------------------------------------------------------- + 19. LOADING & EMPTY STATES + ---------------------------------------------------------------- */ +.lt-loading { + text-align: center; + padding: var(--space-xl); + color: var(--terminal-green); +} +.lt-loading::before { content: '[ LOADING'; display: block; margin-bottom: var(--space-sm); } +.lt-loading::after { content: '...'; animation: loading-dots 1.5s steps(4, end) infinite; } + +.lt-empty { + text-align: center; + padding: var(--space-xl) var(--space-lg); + color: var(--text-muted); +} +.lt-empty::before { + content: '[ NO DATA ]'; + display: block; + font-size: 1.1rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + margin-bottom: var(--space-sm); +} + +/* Skeleton shimmer for async content */ +.lt-skeleton { + background: linear-gradient( + 90deg, + rgba(0, 255, 65, 0.04) 0%, + rgba(0, 255, 65, 0.10) 50%, + rgba(0, 255, 65, 0.04) 100% + ); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; + border: 1px solid var(--border-color-dim); + color: transparent; + user-select: none; +} + +/* ---------------------------------------------------------------- + 20. BOOT SEQUENCE OVERLAY + ---------------------------------------------------------------- */ +.lt-boot-overlay { + position: fixed; + inset: 0; + background: var(--bg-primary); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.5s ease; +} +.lt-boot-overlay.fade-out { opacity: 0; pointer-events: none; } + +.lt-boot-text { + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--terminal-green); + text-shadow: var(--glow-green); + line-height: 1.7; + white-space: pre; + padding: var(--space-xl); +} + +/* ---------------------------------------------------------------- + 21. LOG / TIMELINE ENTRIES + ---------------------------------------------------------------- */ +.lt-log-entry { + padding: var(--space-md); + background: var(--bg-secondary); + border-left: 3px solid var(--terminal-green); + margin-bottom: var(--space-sm); + font-size: 0.88rem; + transition: var(--transition-fast); +} +.lt-log-entry::before { content: '> '; color: var(--terminal-amber); font-weight: bold; } +.lt-log-entry:hover { background: var(--terminal-green-dim); border-left-width: 5px; } +.lt-log-entry.error { border-left-color: var(--terminal-red); } +.lt-log-entry.warning { border-left-color: var(--terminal-amber); } +.lt-log-entry.success { border-left-color: var(--status-open); } + +.lt-log-ts { + font-size: 0.78rem; + color: var(--terminal-amber); + margin-bottom: var(--space-xs); +} + +.lt-log-output { + background: var(--bg-terminal); + border: 1px solid var(--border-color-dim); + padding: var(--space-sm); + margin-top: var(--space-sm); + max-height: 300px; + overflow-y: auto; + font-size: 0.82rem; +} + +/* Timeline (vertical) */ +.lt-timeline { position: relative; padding-left: var(--space-xl); } +.lt-timeline::before { + content: ''; + position: absolute; + left: 8px; top: 0; bottom: 0; + width: 1px; + background: var(--border-color); +} +.lt-timeline-item { + position: relative; + margin-bottom: var(--space-md); +} +.lt-timeline-item::before { + content: '●'; + position: absolute; + left: calc(-1 * var(--space-xl) + 2px); + color: var(--terminal-green); + font-size: 0.6rem; + top: 0.4rem; +} + +/* ---------------------------------------------------------------- + 22. ANIMATIONS + ---------------------------------------------------------------- */ +@keyframes scanline { + 0% { transform: translateY(0); } + 100% { transform: translateY(4px); } +} + +@keyframes flicker { + 0% { opacity: 1; } + 10% { opacity: 0.95; } + 20% { opacity: 1; } + 30% { opacity: 0.97; } + 40% { opacity: 1; } +} + +@keyframes data-stream { + 0% { content: '10101010'; opacity: 0.07; } + 25% { content: '01010101'; opacity: 0.10; } + 50% { content: '11001100'; opacity: 0.07; } + 75% { content: '00110011'; opacity: 0.10; } + 100% { content: '10101010'; opacity: 0.07; } +} + +@keyframes boot-up { + 0% { opacity: 0; filter: brightness(2) saturate(0); } + 30% { opacity: 1; filter: brightness(1.5) saturate(0.5); } + 100% { opacity: 1; filter: brightness(1) saturate(1); } +} + +@keyframes modal-slide-in { + from { transform: translateY(-30px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes slide-in-right { + from { transform: translateX(30px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes corner-pulse { + 0%, 100% { text-shadow: var(--glow-green); } + 50% { text-shadow: var(--glow-green-intense); } +} + +@keyframes subtle-pulse { + 0%, 100% { text-shadow: var(--glow-amber); } + 50% { text-shadow: var(--glow-amber-intense); } +} + +@keyframes pulse-glow { + 0%, 100% { text-shadow: 0 0 5px currentColor, 0 0 10px currentColor; } + 50% { text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor; } +} + +@keyframes pulse-red { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.5); } + 50% { box-shadow: 0 0 6px 3px rgba(255, 68, 68, 0.2); } +} + +@keyframes focus-pulse { + 0%, 100% { box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5); } + 50% { box-shadow: var(--glow-amber-intense), inset 0 0 10px rgba(0, 0, 0, 0.5); } +} + +@keyframes spin-status { + 0% { content: '[◐'; } + 25% { content: '[◓'; } + 50% { content: '[◑'; } + 75% { content: '[◒'; } + 100% { content: '[◐'; } +} + +@keyframes spin-full { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes loading-dots { + 0%, 20% { content: '.'; } + 40% { content: '..'; } + 60%, 100% { content: '...'; } +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@keyframes cursor-blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* Item pulse for actively running tasks */ +@keyframes exec-running-pulse { + 0%, 100% { border-color: var(--terminal-green); } + 50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255, 193, 7, 0.35); } +} +.lt-item-running { animation: exec-running-pulse 2s ease-in-out infinite; } + +/* ---------------------------------------------------------------- + 23. RESPONSIVE DESIGN + ---------------------------------------------------------------- */ +@media (max-width: 1400px) { + .lt-grid-4 { grid-template-columns: repeat(3, 1fr); } + .lt-stats-grid { grid-template-columns: repeat(4, 1fr); } +} + +@media (max-width: 1200px) { + .lt-sidebar { width: var(--sidebar-width-sm); } + .lt-grid-4, .lt-grid-3 { grid-template-columns: repeat(2, 1fr); } + .lt-stats-grid { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 1024px) { + .lt-layout { flex-direction: column; } + .lt-sidebar { width: 100%; max-height: none; position: static; } + .lt-stats-grid { grid-template-columns: repeat(2, 1fr); } + .lt-grid-3 { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + :root { --header-height: 50px; } + .lt-header { padding: 0 var(--space-md); } + .lt-nav { display: none; } /* Replace with hamburger menu on mobile */ + .lt-brand-subtitle { display: none; } + .lt-main { padding: var(--space-md); } + .lt-grid-2 { grid-template-columns: 1fr; } + .lt-stats-grid { grid-template-columns: repeat(2, 1fr); } + .lt-modal { width: 100%; max-width: none; margin: var(--space-md); } + .lt-table { font-size: 0.8rem; } + .lt-table th, .lt-table td { padding: 8px; } +} + +@media (max-width: 480px) { + .lt-main { padding: var(--space-sm); } + .lt-stats-grid { grid-template-columns: 1fr 1fr; } + .lt-btn { padding: 8px 14px; font-size: 0.8rem; } + .lt-toast-container { left: var(--space-sm); right: var(--space-sm); bottom: var(--space-sm); } + .lt-toast { max-width: none; } +} + +/* ---------------------------------------------------------------- + 24. UTILITY CLASSES + ---------------------------------------------------------------- */ +.lt-text-green { color: var(--terminal-green) !important; text-shadow: var(--glow-green); } +.lt-text-amber { color: var(--terminal-amber) !important; text-shadow: var(--glow-amber); } +.lt-text-red { color: var(--terminal-red) !important; } +.lt-text-cyan { color: var(--terminal-cyan) !important; } +.lt-text-muted { color: var(--text-muted) !important; } +.lt-text-mono { font-family: var(--font-mono) !important; } +.lt-text-upper { text-transform: uppercase; letter-spacing: 0.06em; } +.lt-text-sm { font-size: 0.8rem; } +.lt-text-xs { font-size: 0.7rem; } +.lt-no-wrap { white-space: nowrap; } + +.lt-flex { display: flex; align-items: center; } +.lt-flex-between { display: flex; align-items: center; justify-content: space-between; } +.lt-flex-wrap { flex-wrap: wrap; } +.lt-gap-xs { gap: var(--space-xs); } +.lt-gap-sm { gap: var(--space-sm); } +.lt-gap-md { gap: var(--space-md); } +.lt-mt-sm { margin-top: var(--space-sm); } +.lt-mt-md { margin-top: var(--space-md); } +.lt-mb-md { margin-bottom: var(--space-md); } +.lt-mb-lg { margin-bottom: var(--space-lg); } +.lt-p-md { padding: var(--space-md); } + +/* Screen reader only */ +.lt-sr-only { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0,0,0,0); white-space: nowrap; border: 0; +} + +/* ---------------------------------------------------------------- + 25. PRINT STYLES + ---------------------------------------------------------------- */ +@media print { + body::before, body::after { display: none; } + .lt-header, .lt-sidebar, .lt-tabs, + .lt-modal-overlay, .lt-toast-container { display: none !important; } + .lt-main { padding: 0; } + body { background: white; color: black; animation: none; } + .lt-card, .lt-frame { border: 1px solid #333; box-shadow: none; } + a { color: black; } +} + +/* ---------------------------------------------------------------- + 26. ACCESSIBILITY + ---------------------------------------------------------------- */ +:focus-visible { + outline: 2px solid var(--terminal-amber); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + body::before { animation: none; } + body { animation: none; } +} diff --git a/assets/js/base.js b/assets/js/base.js new file mode 100644 index 0000000..6caf677 --- /dev/null +++ b/assets/js/base.js @@ -0,0 +1,796 @@ +/** + * 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, '''); + } + + /* ---------------------------------------------------------------- + 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: +
+
+
+ Title + +
+
+ +
+
+ ---------------------------------------------------------------- */ + 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: +
+ + +
+
+
+ + Persistence: localStorage key 'lt_activeTab_' + ---------------------------------------------------------------- */ + 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 , hidden by default): + + ---------------------------------------------------------------- */ + 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 titleStr = name + ' v1.0'; + const innerWidth = 43; + const leftPad = Math.max(0, Math.floor((innerWidth - titleStr.length) / 2)); + const rightPad = Math.max(0, innerWidth - titleStr.length - leftPad); + const messages = [ + '╔═══════════════════════════════════════════╗', + '║' + ' '.repeat(leftPad) + titleStr + ' '.repeat(rightPad) + '║', + '║ 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: + + ---------------------------------------------------------------- */ + 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: + + 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 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 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: +
+ + ---------------------------------------------------------------- */ + 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)); diff --git a/deploy.sh b/deploy.sh index 187309a..f3a9a71 100755 --- a/deploy.sh +++ b/deploy.sh @@ -3,17 +3,17 @@ set -e echo "Deploying tinker_tickets to web server..." -# Deploy web_template (shared UI framework) +# Deploy web_template (shared UI framework) into the app directory so nginx serves it echo "Syncing web_template to web server..." -rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/web_template/ +rsync -avz --delete --exclude='.git' --exclude='node' --exclude='php' --exclude='python' --exclude='README.md' --exclude='Claude.md' /root/code/web_template/ root@10.10.10.45:/var/www/html/tinkertickets/web_template/ # Deploy to web server echo "Syncing to web server (10.10.10.45)..." -rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/ +rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' --exclude='web_template' ./ root@10.10.10.45:/var/www/html/tinkertickets/ # Set proper permissions on the web server echo "Setting proper file permissions..." -ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/web_template /var/www/html/tinkertickets && find /var/www/html/web_template /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/web_template /var/www/html/tinkertickets -type d -exec chmod 755 {} \;" +ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;" echo "Deployment to web server complete!" echo "Don't forget to commit and push your changes via VS Code when ready." \ No newline at end of file diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 47204d6..819589a 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -11,10 +11,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Create New Ticket - + - + + diff --git a/views/TicketView.php b/views/TicketView.php index 53a348b..3caebfa 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -50,10 +50,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Ticket #<?php echo $ticket['ticket_id']; ?> - + - + diff --git a/views/admin/ApiKeysView.php b/views/admin/ApiKeysView.php index 29d03e5..4f7a38b 100644 --- a/views/admin/ApiKeysView.php +++ b/views/admin/ApiKeysView.php @@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); API Keys - Admin - + - + +
diff --git a/views/admin/CustomFieldsView.php b/views/admin/CustomFieldsView.php index 2c15266..c525177 100644 --- a/views/admin/CustomFieldsView.php +++ b/views/admin/CustomFieldsView.php @@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Custom Fields - Admin - + - + diff --git a/views/admin/RecurringTicketsView.php b/views/admin/RecurringTicketsView.php index 525d50e..8bcbbea 100644 --- a/views/admin/RecurringTicketsView.php +++ b/views/admin/RecurringTicketsView.php @@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Recurring Tickets - Admin - + - + diff --git a/views/admin/TemplatesView.php b/views/admin/TemplatesView.php index 0e14aa4..6a32039 100644 --- a/views/admin/TemplatesView.php +++ b/views/admin/TemplatesView.php @@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Template Management - Admin - + - + diff --git a/views/admin/UserActivityView.php b/views/admin/UserActivityView.php index ef4405a..e45828d 100644 --- a/views/admin/UserActivityView.php +++ b/views/admin/UserActivityView.php @@ -9,10 +9,10 @@ User Activity - Admin - + - +
diff --git a/views/admin/WorkflowDesignerView.php b/views/admin/WorkflowDesignerView.php index a45a78b..e1c2bb9 100644 --- a/views/admin/WorkflowDesignerView.php +++ b/views/admin/WorkflowDesignerView.php @@ -12,10 +12,10 @@ $nonce = SecurityHeadersMiddleware::getNonce(); Workflow Designer - Admin - + - +