Compare commits

..

41 Commits

Author SHA1 Message Date
ce95e555d5 CSS class migrations: admin views and boot overlay fade-out
- Replace style.display with .is-hidden classList in ApiKeysView, CustomFieldsView, RecurringTicketsView
- Convert boot overlay fade-out from style.opacity to .boot-overlay--fade-out CSS class
- Add .boot-overlay--fade-out rule to dashboard.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:20:55 -04:00
f45ec9b0f7 CSS class migrations in CreateTicketView: duplicate warning, visibility groups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:18:16 -04:00
5a41ebf180 Convert ticket preview popup visibility to use .is-hidden CSS class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:16:49 -04:00
e35401d54e CSS class migrations for ticket page: tabs, visibility, markdown preview, uploads
- Switch tab show/hide from style.display to .tab-content.active CSS class
- Convert visibilityGroupsField, markdownPreview, uploadProgress to use .is-hidden class
- Replace comment text div style.display with classList.add/remove('is-hidden')
- Add .is-hidden utility class to ticket.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:13:55 -04:00
913e294f9d CSS class migrations: stat-card cursor, view toggle, bulk actions visibility
- Replace stat-card cursor:pointer inline style with CSS rule
- Convert view toggle (table/card) to use .is-hidden CSS class
- Convert bulk-actions and export-dropdown to use .is-visible class
- Add .is-hidden/.is-visible utility rules to dashboard.css
- Remove duplicate lt.keys.initDefaults() call from dashboard.js
- Remove redundant setTimeout from view mode restore
- Add lt.keys.initDefaults() to dashboard.js (was missing entirely)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 21:08:28 -04:00
28aa9e33ea Fix XSS: escape table data and sanitize sort/pagination URL params
- htmlspecialchars() on category, type, status in table rows
- htmlspecialchars() on data-status attributes in quick-action buttons
- Restrict $currentDir to 'asc'|'desc' to prevent class injection
- htmlspecialchars() on all http_build_query URLs in pagination and sort headers
- htmlspecialchars() on AuditLogView pagination URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:40:51 -04:00
31aa7d1b81 Fix JS SyntaxError breaking tabs, textarea scrolling, and XSS escaping
Bug fixes:
- ticket.js: Remove duplicate const textarea declaration inside showMentionSuggestions()
  (was redeclaring a parameter, causing SyntaxError that broke all tab switching)
- ticket.css: Add overflow:hidden + resize:none to disabled textarea so description
  shows full height without internal scrollbar (page scrolls instead)
- ticket.js: Trigger height recalculation when entering edit mode on description

XSS/escaping fixes:
- TicketView.php: htmlspecialchars() on description textarea content (closes </textarea> injection risk)
- TicketView.php: htmlspecialchars() on ticket status and workflow transition status strings
- DashboardView.php: htmlspecialchars() on $cat/$type in input value= attributes
- RecurringTicketsView.php: htmlspecialchars() on composed schedule string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:34:55 -04:00
7695c6134c Accessibility pass: ARIA roles, label associations, CSS class migrations
- Add role=dialog/aria-modal/aria-labelledby to all 12 modal overlays (JS + PHP)
- Add aria-label="Close" to all 14 modal close buttons
- Add full ARIA combobox pattern to @mention autocomplete (listbox, option, aria-selected, aria-expanded)
- Add for= attributes to admin filter form labels (AuditLog, UserActivity, ApiKeys)
- Remove dead closeOnAdvancedSearchBackdropClick() from advanced-search.js

CSS/JS style cleanup:
- Move .ascii-banner static styles from JS inline to CSS class; add .ascii-banner--glow
- Add .ascii-banner-cursor, .loading-overlay--hiding, .has-overlay, tr[data-clickable]
- Add .animate-fadein/.animate-fadeout/.comment--deleting to ticket.css
- Add .lt-toast--hiding to base.css; remove opacity/transition inline JS
- Remove redundant cursor:pointer JS (already in th{} CSS rule)
- Remove trailing space in lt-select class attributes

Bug fixes:
- base.js: boot overlay opacity inline style was overriding .fade-out class opacity via
  specificity (1000 vs 20), preventing the fade-out animation — removed
- ascii-banner.js: cursor used blink-caret (border-color only) instead of blink-cursor
  (opacity-based), so the █ cursor never actually blinked — fixed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 20:29:58 -04:00
11f75fd823 Migrate all raw fetch() calls to lt.api, fix CSS fallback values
- Replace all 23 raw fetch() calls in dashboard.js and ticket.js with
  lt.api.get/post/delete — removes manual CSRF header injection,
  manual JSON parsing boilerplate, and response.ok checks throughout
- dashboard.js: 10 calls (inline save x2, template GET, 5x bulk ops,
  quick-status, quick-assign)
- ticket.js: 13 calls (main save, add/update/delete comment x3, reply,
  assign, metadata update, status change, deps GET/POST/DELETE,
  attachments GET, delete attachment)
- Remove stale csrf_token from deleteAttachment body (lt.api sends the
  X-CSRF-Token header automatically)
- Fix CSS variable fallbacks in ticket.css: replace
  var(--text-primary, #f7fafc) and var(--bg-secondary, #1a202c)
  with plain var(--text-primary) and var(--bg-secondary)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:27:46 -04:00
e179709fc3 Add lt.autoRefresh, fix showToast in admin, clean up inline styles
- Replace all 8 showToast() calls in ApiKeysView.php with lt.toast.*
  — all toast calls in the codebase now use lt.toast directly
- Add .duplicate-list, .duplicate-meta, .duplicate-hint CSS classes to
  dashboard.css; replace inline styles in duplicate detection JS with them
- Add dashboardAutoRefresh() using lt.autoRefresh — reloads page every
  5 minutes, skipping if a modal is open or user is typing in an input
- Add REFRESH button to dashboard header that triggers lt.autoRefresh.now()
  for immediate manual refresh with timer restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:16:18 -04:00
b03a9cfc8c Extract hardcoded rgba colors and inline styles to CSS classes
- Add .inline-error and .inline-warning utility classes to dashboard.css
  with correctly-matched terminal palette rgba values (replaces off-palette
  rgba(231,76,60,0.1) and rgba(241,196,15,0.1))
- Add .key-generated-alert class for the new API key display frame
- Add base .dependency-item, .dependency-group h4, .dependency-item a,
  .dependency-title, .btn-small overrides to ticket.css
- Remove all inline styles from the dependency list template in ticket.js
  — layout, colors, and sizing now come from CSS classes
- Update CreateTicketView.php and ApiKeysView.php to use the new classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:08:52 -04:00
d44a530018 Extend lt.time.ago() to ticket view, replace showToast with lt.toast
- Add data-ts attributes to TicketView.php: ticket created/updated
  header, comment dates (inner span to preserve edited indicator),
  and all activity timeline dates
- Add initRelativeTimes() to ticket.js using lt.time.ago(); runs on
  DOMContentLoaded and every 60s to keep relative times current
- Attachment dates now use lt.time.ago() with full date in title attr
  and ts-cell span for periodic refresh
- Replace all 11 showToast() calls in ticket.js with lt.toast.* directly,
  removing reliance on the backwards-compat shim for these paths
- Add span.ts-cell and td.ts-cell CSS to both dashboard.css and ticket.css:
  dotted underline + cursor:help signals the title tooltip is available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:03:34 -04:00
3c3b9d0a61 Integrate lt.time.ago() for dashboard timestamps, update README
- Add data-ts attributes to table and card view date cells so JS can
  convert them to relative time ("2h ago") while keeping the full date
  in the title attribute for hover tooltips
- Add initRelativeTimes() in dashboard.js using lt.time.ago(); runs on
  DOMContentLoaded and refreshes every 60s so times stay current
- Fix table sort for date columns to read data-ts attribute instead of
  text content (which is now relative and not sortable as a date)
- Update README: add base.css/base.js/utils.js to project structure,
  fix ascii-banner.js description, expand keyboard shortcuts table,
  add developer notes for lt.time and boot sequence behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:52:59 -04:00
1046537429 Move ASCII banner into boot sequence, fix remaining UI issues
- Remove collapsible ASCII banner from dashboard (was cluttering the UI)
- Show ASCII banner in the boot overlay on first session visit, above
  the boot messages, with a 400ms pause before messages begin
- Add scroll fade indicator (green-tinted gradient edges) to .table-wrapper
  so users can see when the table is horizontally scrollable
- Fix null guards for tab switcher in ticket.js (tabEl, activeBtn)
- Fix Reset → RESET uppercase in AuditLogView and UserActivityView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:41:57 -04:00
d8220da1e0 Make dashboard table horizontally scrollable at smaller screen widths
- Set .table-wrapper to overflow-x: auto with touch scrolling support
- Add min-width: 900px to table to trigger scroll before columns collapse
- Set .ascii-frame-outer overflow-x: visible to avoid clipping conflict

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:35:45 -04:00
021c01b3d4 Polish: uppercase all admin view button text
- AuditLogView.php: FILTER, RESET
- UserActivityView.php: APPLY, RESET
- ApiKeysView.php: GENERATE KEY, COPY, REVOKE
- WorkflowDesignerView.php: + NEW TRANSITION, EDIT, DELETE, SAVE, CANCEL
- CustomFieldsView.php: + NEW FIELD, EDIT, DELETE, SAVE, CANCEL
- TemplatesView.php: + NEW TEMPLATE, EDIT, DELETE, SAVE, CANCEL
- RecurringTicketsView.php: + NEW RECURRING TICKET, EDIT, DISABLE/ENABLE, DELETE, SAVE, CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:27:18 -04:00
22cab10d5d Polish: uppercase remaining modal and pagination button text
- DashboardView.php: settings modal SAVE PREFERENCES/CANCEL, advanced search SEARCH/RESET/CANCEL
- DashboardView.php: pagination prev/next add [ « ] and [ » ] brackets
- TicketView.php: settings modal SAVE PREFERENCES/CANCEL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:19:13 -04:00
f0d7b9aa61 Polish: uppercase all remaining mixed-case button text
- DashboardView.php: APPLY FILTERS, CLEAR ALL, SEARCH, CHANGE STATUS, ASSIGN, PRIORITY, CLEAR, EXPORT SELECTED
- CreateTicketView.php: CREATE TICKET, CANCEL
- ticket.js: SAVE, CANCEL, REMOVE, REPLY in dynamically-generated HTML templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:14:41 -04:00
3493ed78f8 Polish: uppercase button text, ASCII-safe stat icons and boot sequence
- TicketView.php: 'Edit Ticket' → 'EDIT TICKET'
- DashboardView.php: '+ New Ticket' → '+ NEW TICKET'
- DashboardView.php: stat-icon [ ✓ ] → [ OK ] (ASCII-safe)
- DashboardView.php: boot sequence '> SYSTEM READY ✓' → '> SYSTEM READY [OK]'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:10:38 -04:00
90c5b3ff71 UI/UX polish: terminal design system alignment pass
Views:
- DashboardView.php: remove hardcoded [ ] from admin-badge button (CSS adds them)
- DashboardView.php: view toggle ≡/▦ → [ = ]/[ # ] (view-btn suppresses auto-brackets)
- DashboardView.php: clear-search ✗ → [ X ] (plain text, no auto-brackets on <a>)
- DashboardView.php: remove ↓ arrow emoji from export button text
- TicketView.php: tab labels → UPPERCASE (tab-btn CSS adds [ ] around text)
- TicketView.php: Edit Ticket/Clone/Add Comment/Add → title-case → UPPERCASE
- TicketView.php: reply button ↩ → [ << ] (comment-action-btn has no auto-brackets)

JavaScript:
- dashboard.js: modal/action button text all → UPPERCASE (CONFIRM/CANCEL/SAVE/ASSIGN/UPDATE/DELETE PERMANENTLY)
- dashboard.js: null guard in loadTemplate(), toggleSelectAll()
- ticket.js: null guards in addDependency(), handleFileUpload()

CSS:
- dashboard.css: z-index 1001/1002 magic numbers → var(--z-modal)/var(--z-popover)
- ticket.css: status-select hover/focus border rgba(white) → terminal palette

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:50:59 -04:00
84bea80abd Fix PHP parse error and CSS/JS follow-on fixes
- DashboardView.php: fix PHP parse error on line 456/472/473/474 caused by
  escaped double-quotes {$row[\"key\"]} inside double-quoted echo strings;
  replaced with safe string concatenation . $row['key'] .
- ticket.css: fix status-select hover/focus border rgba(white) → terminal palette
- ticket.js: add null guards to addComment, togglePreview, updatePreview,
  toggleMarkdownMode, and addDependency element lookups

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:44:08 -04:00
2f9af856dc Fix design system violations: replace off-brand colors with terminal palette
- dashboard.css: replace all hardcoded Tailwind hex colors (#2d3748, #1a202c,
  #e2e8f0, #4a5568, #007cba, #3b82f6 etc.) in dark-mode sections and component
  styles with terminal CSS variables (--bg-*, --text-*, --border-color,
  --terminal-green/amber)
- dashboard.css: fix card-priority colors white/black → var(--bg-primary)
- dashboard.css: fix card-assignee border-radius: 50% → 0 (no circles rule)
- dashboard.css: fix mobile bottom-sheet border-radius: 12px → 0
- dashboard.css: fix search-box focus border (#007cba → var(--terminal-green))
- dashboard.css: fix save-filter button blue (#3b82f6) → terminal green
- dashboard.css: fix search-results-info blue highlight → terminal green
- dashboard.css: fix btn-bulk/btn-secondary dark-mode bootstrap colors → terminal
- ticket.css: replace comprehensive dark-mode Tailwind hex block with CSS vars
- ticket.css: fix status-select white/black text → var(--bg-primary)
- ticket.css: fix status-select.status-resolved hardcoded #28a745 → var(--status-open)
- ticket.css: fix timeline dark-mode hardcoded colors → CSS vars
- ticket.css: fix .slider:before background white → var(--bg-primary)
- ticket.css: fix .btn-danger:hover color white → var(--bg-primary)
- ticket.css: fix visibility-groups-list label border-radius: 4px → 0
- ticket.css: add will-change: opacity to age-warning/age-critical animations
- views: bump CSS version strings to v=20260319c
- views/DashboardView.php: add aria-labels to card view quick action buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:37:19 -04:00
27075a62ee Fix bracket buttons rendering below text + UI/security improvements
CSS fixes:
- Fix [ ] brackets appearing below button text by replacing display:inline-flex
  with display:inline-block + white-space:nowrap on .btn — removes cross-browser
  flex pseudo-element inconsistency as root cause
- Remove conflicting .btn::before ripple block (position:absolute was overriding
  bracket content positioning)
- Remove overflow:hidden from .btn which was clipping bracket content
- Fix body::after duplicate rule causing GPU layer blink (second position:fixed
  rule re-created compositor layer, overriding display:none suppression)
- Replace all transition:all with scoped property transitions in dashboard.css,
  ticket.css, base.css (prevents full CSS property evaluation on every hover)
- Convert pulse-warning/pulse-critical keyframes from box-shadow to opacity
  animation (GPU-composited, eliminates CPU repaints at 60fps)
- Fix mobile *::before/*::after blanket content:none rule — now targets only
  decorative frame glyphs, preserving button brackets and status indicators
- Remove --terminal-green-dim override that broke .lt-btn hover backgrounds

JS fixes:
- Fix all lt.lt.toast.* double-prefix instances in dashboard.js
- Add null guard before .appendChild() on bulkAssignUser select
- Replace all remaining emoji with terminal bracket notation (dashboard.js,
  ticket.js, markdown.js)
- Migrate all toast.*() shim calls to lt.toast.* across all JS files

View fixes:
- Remove hardcoded [ ] brackets from .btn buttons (CSS now adds them)
- Replace all emoji with terminal bracket notation in all views and admin views
- Add missing CSP nonces to AuditLogView.php and UserActivityView.php script tags
- Bump CSS version strings to ?v=20260319b for cache busting

Security fixes:
- update_ticket.php: add authorization check (non-admins can only edit their own
  or assigned tickets)
- add_comment.php: validate and cast ticket_id to integer with 400 response
- clone_ticket.php: fix unconditional session_start(), add ticket ID validation,
  add internal ticket access check
- bulk_operation.php: add HTTP 401/403 status codes on auth failures
- upload_attachment.php: fix missing $conn arg in AttachmentModel constructor
- assign_ticket.php: add ticket existence check and permission verification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 22:20:43 -04:00
dd8833ee2f Restore visual effects using GPU-safe techniques (no repaint triggers)
Rules: transform/opacity = GPU composited (fine). box-shadow/text-shadow on
hover = CPU repaint (removed). Static box-shadow/text-shadow = painted once (fine).

- Buttons (.btn, .btn-base, button, .btn-primary): add will-change:transform
  for pre-promotion, add transform:translateY(-1px) on hover (GPU, no repaint),
  scope transition to include transform, remove box-shadow/text-shadow from hover
- Stat cards: add will-change:transform, add transform:translateY(-2px) on hover
- Priority badges: replace filter:blur(6px) ::after pseudo-element (permanent GPU
  layer per badge, ~20 on screen at once) with static box-shadow:0 0 6px currentColor
  on the badge itself — painted once, never changes, zero compositor overhead
- Links: replace opacity-transition ::after underline (lazy GPU layer creation on
  hover) with text-decoration:underline on hover (pure CPU paint, no GPU layer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 19:28:28 -04:00
ab3e77a9ba Fix blink root cause: eliminate position:fixed GPU compositing layers
Chrome promotes ALL position:fixed elements to GPU compositing layers for scroll
performance, regardless of whether they have animations. The body::before scanline
overlay (position:fixed, z-index:9999, full-viewport) and body::after watermark
(position:fixed) were both on GPU layers. Every CPU repaint from any hover state
change required a compositor re-blend pass → one-frame blink at compositor sync.

Fixes:
- Move scanlines from body::before (position:fixed) into body { background-image }
  — same visual, no separate element, no GPU layer promotion
- Set body::before { display:none } and body::after { display:none } in both
  dashboard.css and base.css
- Remove animation:matrix-rain from .stat-card:hover::before — background-position
  animation is not GPU-composited, caused CPU repaints every frame while hovered
  plus GPU texture uploads when animation started/stopped on cursor enter/exit
- Scope a { transition: all } → transition: color in base.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:23:30 -04:00
68ff89b48c Fix persistent blink: scanline animation still active via base.css cascade
Root cause: removing 'animation' from dashboard.css body::before did NOT disable
the scanline — it just stopped overriding base.css which still had
'animation: scanline 8s linear infinite'. CSS cascade means the base.css value
remained active. Fixed by setting 'animation: none' explicitly in dashboard.css.

Also fix base.css (used by all pages including ticket page):
- Set body::before animation: none (removes GPU compositing layer from scanline)
- Change corner-pulse/subtle-pulse/pulse-glow/pulse-red keyframes from text-shadow
  and box-shadow animations to opacity (GPU composited, zero CPU repaint overhead)
- Change exec-running-pulse from box-shadow to opacity
- Remove box-shadow from .lt-table tr:hover, .lt-card:hover, .lt-stat-card:hover
- Remove text-shadow/box-shadow/transform from .lt-btn:hover and variants
- Remove text-shadow from a:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:12:44 -04:00
328c103460 Fix ascii-frame-outer blink: eliminate all repaint-causing hover effects
- Change pulse-glow keyframes from text-shadow animation to opacity (GPU composited,
  eliminates 60fps CPU repaint that was the likely root cause of the persistent blink)
- Remove box-shadow from .quick-action-btn:hover; scope transition: all → background/color
- Remove box-shadow + background gradient + transform:translateY from .stat-card:hover;
  scope transition: all → border-color only
- Remove .stat-card::after transition and hover background change
- Remove duplicate .stat-card:hover transform:translateY block
- Remove box-shadow from .clear-search-btn:hover; scope transition: all → background/color
- Remove text-shadow from th transition (th:hover never changes text-shadow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:06:39 -04:00
21ef9154e9 Fix ascii-frame-outer blink: remove scanline GPU layer and remaining repaint triggers
- Remove body::before scanline animation (transform: translateY promoted it to a
  GPU compositing layer; CPU repaints from hover states required compositor re-blend,
  causing one-frame blink at compositor sync boundary)
- Remove text-shadow and transform: translateY(-2px) from .btn-primary:hover/.create-ticket:hover
- Scope .btn-primary transition from 'all' to specific composited properties
- Remove box-shadow: inset from .banner-toggle:hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:57:47 -04:00
4ecd72bc04 Strip all box-shadow/text-shadow from hover states inside ascii-frame-outer
These non-composited properties force CPU repaints of the surrounding
paint region (the section itself) every time hover enters or exits.
With the fixed overlay (body::before scanline), each such repaint
requires the compositor to re-blend the layer, visible as a blink.

Removed from dashboard.css:
- btn/button/btn-base:hover: box-shadow + text-shadow
- th:hover: text-shadow
- ticket-link:hover: text-shadow
- pagination button:hover: box-shadow + transform + transition:all
- ticket-card-row:hover: box-shadow + transition:all -> background only
- .btn ripple rule: transition:all -> specific properties
- ascii-frame-outer: removed will-change/translateZ (GPU upload worse)

Removed from ticket.css:
- metadata-select:hover: box-shadow; transition:all -> border-color
- comment:hover: box-shadow
- btn:hover: box-shadow
- mention:hover: text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:50:06 -04:00
368ad9b48e Promote ascii-frame-outer to GPU layer to stop hover blink
The body::before scanline overlay (position:fixed, z-index:9999) requires
the compositor to re-blend over the section every time a CPU repaint
happens inside it. Hover state entry/exit triggers these repaints, causing
a visible blink as the compositor flushes.

Fixes:
- Add will-change:transform + transform:translateZ(0) to ascii-frame-outer
  to promote it to its own GPU compositing layer, isolating its repaints
  from the scanline compositing pass
- Convert corner-pulse and subtle-pulse from text-shadow (CPU repaint)
  to opacity (GPU composited) to eliminate continuous repaint pressure
  inside the section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:35:38 -04:00
3497c4cb47 Fix ascii-frame-outer blink: remove compositor layer thrashing on hover
Four root causes removed:
- body { transition: all } — forced browser to check all CSS properties
  on every hover event across the entire page
- a:not(.btn)::after underline: width+box-shadow transition replaced with
  opacity transition — width repaints paint layer, box-shadow forced parent
  section repaint; opacity is GPU-composited and doesn't repaint ancestors
- .ticket-link:hover { transform: translateX } — created/destroyed GPU
  compositor layer on every ticket ID hover; removed, scoped transition
  to specific non-layout properties
- .btn:hover { transform: translateY } in ticket.css — same layer issue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:25:18 -04:00
e756f8e0bb Fix ascii-frame-outer blink caused by JS/CSS hover conflict
JS mouseenter/mouseleave handlers were setting row.style.backgroundColor
inline, fighting with the CSS tr:hover rule. On mouseleave both fired
simultaneously causing a double repaint / blink. Removed the redundant
JS handlers — the CSS tr:hover transition already handles this cleanly.

Also removed body flicker animation from base.css (was still present
after being removed from dashboard.css).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:14:05 -04:00
fea7575ac8 Fix dashboard blink when cursor leaves ticket table section
- Remove pulse-glow-box animation and translateY from button:hover
  (infinite animation stopping abruptly caused a flash on mouse-leave)
- Scope button transition from 'all' to specific visual properties
  (prevents transform/layout changes from triggering on hover exit)
- Scope th transition from 'all' to background-color + text-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:10:27 -04:00
6fbba3939f Remove page-blinking CSS animations and old deploy script
- Remove .ascii-frame-outer:hover flicker animation (caused article to
  shake/blink every time cursor entered the ticket container)
- Remove body flicker animation (caused full page blink every 30s)
- Remove deploy.sh (deployment now handled by Gitea CI/CD)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:04:03 -04:00
f3c15e2582 Fix table row blink when cursor leaves the table
transition:all was firing on every row simultaneously when the cursor
left the table. Scoped it to background-color only. Also removed the
inset box-shadow from tr:hover which forced repaint layer thrashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:55:50 -04:00
51fa5a8a3c Add lt.keys.initDefaults() to audit log and user activity views
Ensures ESC/Ctrl+K/? keyboard shortcuts work consistently on all admin pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:50 -04:00
4a838b68ca 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 <noreply@anthropic.com>
2026-03-17 23:44:46 -04:00
5545328e53 Fix deploy.sh to also sync web_template to server
base.js and base.css were returning 404 because /var/www/html/web_template
did not exist on the server. Now rsyncs /root/code/web_template/ to
/var/www/html/web_template/ before deploying the app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:36:55 -04:00
8bb43c14db Guard lt.* calls when base.js unavailable to prevent crash
Wraps all lt.keys.initDefaults() calls in `if (window.lt)` guards across
6 view files. Adds `if (!window.lt) return` bail-out in keyboard-shortcuts.js
and `if (window.lt)` guard in settings.js DOMContentLoaded handler.

This prevents TypeError crashes when /web_template/base.js returns 404,
which was causing the admin menu click delegation to never register.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:34:59 -04:00
92544d60ce Fix lt-modal-overlay not hidden without base.css
Add lt-modal-overlay, lt-modal, lt-btn fallback styles to dashboard.css
so modals are properly hidden (display:none) and styled even when
/web_template/base.css is not yet served. Mirrors the rules from base.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:29:20 -04:00
89a685a502 Integrate web_template design system and fix security/quality issues
Security fixes:
- Add HTTP method validation to delete_comment.php (block CSRF via GET)
- Remove $_GET fallback in comment deletion (was CSRF bypass vector)
- Guard session_start() with session_status() check across API files
- Escape json_encode() data attributes with htmlspecialchars in views
- Escape inline APP_TIMEZONE config values in DashboardView/TicketView
- Validate timezone param against DateTimeZone::listIdentifiers() in index.php
- Remove Database::escape() (was using real_escape_string, not safe)
- Fix AttachmentModel hardcoded connection; inject via constructor

Backend fixes:
- Fix CommentModel bind_param type for ticket_id (s→i)
- Fix buildCommentThread orphan parent guard
- Fix StatsModel JOIN→LEFT JOIN so unassigned tickets aren't excluded
- Add ticket ID validation in BulkOperationsModel before implode()
- Add duplicate key retry in TicketModel::createTicket() for race conditions
- Wrap SavedFiltersModel default filter changes in transactions
- Add null result guards in WorkflowModel query methods

Frontend JS:
- Rewrite toast.js as lt.toast shim (base.js dependency)
- Delegate escapeHtml() to lt.escHtml()
- Rewrite keyboard-shortcuts.js using lt.keys.on()
- Migrate settings.js to lt.api.* and lt.modal.open/close()
- Migrate advanced-search.js to lt.api.* and lt.modal.open/close()
- Migrate dashboard.js fetch calls to lt.api.*; update all dynamic
  modals (bulk ops, quick actions, confirm/input) to lt-modal structure
- Migrate ticket.js fetchMentionUsers to lt.api.get()
- Remove console.log/error/warn calls from JS files

Views:
- Add /web_template/base.css and base.js to all 10 view files
- Call lt.keys.initDefaults() in DashboardView, TicketView, admin views
- Migrate all modal HTML from settings-modal/settings-content to
  lt-modal-overlay/lt-modal/lt-modal-header/lt-modal-body/lt-modal-footer
- Replace style="display:none" with aria-hidden="true" on all modals
- Replace modal open/close style.display with lt.modal.open/close()
- Update modal buttons to lt-btn lt-btn-primary/lt-btn-ghost classes
- Remove manual ESC keydown handlers (replaced by lt.keys.initDefaults)
- Fix unescaped timezone values in TicketView inline script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:22:24 -04:00
44 changed files with 4970 additions and 2610 deletions

View File

@@ -126,6 +126,11 @@ Access all admin pages via the **Admin dropdown** in the dashboard header.
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
| `Ctrl/Cmd + S` | Save changes (ticket page) |
| `Ctrl/Cmd + K` | Focus search box (dashboard) |
| `N` | New ticket (dashboard) |
| `J` / `K` | Next / previous row (dashboard table) |
| `Enter` | Open selected ticket (dashboard) |
| `G` then `D` | Go to dashboard |
| `1``4` | Quick status change (ticket page) |
| `ESC` | Cancel edit / close modal |
| `?` | Show keyboard shortcuts help |
@@ -242,17 +247,20 @@ tinker_tickets/
│ └── upload_attachment.php # GET/POST: List or upload attachments
├── assets/
│ ├── css/
│ │ ├── base.css # LotusGuild Terminal Design System (symlinked from web_template)
│ │ ├── dashboard.css # Dashboard + terminal styling
│ │ └── ticket.css # Ticket view styling
│ ├── js/
│ │ ├── advanced-search.js # Advanced search modal
│ │ ├── ascii-banner.js # ASCII art banner
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
│ │ ├── base.js # LotusGuild JS utilities — window.lt (symlinked from web_template)
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
│ │ ├── settings.js # User preferences
│ │ ├── ticket.js # Ticket + comments + visibility
│ │ ── toast.js # Toast notifications
│ │ ── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
│ └── images/
│ └── favicon.png
├── config/
@@ -387,6 +395,14 @@ Key conventions and gotchas for working with this codebase:
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60s; full date is always in the `title` attribute for hover
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
24. **CSS utility classes** (dashboard.css): Use `.text-green`, `.text-amber`, `.text-cyan`, `.text-danger`, `.text-open`, `.text-closed`, `.text-muted`, `.text-muted-green`, `.text-sm`, `.text-center`, `.nowrap`, `.mono`, `.fw-bold`, `.mb-1` for typography. Use `.admin-container` (1200px) or `.admin-container-wide` (1400px) for admin page max-widths. Use `.admin-header-row` for title+button header rows. Use `.admin-form-row`, `.admin-form-field`, `.admin-label`, `.admin-input` for filter forms. Use `.admin-form-actions` for filter form button groups. Use `.table-wrapper` + `td.empty-state` for tables. Use `.setting-grid-2` and `.setting-grid-3` for modal form grids. Use `.lt-modal-sm` or `.lt-modal-lg` for modal sizing.
25. **CSS utility classes** (ticket.css): Use `.form-hint` (green helper text), `.form-hint-warning` (amber helper text), `.visibility-groups-list` (checkbox row), `.group-checkbox-label` (checkbox label) for ticket forms.
## File Reference

View File

@@ -30,7 +30,9 @@ try {
require_once dirname(__DIR__) . '/helpers/Database.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -60,7 +62,14 @@ try {
throw new Exception("Invalid JSON data received");
}
$ticketId = $data['ticket_id'];
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0;
if ($ticketId <= 0) {
http_response_code(400);
ob_end_clean();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
// Initialize models
$commentModel = new CommentModel($conn);

View File

@@ -6,10 +6,17 @@ require_once dirname(__DIR__) . '/models/UserModel.php';
// Get request data
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = $data['ticket_id'] ?? null;
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
exit;
}
$ticketId = isset($data['ticket_id']) ? (int)$data['ticket_id'] : null;
$assignedTo = $data['assigned_to'] ?? null;
if (!$ticketId) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
exit;
}
@@ -18,6 +25,21 @@ $ticketModel = new TicketModel($conn);
$auditLogModel = new AuditLogModel($conn);
$userModel = new UserModel($conn);
// Verify ticket exists
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket) {
http_response_code(404);
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
exit;
}
// Authorization: only admins or the ticket creator/assignee can reassign
if (!$isAdmin && $ticket['created_by'] !== $userId && $ticket['assigned_to'] !== $userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
if ($assignedTo === null || $assignedTo === '') {
// Unassign ticket
$success = $ticketModel->unassignTicket($ticketId, $userId);
@@ -40,4 +62,9 @@ if ($assignedTo === null || $assignedTo === '') {
}
}
echo json_encode(['success' => $success]);
if (!$success) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to update ticket assignment']);
} else {
echo json_encode(['success' => true]);
}

View File

@@ -14,6 +14,7 @@ header('Content-Type: application/json');
// Check authentication
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
exit;
}
@@ -32,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check admin status - bulk operations are admin-only
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
if (!$isAdmin) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Admin access required']);
exit;
}

View File

@@ -17,7 +17,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Authentication required']);
@@ -50,8 +52,14 @@ try {
exit;
}
$sourceTicketId = $data['ticket_id'];
$sourceTicketId = (int)$data['ticket_id'];
if ($sourceTicketId <= 0) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
exit;
}
$userId = $_SESSION['user']['user_id'];
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
// Get database connection
$conn = Database::getConnection();
@@ -66,6 +74,15 @@ try {
exit;
}
// Authorization: non-admins cannot clone internal tickets unless they created/are assigned
if (!$isAdmin && ($sourceTicket['visibility'] ?? 'public') === 'internal') {
if ($sourceTicket['created_by'] != $userId && $sourceTicket['assigned_to'] != $userId) {
http_response_code(403);
echo json_encode(['success' => false, 'error' => 'Permission denied']);
exit;
}
}
// Prepare cloned ticket data
$clonedTicketData = [
'title' => '[CLONE] ' . $sourceTicket['title'],

View File

@@ -58,7 +58,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);

View File

@@ -21,8 +21,19 @@ try {
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
$method = $_SERVER['REQUEST_METHOD'];
if ($method !== 'POST' && $method !== 'DELETE') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -48,9 +59,9 @@ try {
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['comment_id'])) {
// Try query params
if (isset($_GET['comment_id'])) {
$data = ['comment_id' => $_GET['comment_id']];
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
if (isset($_POST['comment_id'])) {
$data = ['comment_id' => $_POST['comment_id']];
} else {
throw new Exception("Missing required field: comment_id");
}

View File

@@ -33,7 +33,7 @@ if (!$attachmentId || !is_numeric($attachmentId)) {
$attachmentId = (int)$attachmentId;
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel(Database::getConnection());
// Get attachment details
$attachment = $attachmentModel->getAttachment($attachmentId);

View File

@@ -22,7 +22,9 @@ try {
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}

View File

@@ -28,7 +28,9 @@ try {
require_once $workflowModelPath;
// Check authentication via session
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
throw new Exception("Authentication required");
}
@@ -77,6 +79,17 @@ try {
];
}
// Authorization: admins can edit any ticket; others only their own or assigned
if (!$this->isAdmin
&& $currentTicket['created_by'] != $this->userId
&& $currentTicket['assigned_to'] != $this->userId
) {
return [
'success' => false,
'error' => 'Permission denied'
];
}
// Merge current data with updates, keeping existing values for missing fields
$updateData = [
'ticket_id' => $id,

View File

@@ -46,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
}
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel(Database::getConnection());
$attachments = $attachmentModel->getAttachments($ticketId);
// Add formatted file size and icon to each attachment
@@ -155,7 +155,7 @@ if (empty($originalFilename)) {
// Save to database
try {
$attachmentModel = new AttachmentModel();
$attachmentModel = new AttachmentModel($conn);
$attachmentId = $attachmentModel->addAttachment(
$ticketId,
$uniqueFilename,

1701
assets/css/base.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -262,12 +262,11 @@
color: var(--terminal-green);
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.2s ease;
transition: border-color 0.2s ease;
}
.metadata-select:hover {
border-color: var(--terminal-amber);
box-shadow: var(--glow-amber);
}
.metadata-select:focus {
@@ -346,24 +345,28 @@ textarea[data-field="description"]:not(:disabled)::after {
color: var(--terminal-amber);
border-color: var(--terminal-amber);
background: rgba(255, 176, 0, 0.1);
box-shadow: 0 0 6px rgba(255, 176, 0, 0.4);
animation: pulse-warning 2s ease-in-out infinite;
will-change: opacity;
}
.ticket-age.age-critical {
color: var(--priority-1);
border-color: var(--priority-1);
background: rgba(255, 77, 77, 0.15);
box-shadow: 0 0 8px rgba(255, 77, 77, 0.5);
animation: pulse-critical 1s ease-in-out infinite;
will-change: opacity;
}
@keyframes pulse-warning {
0%, 100% { box-shadow: 0 0 5px rgba(255, 176, 0, 0.3); }
50% { box-shadow: 0 0 15px rgba(255, 176, 0, 0.6); }
0%, 100% { opacity: 0.75; }
50% { opacity: 1; }
}
@keyframes pulse-critical {
0%, 100% { box-shadow: 0 0 5px rgba(255, 77, 77, 0.3); }
50% { box-shadow: 0 0 20px rgba(255, 77, 77, 0.8); }
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
/* Tab transition animations */
@@ -463,6 +466,50 @@ textarea[data-field="description"]:not(:disabled)::after {
}
/* Form Elements */
/* Helper text below form fields */
.form-hint {
color: var(--terminal-green);
font-family: var(--font-mono);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.form-hint-warning {
color: var(--terminal-amber);
font-family: var(--font-mono);
font-size: 0.85rem;
margin-top: 0.5rem;
}
/* Visibility group checkbox row */
.visibility-groups-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.group-checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
/* Duplicate warning box and visibility groups (JS-toggled, need margin when visible) */
#duplicateWarning {
margin-top: 1rem;
}
#visibilityGroupsContainer {
margin-top: 1rem;
}
/* Duplicate found heading */
.duplicate-heading {
margin-bottom: 0.5rem;
}
.detail-group {
margin-bottom: 30px;
padding: 15px;
@@ -508,7 +555,7 @@ textarea[data-field="description"]:not(:disabled)::after {
border-radius: 0;
background: var(--bg-primary);
color: var(--text-primary);
transition: all 0.3s ease;
transition: border-color 0.2s ease;
}
input.editable {
@@ -537,6 +584,8 @@ textarea.editable {
background: var(--bg-secondary);
cursor: default;
border-color: transparent;
overflow: hidden;
resize: none;
}
/* Button Styles */
@@ -548,7 +597,7 @@ textarea.editable {
font-weight: 500;
background: var(--bg-primary);
color: var(--text-primary);
transition: all 0.3s ease;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.btn.primary {
@@ -564,8 +613,6 @@ textarea.editable {
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Comments Section - TERMINAL STYLE */
@@ -629,7 +676,7 @@ textarea.editable {
margin-bottom: 1rem;
position: relative;
box-shadow: none;
transition: all 0.3s ease;
transition: border-color 0.2s ease;
animation: comment-appear 0.4s ease-out;
}
@@ -646,8 +693,6 @@ textarea.editable {
.comment:hover {
border-color: var(--terminal-amber);
background: linear-gradient(135deg, var(--bg-primary) 0%, rgba(255, 176, 0, 0.03) 100%);
box-shadow: 0 0 15px rgba(0, 255, 65, 0.1);
}
.comment:hover::before,
@@ -764,13 +809,16 @@ textarea.editable {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
font-family: var(--font-mono);
line-height: 1;
}
.comment-action-btn:hover {
.comment-action-btn:hover,
.comment-action-btn:focus-visible {
background: rgba(0, 255, 65, 0.1);
outline: 2px solid var(--terminal-amber);
outline-offset: 2px;
}
.comment-action-btn.edit-btn:hover {
@@ -942,6 +990,11 @@ textarea.editable {
}
}
.animate-fadein { animation: fadeIn 0.3s ease; }
.is-hidden { display: none !important; }
.animate-fadeout { animation: fadeIn 0.2s ease reverse; }
.comment--deleting { opacity: 0; transform: translateX(-20px); transition: opacity 0.3s, transform 0.3s; }
.reply-form-container .reply-header {
display: flex;
justify-content: space-between;
@@ -1059,7 +1112,7 @@ textarea.editable {
font-size: 1em;
font-family: var(--font-mono);
color: var(--terminal-green);
transition: all 0.3s ease;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
position: relative;
margin-right: -2px;
}
@@ -1074,9 +1127,12 @@ textarea.editable {
color: var(--terminal-green);
}
.tab-btn:hover {
.tab-btn:hover,
.tab-btn:focus-visible {
background: rgba(0, 255, 65, 0.05);
color: var(--terminal-amber);
outline: 2px solid var(--terminal-amber);
outline-offset: -2px;
}
.tab-btn.active {
@@ -1159,7 +1215,7 @@ textarea.editable {
right: 0;
bottom: 0;
background-color: var(--bg-secondary);
transition: .4s;
transition: background-color 0.4s ease;
}
.slider:before {
@@ -1169,8 +1225,8 @@ textarea.editable {
width: 16px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
background-color: var(--bg-primary);
transition: transform 0.4s ease;
}
.slider.round {
@@ -1328,24 +1384,24 @@ input:checked + .slider:before {
}
body.dark-mode .timeline-content {
--card-bg: #2d3748;
--border-color: #444;
--text-muted: #a0aec0;
--text-secondary: #cbd5e0;
background: #2d3748;
color: #f7fafc;
--card-bg: var(--bg-tertiary);
--border-color: var(--border-color);
--text-muted: var(--text-muted);
--text-secondary: var(--text-secondary);
background: var(--bg-tertiary);
color: var(--text-primary);
}
body.dark-mode .timeline-header strong {
color: #f7fafc;
color: var(--text-primary);
}
body.dark-mode .timeline-action {
color: #a0aec0;
color: var(--text-muted);
}
body.dark-mode .timeline-date {
color: #718096;
color: var(--text-secondary);
}
/* Status select dropdown */
.status-select {
@@ -1357,38 +1413,38 @@ body.dark-mode .timeline-date {
letter-spacing: 0.5px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.status-select:hover {
opacity: 0.9;
border-color: rgba(255, 255, 255, 0.3);
border-color: rgba(0, 255, 65, 0.4);
}
.status-select:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.5);
border-color: var(--terminal-amber);
}
/* Status colors for dropdown */
.status-select.status-open {
background-color: var(--status-open) !important;
color: white !important;
color: var(--bg-primary) !important;
}
.status-select.status-in-progress {
background-color: var(--status-in-progress) !important;
color: #212529 !important;
color: var(--bg-primary) !important;
}
.status-select.status-closed {
background-color: var(--status-closed) !important;
color: white !important;
color: var(--bg-primary) !important;
}
.status-select.status-resolved {
background-color: #28a745 !important;
color: white !important;
background-color: var(--status-open) !important;
color: var(--bg-primary) !important;
}
/* Dropdown options inherit colors */
@@ -1399,66 +1455,56 @@ body.dark-mode .timeline-date {
}
body.dark-mode .status-select option {
background-color: #2d3748;
color: #f7fafc;
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
/* Dark mode for Activity tab and general improvements */
body.dark-mode .tab-content {
color: var(--text-primary, #f7fafc);
color: var(--text-primary);
}
body.dark-mode #activity-tab {
background: var(--bg-secondary, #1a202c);
color: var(--text-primary, #f7fafc);
background: var(--bg-secondary);
color: var(--text-primary);
}
body.dark-mode #activity-tab p {
color: var(--text-primary, #f7fafc);
color: var(--text-primary);
}
/* Comprehensive Dark Mode Fix - Ensure no white on white */
body.dark-mode {
--bg-primary: #1a202c;
--bg-secondary: #2d3748;
--bg-tertiary: #4a5568;
--text-primary: #e2e8f0;
--text-secondary: #cbd5e0;
--text-muted: #a0aec0;
--border-color: #4a5568;
--card-bg: #2d3748;
}
/* Comprehensive Dark Mode Fix - terminal CSS variables apply throughout */
/* Ensure ticket container has dark background */
body.dark-mode .ticket-container {
background: #1a202c !important;
color: #e2e8f0 !important;
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
}
/* Ensure all ticket details sections are dark */
body.dark-mode .ticket-details {
background: #1a202c !important;
color: #e2e8f0 !important;
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
}
/* Ensure detail groups are dark */
body.dark-mode .detail-group {
background: transparent !important;
color: #e2e8f0 !important;
color: var(--text-primary) !important;
}
/* Ensure labels are visible */
body.dark-mode .detail-group label,
body.dark-mode label {
color: #cbd5e0 !important;
color: var(--text-secondary) !important;
}
/* Fix textarea and input fields */
body.dark-mode textarea,
body.dark-mode input[type="text"] {
background: #2d3748 !important;
color: #e2e8f0 !important;
border-color: #4a5568 !important;
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
/* Ensure timeline event backgrounds are dark */
@@ -1468,30 +1514,38 @@ body.dark-mode .timeline-event {
/* Fix any remaining white text issues */
body.dark-mode .timeline-details {
color: #cbd5e0 !important;
color: var(--text-secondary) !important;
background: transparent !important;
}
/* Fix comment sections */
body.dark-mode .comment {
background: #2d3748 !important;
color: #e2e8f0 !important;
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
body.dark-mode .comment-text {
color: #e2e8f0 !important;
color: var(--text-primary) !important;
}
body.dark-mode .comment-header {
color: #cbd5e0 !important;
color: var(--text-secondary) !important;
}
/* Fix any form elements */
body.dark-mode select,
body.dark-mode .editable {
background: #2d3748 !important;
color: #e2e8f0 !important;
border-color: #4a5568 !important;
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
/* ===== RELATIVE TIMESTAMP CELLS ===== */
span.ts-cell {
cursor: help;
border-bottom: 1px dotted var(--text-muted);
text-decoration: none;
}
/* ===== RESPONSIVE DESIGN - TERMINAL EDITION ===== */
@@ -1608,7 +1662,7 @@ body.dark-mode .editable {
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
transition: border-color 0.2s ease, background-color 0.2s ease;
background: var(--bg-primary);
}
@@ -1695,7 +1749,7 @@ body.dark-mode .editable {
padding: 0.75rem 1rem;
border: 1px solid var(--terminal-green);
background: var(--bg-primary);
transition: all 0.2s ease;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.attachment-item:hover {
@@ -1756,7 +1810,7 @@ body.dark-mode .editable {
.btn-danger:hover {
background: var(--priority-1);
color: white;
color: var(--bg-primary);
}
/* Mobile responsiveness for attachments */
@@ -1786,12 +1840,11 @@ body.dark-mode .editable {
border-radius: 0;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.15s ease;
}
.mention:hover {
background: rgba(0, 255, 255, 0.2);
text-shadow: 0 0 5px var(--terminal-cyan);
}
.mention::before {
@@ -1821,7 +1874,7 @@ body.dark-mode .editable {
cursor: pointer;
font-family: var(--font-mono);
color: var(--terminal-green);
transition: all 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -1862,7 +1915,7 @@ body.dark-mode .editable {
font-family: var(--font-mono);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
min-width: 32px;
}
@@ -1915,7 +1968,7 @@ body.dark-mode .editable {
color: var(--terminal-green);
text-decoration: none;
font-family: var(--font-mono);
transition: all 0.2s ease;
transition: background-color 0.15s ease, color 0.15s ease;
}
.export-dropdown-content a:hover {
@@ -2137,6 +2190,38 @@ body.dark-mode .editable {
font-family: var(--font-mono);
}
/* Dependency list items */
.dependency-group h4 {
color: var(--terminal-amber);
margin: 0.5rem 0;
}
.dependency-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-bottom: 1px dashed var(--terminal-green-dim);
}
.dependency-item a {
color: var(--terminal-green);
}
.dependency-item .dependency-title {
margin-left: 0.5rem;
}
.dependency-item .status-badge {
margin-left: 0.5rem;
font-size: 0.8rem;
}
.dependency-item .btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Upload progress */
.upload-progress {
margin-top: 1rem;
@@ -2645,7 +2730,7 @@ body.dark-mode .editable {
min-height: 44px;
padding: 0.5rem;
background: rgba(0, 255, 65, 0.05);
border-radius: 4px;
border-radius: 0;
}
}

View File

@@ -7,8 +7,7 @@
function openAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lt.modal.open('advancedSearchModal');
loadUsersForSearch();
populateCurrentFilters();
loadSavedFilters();
@@ -17,28 +16,13 @@ function openAdvancedSearch() {
// Close advanced search modal
function closeAdvancedSearch() {
const modal = document.getElementById('advancedSearchModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
}
// Close modal when clicking on backdrop
function closeOnAdvancedSearchBackdropClick(event) {
const modal = document.getElementById('advancedSearchModal');
if (event.target === modal) {
closeAdvancedSearch();
}
lt.modal.close('advancedSearchModal');
}
// Load users for dropdown
async function loadUsersForSearch() {
try {
const response = await fetch('/api/get_users.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/get_users.php');
if (data.success && data.users) {
const createdBySelect = document.getElementById('adv-created-by');
@@ -68,7 +52,7 @@ async function loadUsersForSearch() {
});
}
} catch (error) {
console.error('Error loading users:', error);
lt.toast.error('Error loading users');
}
}
@@ -156,37 +140,21 @@ async function saveCurrentFilter() {
'My Filter',
async (filterName) => {
if (!filterName || filterName.trim() === '') {
toast.warning('Filter name cannot be empty', 2000);
lt.toast.warning('Filter name cannot be empty', 2000);
return;
}
const filterCriteria = getCurrentFilterCriteria();
try {
const response = await fetch('/api/saved_filters.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
await lt.api.post('/api/saved_filters.php', {
filter_name: filterName.trim(),
filter_criteria: filterCriteria
})
});
const result = await response.json();
if (result.success) {
toast.success(`Filter "${filterName}" saved successfully!`, 3000);
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
loadSavedFilters();
} else {
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'), 4000);
}
} catch (error) {
console.error('Error saving filter:', error);
toast.error('Error saving filter', 4000);
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
}
}
);
@@ -233,16 +201,12 @@ function getCurrentFilterCriteria() {
// Load saved filters
async function loadSavedFilters() {
try {
const response = await fetch('/api/saved_filters.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/saved_filters.php');
if (data.success && data.filters) {
populateSavedFiltersDropdown(data.filters);
}
} catch (error) {
console.error('Error loading saved filters:', error);
lt.toast.error('Error loading saved filters');
}
}
@@ -277,7 +241,7 @@ function loadSavedFilter() {
const criteria = JSON.parse(selectedOption.dataset.criteria);
applySavedFilterCriteria(criteria);
} catch (error) {
console.error('Error loading filter:', error);
lt.toast.error('Error loading filter');
}
}
@@ -314,9 +278,7 @@ async function deleteSavedFilter() {
const selectedOption = dropdown.options[dropdown.selectedIndex];
if (!selectedOption || selectedOption.value === '') {
if (typeof toast !== 'undefined') {
toast.error('Please select a filter to delete');
}
lt.toast.error('Please select a filter to delete');
return;
}
@@ -329,45 +291,21 @@ async function deleteSavedFilter() {
'error',
async () => {
try {
const response = await fetch('/api/saved_filters.php', {
method: 'DELETE',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ filter_id: filterId })
});
const result = await response.json();
if (result.success) {
toast.success('Filter deleted successfully', 3000);
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
lt.toast.success('Filter deleted successfully', 3000);
loadSavedFilters();
resetAdvancedSearch();
} else {
toast.error('Failed to delete filter', 4000);
}
} catch (error) {
console.error('Error deleting filter:', error);
toast.error('Error deleting filter', 4000);
lt.toast.error('Error deleting filter', 4000);
}
}
);
}
// Keyboard shortcut (Ctrl+Shift+F)
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
openAdvancedSearch();
}
// ESC to close
if (e.key === 'Escape') {
const modal = document.getElementById('advancedSearchModal');
if (modal && modal.style.display === 'flex') {
closeAdvancedSearch();
}
}
});

View File

@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
const container = document.querySelector(containerSelector);
if (!container || !banner) {
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
return;
}
// Create pre element for ASCII art
const pre = document.createElement('pre');
pre.className = 'ascii-banner';
pre.style.margin = '0';
pre.style.fontFamily = 'var(--font-mono)';
pre.style.color = 'var(--terminal-green)';
if (addGlow) {
pre.style.textShadow = 'var(--glow-green)';
}
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
pre.style.fontSize = getBannerFontSize(bannerId);
pre.style.lineHeight = '1.2';
pre.style.whiteSpace = 'pre';
pre.style.overflow = 'visible';
pre.style.textAlign = 'center';
container.appendChild(pre);
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
banner.addEventListener('bannerComplete', () => {
const cursor = document.createElement('span');
cursor.textContent = '█';
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
cursor.style.marginLeft = '5px';
cursor.className = 'ascii-banner-cursor';
banner.appendChild(cursor);
});
}

793
assets/js/base.js Normal file
View File

@@ -0,0 +1,793 @@
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/* ----------------------------------------------------------------
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.classList.add('lt-toast--hiding');
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" role="dialog" aria-modal="true" aria-labelledby="myModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="myModalTitle">Title</span>
<button class="lt-modal-close" data-modal-close aria-label="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';
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:
<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) => {
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));

File diff suppressed because it is too large Load Diff

View File

@@ -1,173 +1,9 @@
/**
* Keyboard shortcuts for power users
* Keyboard shortcuts for power users.
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
*/
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('keydown', function(e) {
// ESC: Close modals, cancel edit mode, blur inputs
if (e.key === 'Escape') {
// Close any open modals first
const openModals = document.querySelectorAll('.modal-overlay');
let closedModal = false;
openModals.forEach(modal => {
if (modal.style.display !== 'none' && modal.offsetParent !== null) {
modal.remove();
document.body.classList.remove('modal-open');
closedModal = true;
}
});
// Close settings modal if open
const settingsModal = document.getElementById('settingsModal');
if (settingsModal && settingsModal.style.display !== 'none') {
settingsModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// Close advanced search modal if open
const searchModal = document.getElementById('advancedSearchModal');
if (searchModal && searchModal.style.display !== 'none') {
searchModal.style.display = 'none';
document.body.classList.remove('modal-open');
closedModal = true;
}
// If we closed a modal, stop here
if (closedModal) {
e.preventDefault();
return;
}
// Blur any focused input
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
e.target.blur();
}
// Cancel edit mode on ticket pages
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
window.location.reload();
}
return;
}
// Skip other shortcuts if user is typing in an input/textarea
if (e.target.tagName === 'INPUT' ||
e.target.tagName === 'TEXTAREA' ||
e.target.isContentEditable) {
return;
}
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
}
// Ctrl/Cmd + S: Save ticket (on ticket pages)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
toast.success('Saving ticket...');
}
}
// Ctrl/Cmd + K: Focus search (on dashboard)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const searchBox = document.querySelector('.search-box');
if (searchBox) {
searchBox.focus();
searchBox.select();
}
}
// ? : Show keyboard shortcuts help (requires Shift on most keyboards)
if (e.key === '?') {
e.preventDefault();
showKeyboardHelp();
}
// J: Move to next row in table (Gmail-style)
if (e.key === 'j') {
e.preventDefault();
navigateTableRow('next');
}
// K: Move to previous row in table (Gmail-style)
if (e.key === 'k') {
e.preventDefault();
navigateTableRow('prev');
}
// Enter: Open selected ticket
if (e.key === 'Enter') {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
e.preventDefault();
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) {
window.location.href = ticketLink.href;
}
}
}
// N: Create new ticket (on dashboard)
if (e.key === 'n') {
e.preventDefault();
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) {
window.location.href = newTicketBtn.href;
}
}
// C: Focus comment textarea (on ticket page)
if (e.key === 'c') {
const commentBox = document.getElementById('newComment');
if (commentBox) {
e.preventDefault();
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// G then D: Go to Dashboard (vim-style)
if (e.key === 'g') {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
}
if (e.key === 'd' && window._pendingG) {
e.preventDefault();
window._pendingG = false;
window.location.href = '/';
}
// 1-4: Quick status change on ticket page
if (['1', '2', '3', '4'].includes(e.key)) {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.modal-overlay')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[e.key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
e.preventDefault();
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
}
});
});
// Track currently selected row for J/K navigation
let currentSelectedRowIndex = -1;
@@ -175,7 +11,6 @@ function navigateTableRow(direction) {
const rows = document.querySelectorAll('tbody tr');
if (rows.length === 0) return;
// Remove current selection
rows.forEach(row => row.classList.remove('keyboard-selected'));
if (direction === 'next') {
@@ -184,7 +19,6 @@ function navigateTableRow(direction) {
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
}
// Add selection to new row
const selectedRow = rows[currentSelectedRowIndex];
if (selectedRow) {
selectedRow.classList.add('keyboard-selected');
@@ -193,59 +27,140 @@ function navigateTableRow(direction) {
}
function showKeyboardHelp() {
// Check if help is already showing
if (document.getElementById('keyboardHelpModal')) {
return;
}
if (document.getElementById('keyboardHelpModal')) return;
const modal = document.createElement('div');
modal.id = 'keyboardHelpModal';
modal.className = 'modal-overlay';
modal.className = 'lt-modal-overlay';
modal.setAttribute('aria-hidden', 'true');
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'keyboardHelpModalTitle');
modal.innerHTML = `
<div class="modal-content ascii-frame-outer" style="max-width: 500px;">
<div class="ascii-frame">
<div class="ascii-content">
<h3 style="margin: 0 0 1rem 0; color: var(--terminal-green);">KEYBOARD SHORTCUTS</h3>
<div class="modal-body" style="padding: 0;">
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Navigation</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>J</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Next ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Previous ticket in list</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Enter</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Open selected ticket</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>G</kbd> then <kbd>D</kbd></td><td style="padding: 0.4rem;">Go to Dashboard</td></tr>
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="keyboardHelpModalTitle">KEYBOARD SHORTCUTS</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body">
<h4 class="kb-section-heading">Navigation</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>J</kbd></td><td>Next ticket in list</td></tr>
<tr><td><kbd>K</kbd></td><td>Previous ticket in list</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Open selected ticket</td></tr>
<tr><td><kbd>G</kbd> then <kbd>D</kbd></td><td>Go to Dashboard</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Actions</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>N</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">New ticket</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>C</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus comment box</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + E</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Toggle Edit Mode</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>Ctrl/Cmd + S</kbd></td><td style="padding: 0.4rem;">Save Changes</td></tr>
<h4 class="kb-section-heading">Actions</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>N</kbd></td><td>New ticket</td></tr>
<tr><td><kbd>C</kbd></td><td>Focus comment box</td></tr>
<tr><td><kbd>Ctrl/Cmd+E</kbd></td><td>Toggle Edit Mode</td></tr>
<tr><td><kbd>Ctrl/Cmd+S</kbd></td><td>Save Changes</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Quick Status (Ticket Page)</h4>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1rem;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>1</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Open</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>2</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set Pending</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>3</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Set In Progress</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>4</kbd></td><td style="padding: 0.4rem;">Set Closed</td></tr>
<h4 class="kb-section-heading">Quick Status (Ticket Page)</h4>
<table class="kb-shortcuts-table">
<tr><td><kbd>1</kbd></td><td>Set Open</td></tr>
<tr><td><kbd>2</kbd></td><td>Set Pending</td></tr>
<tr><td><kbd>3</kbd></td><td>Set In Progress</td></tr>
<tr><td><kbd>4</kbd></td><td>Set Closed</td></tr>
</table>
<h4 style="color: var(--terminal-amber); margin: 0.5rem 0; font-size: 0.9rem;">Other</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>Ctrl/Cmd + K</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Focus Search</td></tr>
<tr><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);"><kbd>ESC</kbd></td><td style="padding: 0.4rem; border-bottom: 1px solid var(--border-color);">Close Modal / Cancel</td></tr>
<tr><td style="padding: 0.4rem;"><kbd>?</kbd></td><td style="padding: 0.4rem;">Show This Help</td></tr>
<h4 class="kb-section-heading">Other</h4>
<table class="kb-shortcuts-table no-margin">
<tr><td><kbd>Ctrl/Cmd+K</kbd></td><td>Focus Search</td></tr>
<tr><td><kbd>ESC</kbd></td><td>Close Modal / Cancel</td></tr>
<tr><td><kbd>?</kbd></td><td>Show This Help</td></tr>
</table>
</div>
<div class="modal-footer" style="margin-top: 1rem;">
<button class="btn btn-secondary" data-action="close-shortcuts-modal">Close</button>
</div>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-ghost" data-modal-close>CLOSE</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Add event listener for the close button
modal.querySelector('[data-action="close-shortcuts-modal"]').addEventListener('click', function() {
modal.remove();
});
lt.modal.open('keyboardHelpModal');
}
document.addEventListener('DOMContentLoaded', function() {
if (!window.lt) return;
// Ctrl+E: Toggle edit mode (ticket pages)
lt.keys.on('ctrl+e', function() {
const editButton = document.getElementById('editButton');
if (editButton) {
editButton.click();
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
}
});
// Ctrl+S: Save ticket (ticket pages)
lt.keys.on('ctrl+s', function() {
const editButton = document.getElementById('editButton');
if (editButton && editButton.classList.contains('active')) {
editButton.click();
lt.toast.success('Saving ticket...');
}
});
// ?: Show keyboard shortcuts help (lt.keys.initDefaults also handles this, but we override to show our modal)
lt.keys.on('?', function() {
showKeyboardHelp();
});
// J: Next row
lt.keys.on('j', () => navigateTableRow('next'));
// K: Previous row
lt.keys.on('k', () => navigateTableRow('prev'));
// Enter: Open selected ticket
lt.keys.on('enter', function() {
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
if (selectedRow) {
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
if (ticketLink) window.location.href = ticketLink.href;
}
});
// N: New ticket
lt.keys.on('n', function() {
const newTicketBtn = document.querySelector('a[href*="/create"]');
if (newTicketBtn) window.location.href = newTicketBtn.href;
});
// C: Focus comment box
lt.keys.on('c', function() {
const commentBox = document.getElementById('newComment');
if (commentBox) {
commentBox.focus();
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// G then D: Go to Dashboard (vim-style)
lt.keys.on('g', function() {
window._pendingG = true;
setTimeout(() => { window._pendingG = false; }, 1000);
});
lt.keys.on('d', function() {
if (window._pendingG) {
window._pendingG = false;
window.location.href = '/';
}
});
// 1-4: Quick status change on ticket page
['1', '2', '3', '4'].forEach(key => {
lt.keys.on(key, function() {
const statusSelect = document.getElementById('statusSelect');
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
const targetStatus = statusMap[key];
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
if (option && !option.disabled) {
statusSelect.value = targetStatus;
statusSelect.dispatchEvent(new Event('change'));
}
}
});
});
});

View File

@@ -364,7 +364,7 @@ function createEditorToolbar(textareaId, containerId) {
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
<span class="toolbar-separator"></span>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">🔗</button>
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
`;
// Add event delegation for toolbar buttons

View File

@@ -8,16 +8,13 @@ let userPreferences = {};
// Load preferences on page load
async function loadUserPreferences() {
try {
const response = await fetch('/api/user_preferences.php', {
credentials: 'same-origin'
});
const data = await response.json();
const data = await lt.api.get('/api/user_preferences.php');
if (data.success) {
userPreferences = data.preferences;
applyPreferences();
}
} catch (error) {
console.error('Error loading preferences:', error);
lt.toast.error('Error loading preferences');
}
}
@@ -94,34 +91,12 @@ async function saveSettings() {
};
try {
// Batch save all preferences in one request
const response = await fetch('/api/user_preferences.php', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ preferences: prefs })
});
const result = await response.json();
if (!result.success) {
throw new Error('Failed to save preferences');
}
if (typeof toast !== 'undefined') {
toast.success('Preferences saved successfully!');
}
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
lt.toast.success('Preferences saved successfully!');
closeSettingsModal();
// Reload page to apply new preferences
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
if (typeof toast !== 'undefined') {
toast.error('Error saving preferences');
}
console.error('Error saving preferences:', error);
lt.toast.error('Error saving preferences');
}
}
@@ -129,24 +104,18 @@ async function saveSettings() {
function openSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lt.modal.open('settingsModal');
loadUserPreferences();
}
}
function closeSettingsModal() {
const modal = document.getElementById('settingsModal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
lt.modal.close('settingsModal');
}
// Close modal when clicking on backdrop (outside the settings content)
function closeOnBackdropClick(event) {
const modal = document.getElementById('settingsModal');
// Only close if clicking directly on the modal backdrop, not on content
if (event.target === modal) {
closeSettingsModal();
}
@@ -158,15 +127,10 @@ document.addEventListener('keydown', (e) => {
e.preventDefault();
openSettingsModal();
}
// ESC to close modal
if (e.key === 'Escape') {
const modal = document.getElementById('settingsModal');
if (modal && modal.style.display !== 'none' && modal.style.display !== '') {
closeSettingsModal();
}
}
// ESC is handled globally by lt.keys.initDefaults()
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', loadUserPreferences);
document.addEventListener('DOMContentLoaded', function() {
if (window.lt) loadUserPreferences();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +1,22 @@
/**
* Terminal-style toast notification system with queuing
* Deprecated: use lt.toast.* directly (from web_template/base.js).
* This shim maintains backwards compatibility while callers are migrated.
*/
// Toast queue management
let toastQueue = [];
let currentToast = null;
function showToast(message, type = 'info', duration = 3000) {
// Queue if a toast is already showing
if (currentToast) {
toastQueue.push({ message, type, duration });
return;
// showToast() shim — used by inline view scripts
function showToast(message, type = 'info', duration = 3500) {
switch (type) {
case 'success': lt.toast.success(message, duration); break;
case 'error': lt.toast.error(message, duration); break;
case 'warning': lt.toast.warning(message, duration); break;
default: lt.toast.info(message, duration); break;
}
displayToast(message, type, duration);
}
function displayToast(message, type, duration) {
// Create toast element
const toast = document.createElement('div');
toast.className = `terminal-toast toast-${type}`;
currentToast = toast;
// Icon based on type
const icons = {
success: '✓',
error: '✗',
info: '',
warning: '⚠'
};
const iconSpan = document.createElement('span');
iconSpan.className = 'toast-icon';
iconSpan.textContent = `[${icons[type] || ''}]`;
const msgSpan = document.createElement('span');
msgSpan.className = 'toast-message';
msgSpan.textContent = message;
const closeSpan = document.createElement('span');
closeSpan.className = 'toast-close';
closeSpan.style.cssText = 'margin-left: auto; cursor: pointer; opacity: 0.7; padding-left: 1rem;';
closeSpan.textContent = '[×]';
toast.appendChild(iconSpan);
toast.appendChild(msgSpan);
toast.appendChild(closeSpan);
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Manual dismiss handler
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => dismissToast(toast));
// Auto-remove after duration
const timeoutId = setTimeout(() => {
dismissToast(toast);
}, duration);
// Store timeout ID for manual dismiss
toast.timeoutId = timeoutId;
}
function dismissToast(toast) {
// Clear auto-dismiss timeout
if (toast.timeoutId) {
clearTimeout(toast.timeoutId);
}
toast.classList.remove('show');
setTimeout(() => {
toast.remove();
currentToast = null;
// Show next toast in queue
if (toastQueue.length > 0) {
const next = toastQueue.shift();
displayToast(next.message, next.type, next.duration);
}
}, 300);
}
// Convenience functions
// window.toast.* shim — used by JS files
window.toast = {
success: (msg, duration) => showToast(msg, 'success', duration),
error: (msg, duration) => showToast(msg, 'error', duration),
info: (msg, duration) => showToast(msg, 'info', duration),
warning: (msg, duration) => showToast(msg, 'warning', duration)
success: (msg, dur) => lt.toast.success(msg, dur),
error: (msg, dur) => lt.toast.error(msg, dur),
warning: (msg, dur) => lt.toast.warning(msg, dur),
info: (msg, dur) => lt.toast.info(msg, dur),
};

View File

@@ -1,8 +1,6 @@
// XSS prevention helper
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
return lt.escHtml(text);
}
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
@@ -12,3 +10,49 @@ function getTicketIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
/**
* Show a terminal-style confirmation modal using the lt.modal system.
* Falls back gracefully if dashboard.js has already defined this function.
* @param {string} title - Modal title
* @param {string} message - Confirmation message
* @param {string} type - 'warning' | 'error' | 'info'
* @param {Function} onConfirm - Called when user confirms
* @param {Function|null} onCancel - Called when user cancels
*/
if (typeof showConfirmModal === 'undefined') {
window.showConfirmModal = function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
const modalId = 'confirmModal' + Date.now();
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
const color = colors[type] || colors.warning;
const icon = icons[type] || icons.warning;
const safeTitle = lt.escHtml(title);
const safeMessage = lt.escHtml(message);
document.body.insertAdjacentHTML('beforeend', `
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header" style="color:${color};">
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
</div>
<div class="lt-modal-body text-center">
<p class="modal-message">${safeMessage}</p>
</div>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
</div>
</div>
</div>
`);
const modal = document.getElementById(modalId);
lt.modal.open(modalId);
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
};
}

View File

@@ -1,15 +0,0 @@
#!/bin/bash
set -e
echo "Deploying tinker_tickets to web server..."
# 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/
# 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/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."

View File

@@ -162,13 +162,5 @@ class Database {
return self::getConnection()->insert_id;
}
/**
* Escape a string for use in queries (prefer prepared statements)
*
* @param string $string String to escape
* @return string Escaped string
*/
public static function escape(string $string): string {
return self::getConnection()->real_escape_string($string);
}
// escape() removed — use prepared statements with bind_param() instead
}

View File

@@ -42,8 +42,8 @@ if (!str_starts_with($requestPath, '/api/')) {
require_once 'models/UserPreferencesModel.php';
$prefsModel = new UserPreferencesModel($conn);
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
if ($userTimezone) {
// Override system timezone with user preference
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
// Override system timezone with user preference (validated against known identifiers)
date_default_timezone_set($userTimezone);
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
$now = new DateTime('now', new DateTimeZone($userTimezone));

View File

@@ -3,22 +3,11 @@
* AttachmentModel - Handles ticket file attachments
*/
require_once __DIR__ . '/../config/config.php';
class AttachmentModel {
private $conn;
public function __construct() {
$this->conn = new mysqli(
$GLOBALS['config']['DB_HOST'],
$GLOBALS['config']['DB_USER'],
$GLOBALS['config']['DB_PASS'],
$GLOBALS['config']['DB_NAME']
);
if ($this->conn->connect_error) {
throw new Exception('Database connection failed: ' . $this->conn->connect_error);
}
public function __construct($conn) {
$this->conn = $conn;
}
/**
@@ -204,9 +193,4 @@ class AttachmentModel {
return in_array($mimeType, $allowedTypes);
}
public function __destruct() {
if ($this->conn) {
$this->conn->close();
}
}
}

View File

@@ -19,6 +19,14 @@ class BulkOperationsModel {
* @return int|false Operation ID or false on failure
*/
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
// Validate ticket IDs to prevent injection via implode
$ticketIds = array_values(array_filter(
array_map('strval', $ticketIds),
fn($id) => preg_match('/^[0-9]+$/', $id)
));
if (empty($ticketIds)) {
return false;
}
$ticketIdsStr = implode(',', $ticketIds);
$totalTickets = count($ticketIds);
$parametersJson = $parameters ? json_encode($parameters) : null;

View File

@@ -71,7 +71,7 @@ class CommentModel {
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("s", $ticketId);
$stmt->bind_param("i", $ticketId);
$stmt->execute();
$result = $stmt->get_result();
@@ -126,7 +126,8 @@ class CommentModel {
private function buildCommentThread($comment, &$allComments) {
$comment['replies'] = [];
foreach ($allComments as $c) {
if ($c['parent_comment_id'] == $comment['comment_id']) {
if ($c['parent_comment_id'] == $comment['comment_id']
&& isset($allComments[$c['comment_id']])) {
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
}
}

View File

@@ -54,6 +54,8 @@ class SavedFiltersModel {
* Save a new filter
*/
public function saveFilter($userId, $filterName, $filterCriteria, $isDefault = false) {
$this->conn->begin_transaction();
try {
// If this is set as default, unset all other defaults for this user
if ($isDefault) {
$this->clearDefaultFilters($userId);
@@ -71,12 +73,17 @@ class SavedFiltersModel {
$stmt->bind_param("issi", $userId, $filterName, $criteriaJson, $isDefault);
if ($stmt->execute()) {
return [
'success' => true,
'filter_id' => $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName)
];
$filterId = $stmt->insert_id ?: $this->getFilterIdByName($userId, $filterName);
$this->conn->commit();
return ['success' => true, 'filter_id' => $filterId];
}
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
}
return ['success' => false, 'error' => $this->conn->error];
}
/**
@@ -126,18 +133,25 @@ class SavedFiltersModel {
* Set a filter as default
*/
public function setDefaultFilter($filterId, $userId) {
// First, clear all defaults
$this->conn->begin_transaction();
try {
$this->clearDefaultFilters($userId);
// Then set this one as default
$sql = "UPDATE saved_filters SET is_default = 1 WHERE filter_id = ? AND user_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ii", $filterId, $userId);
if ($stmt->execute()) {
$this->conn->commit();
return ['success' => true];
}
return ['success' => false, 'error' => $this->conn->error];
$error = $this->conn->error;
$this->conn->rollback();
return ['success' => false, 'error' => $error];
} catch (Exception $e) {
$this->conn->rollback();
return ['success' => false, 'error' => $e->getMessage()];
}
}
/**

View File

@@ -134,7 +134,7 @@ class StatsModel {
u.username,
COUNT(t.ticket_id) as ticket_count
FROM tickets t
JOIN users u ON t.assigned_to = u.user_id
LEFT JOIN users u ON t.assigned_to = u.user_id
WHERE t.status != 'Closed'
GROUP BY t.assigned_to
ORDER BY ticket_count DESC

View File

@@ -422,6 +422,34 @@ class TicketModel {
'ticket_id' => $ticket_id
];
} else {
// Handle duplicate key (errno 1062) caused by race condition between
// the uniqueness SELECT above and this INSERT — regenerate and retry once
if ($this->conn->errno === 1062) {
$stmt->close();
try {
$ticket_id = sprintf('%09d', random_int(100000000, 999999999));
} catch (Exception $e) {
$ticket_id = sprintf('%09d', mt_rand(100000000, 999999999));
}
$stmt = $this->conn->prepare($sql);
$stmt->bind_param(
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
$status,
$priority,
$category,
$type,
$createdBy,
$assignedTo,
$visibility,
$visibilityGroups
);
if ($stmt->execute()) {
return ['success' => true, 'ticket_id' => $ticket_id];
}
}
return [
'success' => false,
'error' => $this->conn->error

View File

@@ -27,6 +27,10 @@ class WorkflowModel {
WHERE is_active = TRUE";
$result = $this->conn->query($sql);
if (!$result) {
return [];
}
$transitions = [];
while ($row = $result->fetch_assoc()) {
$from = $row['from_status'];
@@ -102,6 +106,10 @@ class WorkflowModel {
ORDER BY status";
$result = $this->conn->query($sql);
if (!$result) {
return [];
}
$statuses = [];
while ($row = $result->fetch_assoc()) {
$statuses[] = $row['status'];

View File

@@ -11,10 +11,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Ticket</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -23,13 +25,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
<?php endif; ?>
</div>
@@ -46,7 +48,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-frame-inner">
<div class="ticket-header">
<h2>New Ticket Form</h2>
<p style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9rem; margin-top: 0.5rem;">
<p class="form-hint">
Complete the form below to create a new ticket
</p>
</div>
@@ -60,8 +62,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- ERROR SECTION -->
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="error-message" style="color: var(--priority-1); border: 2px solid var(--priority-1); padding: 1rem; background: rgba(231, 76, 60, 0.1);">
<strong>⚠ Error:</strong> <?php echo $error; ?>
<div class="error-message inline-error">
<strong>[ ! ] ERROR:</strong> <?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?>
</div>
</div>
</div>
@@ -88,7 +90,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?>
<?php endif; ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
<p class="form-hint">
Select a template to auto-fill form fields
</p>
</div>
@@ -107,11 +109,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text" id="title" name="title" class="editable" required placeholder="Enter a descriptive title for this ticket">
</div>
<!-- Duplicate Warning Area -->
<div id="duplicateWarning" style="display: none; margin-top: 1rem; padding: 1rem; border: 2px solid var(--terminal-amber); background: rgba(241, 196, 15, 0.1);">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 0.5rem;">
<div id="duplicateWarning" class="inline-warning is-hidden" role="alert" aria-live="polite" aria-atomic="true">
<div class="text-amber fw-bold duplicate-heading">
Possible Duplicates Found
</div>
<div id="duplicatesList"></div>
<div id="duplicatesList" aria-live="polite"></div>
</div>
</div>
</div>
@@ -183,7 +185,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php endforeach; ?>
<?php endif; ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
<p class="form-hint">
Select a user to assign this ticket to
</p>
</div>
@@ -204,13 +206,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="internal">Internal - Specific groups only</option>
<option value="confidential">Confidential - Creator, assignee, admins only</option>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
<p class="form-hint">
Controls who can view this ticket
</p>
</div>
<div id="visibilityGroupsContainer" class="detail-group" style="display: none; margin-top: 1rem;">
<div id="visibilityGroupsContainer" class="detail-group is-hidden">
<label>Allowed Groups</label>
<div class="visibility-groups-list" style="display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.5rem;">
<div class="visibility-groups-list">
<?php
// Get all available groups
require_once __DIR__ . '/../models/UserModel.php';
@@ -218,16 +220,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$allGroups = $userModel->getAllGroups();
foreach ($allGroups as $group):
?>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<label class="group-checkbox-label">
<input type="checkbox" name="visibility_groups[]" value="<?php echo htmlspecialchars($group); ?>" class="visibility-group-checkbox">
<span class="group-badge"><?php echo htmlspecialchars($group); ?></span>
</label>
<?php endforeach; ?>
<?php if (empty($allGroups)): ?>
<span style="color: var(--text-muted);">No groups available</span>
<span class="text-muted">No groups available</span>
<?php endif; ?>
</div>
<p style="color: var(--terminal-amber); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
<p class="form-hint-warning">
Select which groups can view this ticket
</p>
</div>
@@ -256,8 +258,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="ticket-footer">
<button type="submit" class="btn primary">Create Ticket</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">Cancel</button>
<button type="submit" class="btn primary">CREATE TICKET</button>
<button type="button" data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/" class="btn back-btn">CANCEL</button>
</div>
</div>
</div>
@@ -275,7 +277,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const title = this.value.trim();
if (title.length < 5) {
document.getElementById('duplicateWarning').style.display = 'none';
document.getElementById('duplicateWarning').classList.add('is-hidden');
return;
}
@@ -286,30 +288,29 @@ $nonce = SecurityHeadersMiddleware::getNonce();
});
function checkForDuplicates(title) {
fetch('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(response => response.json())
lt.api.get('/api/check_duplicates.php?title=' + encodeURIComponent(title))
.then(data => {
const warningDiv = document.getElementById('duplicateWarning');
const listDiv = document.getElementById('duplicatesList');
if (data.success && data.duplicates && data.duplicates.length > 0) {
let html = '<ul style="margin: 0; padding-left: 1.5rem; color: var(--terminal-green);">';
let html = '<ul class="duplicate-list">';
data.duplicates.forEach(dup => {
html += `<li style="margin-bottom: 0.5rem;">
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank" style="color: var(--terminal-green);">
html += `<li>
<a href="/ticket/${escapeHtml(dup.ticket_id)}" target="_blank">
#${escapeHtml(dup.ticket_id)}
</a>
- ${escapeHtml(dup.title)}
<span style="color: var(--terminal-amber);">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
<span class="duplicate-meta">(${dup.similarity}% match, ${escapeHtml(dup.status)})</span>
</li>`;
});
html += '</ul>';
html += '<p style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--terminal-green-dim);">Consider checking these tickets before creating a new one.</p>';
html += '<p class="duplicate-hint">Consider checking these tickets before creating a new one.</p>';
listDiv.innerHTML = html;
warningDiv.style.display = 'block';
warningDiv.classList.remove('is-hidden');
} else {
warningDiv.style.display = 'none';
warningDiv.classList.add('is-hidden');
}
})
.catch(error => {
@@ -321,9 +322,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const visibility = document.getElementById('visibility').value;
const groupsContainer = document.getElementById('visibilityGroupsContainer');
if (visibility === 'internal') {
groupsContainer.style.display = 'block';
groupsContainer.classList.remove('is-hidden');
} else {
groupsContainer.style.display = 'none';
groupsContainer.classList.add('is-hidden');
document.querySelectorAll('.visibility-group-checkbox').forEach(cb => cb.checked = false);
}
}
@@ -350,6 +351,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
toggleVisibilityGroups();
}
});
if (window.lt) lt.keys.initDefaults();
</script>
</body>
</html>

View File

@@ -12,60 +12,64 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket Dashboard</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
</script>
</head>
<body data-categories='<?php echo json_encode($categories); ?>' data-types='<?php echo json_encode($types); ?>'>
<body data-categories='<?php echo htmlspecialchars(json_encode($categories), ENT_QUOTES, 'UTF-8'); ?>' data-types='<?php echo htmlspecialchars(json_encode($types), ENT_QUOTES, 'UTF-8'); ?>'>
<!-- Terminal Boot Sequence -->
<div id="boot-sequence" class="boot-overlay">
<div id="boot-banner"></div>
<pre id="boot-text"></pre>
</div>
<script nonce="<?php echo $nonce; ?>">
function showBootSequence() {
const bootText = document.getElementById('boot-text');
const bootOverlay = document.getElementById('boot-sequence');
// Render ASCII banner first, then start boot messages
renderResponsiveBanner('#boot-banner', 0);
const messages = [
'╔═══════════════════════════════════════╗',
'║ TINKER TICKETS TERMINAL v1.0 ║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════╝',
'',
'[ OK ] Loading kernel modules...',
'[ OK ] Initializing ticket database...',
'[ OK ] Mounting user session...',
'[ OK ] Starting dashboard services...',
'[ OK ] Rendering ASCII frames...',
'',
'> SYSTEM READY ',
'> SYSTEM READY [OK]',
''
];
let i = 0;
// Brief pause after banner renders before boot text begins
setTimeout(() => {
const interval = setInterval(() => {
if (i < messages.length) {
bootText.textContent += messages[i] + '\n';
i++;
} else {
setTimeout(() => {
bootOverlay.style.opacity = '0';
bootOverlay.classList.add('boot-overlay--fade-out');
setTimeout(() => bootOverlay.remove(), 500);
}, 500);
clearInterval(interval);
}
}, 80);
}, 400);
}
// Run on first visit only (per session)
@@ -78,52 +82,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</script>
<header class="user-header" role="banner">
<div class="user-header-left">
<a href="/" class="app-title">🎫 Tinker Tickets</a>
<a href="/" class="app-title">[ TINKER TICKETS ]</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<div class="admin-dropdown">
<button class="admin-badge" data-action="toggle-admin-menu">Admin ▼</button>
<button class="admin-badge" data-action="toggle-admin-menu" aria-label="Admin menu" aria-haspopup="true" aria-expanded="false">ADMIN ▼</button>
<div class="admin-dropdown-content" id="adminDropdown">
<a href="/admin/templates">📋 Templates</a>
<a href="/admin/workflow">🔄 Workflow</a>
<a href="/admin/recurring-tickets">🔁 Recurring Tickets</a>
<a href="/admin/custom-fields">📝 Custom Fields</a>
<a href="/admin/user-activity">👥 User Activity</a>
<a href="/admin/audit-log">📜 Audit Log</a>
<a href="/admin/api-keys">🔑 API Keys</a>
<a href="/admin/templates">TEMPLATES</a>
<a href="/admin/workflow">WORKFLOW</a>
<a href="/admin/recurring-tickets">RECURRING</a>
<a href="/admin/custom-fields">CUSTOM FIELDS</a>
<a href="/admin/user-activity">USER ACTIVITY</a>
<a href="/admin/audit-log">AUDIT LOG</a>
<a href="/admin/api-keys">API KEYS</a>
</div>
</div>
<?php endif; ?>
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">⚙</button>
<button class="btn btn-small" data-action="manual-refresh" title="Refresh now (auto-refreshes every 5 min)" aria-label="Refresh dashboard">REFRESH</button>
<button class="settings-icon" title="Settings (Alt+S)" data-action="open-settings" aria-label="Settings">[ CFG ]</button>
<?php endif; ?>
</div>
</header>
<!-- Collapsible ASCII Banner -->
<div class="ascii-banner-wrapper collapsed">
<button class="banner-toggle" data-action="toggle-banner">
<span class="toggle-icon">▼</span> ASCII Banner
</button>
<div id="ascii-banner-container" class="banner-content"></div>
</div>
<script nonce="<?php echo $nonce; ?>">
function toggleBanner() {
const wrapper = document.querySelector('.ascii-banner-wrapper');
const icon = document.querySelector('.toggle-icon');
wrapper.classList.toggle('collapsed');
icon.textContent = wrapper.classList.contains('collapsed') ? '▼' : '▲';
// Render banner on first expand (no animation for instant display)
if (!wrapper.classList.contains('collapsed') && !wrapper.dataset.rendered) {
renderResponsiveBanner('#ascii-banner-container', 0); // Speed 0 = no animation
wrapper.dataset.rendered = 'true';
}
}
</script>
<!-- Dashboard Layout with Sidebar -->
<div class="dashboard-layout" id="dashboardLayout">
<!-- Left Sidebar with Filters -->
@@ -161,7 +144,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label>
<input type="checkbox"
name="category"
value="<?php echo $cat; ?>"
value="<?php echo htmlspecialchars($cat); ?>"
<?php echo in_array($cat, $currentCategories) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($cat); ?>
</label>
@@ -178,15 +161,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label>
<input type="checkbox"
name="type"
value="<?php echo $type; ?>"
value="<?php echo htmlspecialchars($type); ?>"
<?php echo in_array($type, $currentTypes) ? 'checked' : ''; ?>>
<?php echo htmlspecialchars($type); ?>
</label>
<?php endforeach; ?>
</div>
<button id="apply-filters-btn" class="btn">Apply Filters</button>
<button id="clear-filters-btn" class="btn btn-secondary">Clear All</button>
<button id="apply-filters-btn" class="btn">APPLY FILTERS</button>
<button id="clear-filters-btn" class="btn btn-secondary">CLEAR ALL</button>
</div>
</div>
</aside>
@@ -201,42 +184,42 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="stats-widgets">
<div class="stats-row">
<div class="stat-card stat-open">
<div class="stat-icon">📂</div>
<div class="stat-icon">[ # ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['open_tickets']; ?></div>
<div class="stat-label">Open Tickets</div>
</div>
</div>
<div class="stat-card stat-critical">
<div class="stat-icon">🔥</div>
<div class="stat-icon">[ ! ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['critical']; ?></div>
<div class="stat-label">Critical (P1)</div>
</div>
</div>
<div class="stat-card stat-unassigned">
<div class="stat-icon">👤</div>
<div class="stat-icon">[ @ ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['unassigned']; ?></div>
<div class="stat-label">Unassigned</div>
</div>
</div>
<div class="stat-card stat-today">
<div class="stat-icon">📅</div>
<div class="stat-icon">[ + ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['created_today']; ?></div>
<div class="stat-label">Created Today</div>
</div>
</div>
<div class="stat-card stat-resolved">
<div class="stat-icon"></div>
<div class="stat-icon">[ OK ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['closed_today']; ?></div>
<div class="stat-label">Closed Today</div>
</div>
</div>
<div class="stat-card stat-time">
<div class="stat-icon"></div>
<div class="stat-icon">[ t ]</div>
<div class="stat-content">
<div class="stat-value"><?php echo $stats['avg_resolution_hours']; ?>h</div>
<div class="stat-label">Avg Resolution</div>
@@ -250,7 +233,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="dashboard-toolbar">
<!-- Left: Title + Search -->
<div class="toolbar-left">
<h1 class="dashboard-title">🎫 Tickets</h1>
<h1 class="dashboard-title">[ TICKETS ]</h1>
<form method="GET" action="" class="toolbar-search">
<!-- Preserve existing parameters -->
<?php if (isset($_GET['status'])): ?>
@@ -271,13 +254,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<input type="text"
name="search"
placeholder="🔍 Search tickets..."
placeholder="&gt; Search tickets..."
class="search-box"
value="<?php echo isset($_GET['search']) ? htmlspecialchars($_GET['search']) : ''; ?>">
<button type="submit" class="btn search-btn">Search</button>
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">⚙ Advanced</button>
<button type="submit" class="btn search-btn">SEARCH</button>
<button type="button" class="btn btn-secondary" data-action="open-advanced-search" title="Advanced Search">FILTER</button>
<?php if (isset($_GET['search']) && !empty($_GET['search'])): ?>
<a href="?" class="clear-search-btn">✗</a>
<a href="?" class="clear-search-btn" aria-label="Clear search">[ X ]</a>
<?php endif; ?>
</form>
</div>
@@ -285,12 +268,12 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- Center: Actions + Count -->
<div class="toolbar-center">
<div class="view-toggle">
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true"></button>
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false"></button>
<button id="tableViewBtn" class="view-btn active" data-action="set-view-mode" data-mode="table" title="Table View" aria-label="Table view" aria-pressed="true">[ = ]</button>
<button id="cardViewBtn" class="view-btn" data-action="set-view-mode" data-mode="card" title="Kanban View" aria-label="Kanban view" aria-pressed="false">[ # ]</button>
</div>
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ New Ticket</button>
<button data-action="navigate" data-url="<?php echo $GLOBALS['config']['BASE_URL']; ?>/ticket/create" class="btn create-ticket">+ NEW TICKET</button>
<div class="export-dropdown" id="exportDropdown" style="display: none;">
<button class="btn" data-action="toggle-export-menu">↓ Export Selected (<span id="exportCount">0</span>)</button>
<button class="btn" data-action="toggle-export-menu" aria-label="Export selected tickets" aria-haspopup="true" aria-expanded="false">EXPORT SELECTED (<span id="exportCount">0</span>)</button>
<div class="export-dropdown-content" id="exportDropdownContent">
<a href="#" data-action="export-tickets" data-format="csv">CSV</a>
<a href="#" data-action="export-tickets" data-format="json">JSON</a>
@@ -308,23 +291,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Previous page button
if ($page > 1) {
$currentParams['page'] = $page - 1;
$prevUrl = '?' . http_build_query($currentParams);
echo "<button data-action='navigate' data-url='$prevUrl'</button>";
$prevUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$prevUrl' aria-label='Previous page'>[ « ]</button>";
}
// Page number buttons
for ($i = 1; $i <= $totalPages; $i++) {
$activeClass = ($i === $page) ? 'active' : '';
$currentParams['page'] = $i;
$pageUrl = '?' . http_build_query($currentParams);
$pageUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button class='$activeClass' data-action='navigate' data-url='$pageUrl'>$i</button>";
}
// Next page button
if ($page < $totalPages) {
$currentParams['page'] = $page + 1;
$nextUrl = '?' . http_build_query($currentParams);
echo "<button data-action='navigate' data-url='$nextUrl'</button>";
$nextUrl = htmlspecialchars('?' . http_build_query($currentParams), ENT_QUOTES, 'UTF-8');
echo "<button data-action='navigate' data-url='$nextUrl' aria-label='Next page'>[ » ]</button>";
}
?>
</div>
@@ -350,10 +333,10 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
<div class="bulk-actions-inline" style="display: none;">
<span id="selected-count">0</span> tickets selected
<button data-action="bulk-status" class="btn btn-bulk">Change Status</button>
<button data-action="bulk-assign" class="btn btn-bulk">Assign</button>
<button data-action="bulk-priority" class="btn btn-bulk">Priority</button>
<button data-action="clear-selection" class="btn btn-secondary">Clear</button>
<button data-action="bulk-status" class="btn btn-bulk">CHANGE STATUS</button>
<button data-action="bulk-assign" class="btn btn-bulk">ASSIGN</button>
<button data-action="bulk-priority" class="btn btn-bulk">PRIORITY</button>
<button data-action="clear-selection" class="btn btn-secondary">CLEAR</button>
</div>
<?php endif; ?>
@@ -392,11 +375,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php foreach ($activeFilters as $filter): ?>
<span class="filter-badge" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>">
<?php echo htmlspecialchars($filter['label']); ?>
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter">&times;</button>
<button type="button" class="filter-remove" data-action="remove-filter" data-filter-type="<?php echo htmlspecialchars($filter['type']); ?>" data-filter-value="<?php echo htmlspecialchars($filter['value']); ?>" title="Remove filter" aria-label="Remove <?php echo htmlspecialchars($filter['label']); ?> filter">&times;</button>
</span>
<?php endforeach; ?>
</div>
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">Clear All</button>
<button type="button" class="btn btn-secondary btn-sm" data-action="clear-all-filters">CLEAR ALL</button>
</div>
<?php endif; ?>
@@ -406,11 +389,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<thead>
<tr>
<?php if ($GLOBALS['currentUser']['is_admin'] ?? false): ?>
<th style="width: 40px;"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all"></th>
<th class="col-checkbox" scope="col"><input type="checkbox" id="selectAllCheckbox" data-action="toggle-select-all" aria-label="Select all tickets"></th>
<?php endif; ?>
<?php
$currentSort = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
$currentDir = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
$currentDir = (isset($_GET['dir']) && $_GET['dir'] === 'asc') ? 'asc' : 'desc';
$columns = [
'ticket_id' => 'Ticket ID',
@@ -428,13 +411,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
foreach($columns as $col => $label) {
if ($col === '_actions') {
echo "<th style='width: 100px; text-align: center;'>$label</th>";
echo "<th scope='col' class='col-actions text-center'>$label</th>";
} else {
$newDir = ($currentSort === $col && $currentDir === 'asc') ? 'desc' : 'asc';
$sortClass = ($currentSort === $col) ? "sort-$currentDir" : '';
$ariaSort = ($currentSort === $col) ? "aria-sort='" . ($currentDir === 'asc' ? 'ascending' : 'descending') . "'" : '';
$sortParams = array_merge($_GET, ['sort' => $col, 'dir' => $newDir]);
$sortUrl = '?' . http_build_query($sortParams);
echo "<th class='$sortClass' data-action='navigate' data-url='$sortUrl'>$label</th>";
$sortUrl = htmlspecialchars('?' . http_build_query($sortParams), ENT_QUOTES, 'UTF-8');
echo "<th scope='col' class='$sortClass' data-action='navigate' data-url='$sortUrl' $ariaSort>$label</th>";
}
}
?>
@@ -450,33 +434,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Add checkbox column for admins
if ($GLOBALS['currentUser']['is_admin'] ?? false) {
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='{$row['ticket_id']}' data-action='update-selection'></td>";
echo "<td data-action='toggle-row-checkbox' class='checkbox-cell'><input type='checkbox' class='ticket-checkbox' value='" . $row['ticket_id'] . "' data-action='update-selection' aria-label='Select ticket " . $row['ticket_id'] . "'></td>";
}
echo "<td><a href='/ticket/{$row['ticket_id']}' class='ticket-link'>{$row['ticket_id']}</a></td>";
echo "<td><span>{$row['priority']}</span></td>";
echo "<td>" . htmlspecialchars($row['title']) . "</td>";
echo "<td>{$row['category']}</td>";
echo "<td>{$row['type']}</td>";
echo "<td><span class='status-" . str_replace(' ', '-', $row['status']) . "'>{$row['status']}</span></td>";
echo "<td>" . htmlspecialchars($row['category']) . "</td>";
echo "<td>" . htmlspecialchars($row['type']) . "</td>";
$statusSlug = htmlspecialchars(str_replace(' ', '-', $row['status']), ENT_QUOTES);
echo "<td><span class='status-" . $statusSlug . "'>" . htmlspecialchars($row['status']) . "</span></td>";
echo "<td>" . htmlspecialchars($creator) . "</td>";
echo "<td>" . htmlspecialchars($assignedTo) . "</td>";
echo "<td>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
echo "<td>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['created_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['created_at'])) . "</td>";
echo "<td class='ts-cell' data-ts='" . htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . date('Y-m-d H:i T', strtotime($row['updated_at'])) . "'>" . date('Y-m-d H:i', strtotime($row['updated_at'])) . "</td>";
// Quick actions column
echo "<td class='quick-actions-cell'>";
echo "<div class='quick-actions'>";
echo "<button data-action='view-ticket' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='View'>👁</button>";
echo "<button data-action='quick-status' data-ticket-id='{$row['ticket_id']}' data-status='{$row['status']}' class='quick-action-btn' title='Change Status'>🔄</button>";
echo "<button data-action='quick-assign' data-ticket-id='{$row['ticket_id']}' class='quick-action-btn' title='Assign'>👤</button>";
echo "<button data-action='view-ticket' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='View' aria-label='View ticket " . $row['ticket_id'] . "'>&gt;</button>";
echo "<button data-action='quick-status' data-ticket-id='" . (int)$row['ticket_id'] . "' data-status='" . htmlspecialchars($row['status'], ENT_QUOTES) . "' class='quick-action-btn' title='Change Status' aria-label='Change status for ticket " . (int)$row['ticket_id'] . "'>~</button>";
echo "<button data-action='quick-assign' data-ticket-id='" . $row['ticket_id'] . "' class='quick-action-btn' title='Assign' aria-label='Assign ticket " . $row['ticket_id'] . "'>@</button>";
echo "</div>";
echo "</td>";
echo "</tr>";
}
} else {
$colspan = ($GLOBALS['currentUser']['is_admin'] ?? false) ? '12' : '11';
echo "<tr><td colspan='$colspan' style='text-align: center; padding: 3rem;'>";
echo "<pre style='color: var(--terminal-green); text-shadow: var(--glow-green); font-size: 0.8rem; line-height: 1.2;'>";
echo "<tr><td colspan='$colspan' class='dashboard-empty-state'>";
echo "<pre class='dashboard-empty-pre'>";
echo "╔════════════════════════════════════════╗\n";
echo "║ ║\n";
echo "║ NO TICKETS FOUND ║\n";
@@ -507,17 +492,17 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ticket-card-main">
<div class="ticket-card-title"><?php echo htmlspecialchars($row['title']); ?></div>
<div class="ticket-card-meta">
<span>📁 <?php echo htmlspecialchars($row['category']); ?></span>
<span>👤 <?php echo htmlspecialchars($assignedTo); ?></span>
<span>📅 <?php echo date('M j', strtotime($row['updated_at'])); ?></span>
<span><?php echo htmlspecialchars($row['category']); ?></span>
<span>@ <?php echo htmlspecialchars($assignedTo); ?></span>
<span class="ts-cell" data-ts="<?php echo htmlspecialchars($row['updated_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo date('Y-m-d H:i', strtotime($row['updated_at'])); ?>"><?php echo date('M j', strtotime($row['updated_at'])); ?></span>
</div>
</div>
<div class="ticket-card-status <?php echo $statusClass; ?>">
<?php echo $row['status']; ?>
<div class="ticket-card-status <?php echo htmlspecialchars($statusClass); ?>">
<?php echo htmlspecialchars($row['status']); ?>
</div>
<div class="ticket-card-actions">
<button data-action="view-ticket" data-ticket-id="<?php echo $row['ticket_id']; ?>" title="View">👁</button>
<button data-action="quick-status" data-ticket-id="<?php echo $row['ticket_id']; ?>" data-status="<?php echo $row['status']; ?>" title="Status">🔄</button>
<button data-action="view-ticket" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" title="View" aria-label="View ticket <?php echo (int)$row['ticket_id']; ?>">&gt;</button>
<button data-action="quick-status" data-ticket-id="<?php echo (int)$row['ticket_id']; ?>" data-status="<?php echo htmlspecialchars($row['status'], ENT_QUOTES); ?>" title="Status" aria-label="Change status for ticket <?php echo (int)$row['ticket_id']; ?>">~</button>
</div>
</div>
<?php
@@ -539,7 +524,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- END OUTER FRAME -->
<!-- Kanban Card View -->
<section id="cardView" class="card-view-container" style="display: none;" aria-label="Kanban board view">
<section id="cardView" class="card-view-container is-hidden" aria-label="Kanban board view">
<div class="kanban-board">
<div class="kanban-column" data-status="Open">
<div class="kanban-column-header status-Open">
@@ -573,17 +558,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</section>
<!-- Settings Modal -->
<div class="settings-modal" id="settingsModal" style="display: none;" data-action="close-settings-backdrop" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="settings-content">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="settings-header">
<h3 id="settingsModalTitle">⚙ System Preferences</h3>
<button class="close-settings" data-action="close-settings" aria-label="Close settings">✗</button>
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="settingsModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settingsModalTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
</div>
<div class="settings-body">
<div class="lt-modal-body">
<!-- Display Preferences -->
<div class="settings-section">
<h4>╔══ Display Preferences ══╗</h4>
@@ -724,23 +706,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<div class="settings-footer">
<button class="btn btn-primary" data-action="save-settings">Save Preferences</button>
<button class="btn btn-secondary" data-action="close-settings">Cancel</button>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" data-action="save-settings">SAVE PREFERENCES</button>
<button class="lt-btn lt-btn-ghost" data-action="close-settings">CANCEL</button>
</div>
</div>
</div>
<!-- Advanced Search Modal -->
<div class="settings-modal" id="advancedSearchModal" style="display: none;" data-action="close-advanced-search-backdrop" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
<div class="settings-content">
<div class="settings-header">
<h3 id="advancedSearchModalTitle">🔍 Advanced Search</h3>
<button class="close-settings" data-action="close-advanced-search" aria-label="Close advanced search"></button>
<div class="lt-modal-overlay" id="advancedSearchModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advancedSearchModalTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="advancedSearchModalTitle">[ FILTER ] ADVANCED SEARCH</span>
<button class="lt-modal-close" data-modal-close aria-label="Close advanced search"></button>
</div>
<form id="advancedSearchForm">
<div class="settings-body">
<div class="lt-modal-body">
<!-- Saved Filters -->
<div class="settings-section">
<h4>╔══ Saved Filters ══╗</h4>
@@ -751,8 +733,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</select>
</div>
<div class="setting-row setting-row-right">
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">💾 Save Current</button>
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">🗑 Delete Selected</button>
<button type="button" class="btn btn-secondary btn-setting" data-action="save-filter">SAVE</button>
<button type="button" class="btn btn-secondary btn-setting" data-action="delete-filter">DELETE</button>
</div>
</div>
@@ -841,19 +823,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Search</button>
<button type="button" class="btn btn-secondary" data-action="reset-advanced-search">Reset</button>
<button type="button" class="btn btn-secondary" data-action="close-advanced-search">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SEARCH</button>
<button type="button" class="lt-btn lt-btn-ghost" data-action="reset-advanced-search">RESET</button>
<button type="button" class="lt-btn lt-btn-ghost" data-action="close-advanced-search">CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/advanced-search.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
// Initialize lt keyboard defaults (ESC closes modals, Ctrl+K focuses search, ? shows help)
if (window.lt) lt.keys.initDefaults();
// Event delegation for all data-action handlers
document.addEventListener('click', function(event) {
const target = event.target.closest('[data-action]');
@@ -878,21 +862,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
openSettingsModal();
break;
case 'close-settings':
closeSettingsModal();
case 'manual-refresh':
lt.autoRefresh.now();
break;
case 'close-settings-backdrop':
if (event.target === target) closeSettingsModal();
case 'close-settings':
closeSettingsModal();
break;
case 'save-settings':
saveSettings();
break;
case 'toggle-banner':
toggleBanner();
break;
case 'toggle-sidebar':
toggleSidebar();
@@ -906,10 +887,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
closeAdvancedSearch();
break;
case 'close-advanced-search-backdrop':
if (event.target === target) closeAdvancedSearch();
break;
case 'reset-advanced-search':
resetAdvancedSearch();
break;
@@ -1015,7 +992,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
// Stat card click handlers for filtering
document.querySelectorAll('.stat-card').forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', function() {
const classList = this.classList;
let url = '/?';

View File

@@ -5,14 +5,14 @@
// Helper functions for timeline display
function getEventIcon($actionType) {
$icons = [
'create' => '',
'update' => '📝',
'comment' => '💬',
'view' => '👁️',
'assign' => '👤',
'status_change' => '🔄'
'create' => '[ + ]',
'update' => '[ ~ ]',
'comment' => '[ > ]',
'view' => '[ . ]',
'assign' => '[ @ ]',
'status_change' => '[ ! ]',
];
return $icons[$actionType] ?? '';
return $icons[$actionType] ?? '[ * ]';
}
function formatAction($event) {
@@ -50,20 +50,21 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ticket #<?php echo $ticket['ticket_id']; ?></title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260131e">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
// Timezone configuration (from server)
window.APP_TIMEZONE = '<?php echo $GLOBALS['config']['TIMEZONE']; ?>';
window.APP_TIMEZONE_OFFSET = <?php echo $GLOBALS['config']['TIMEZONE_OFFSET']; ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo $GLOBALS['config']['TIMEZONE_ABBREV']; ?>';
window.APP_TIMEZONE = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
window.APP_TIMEZONE_OFFSET = <?php echo (int)($GLOBALS['config']['TIMEZONE_OFFSET'] ?? 0); ?>; // minutes from UTC
window.APP_TIMEZONE_ABBREV = '<?php echo htmlspecialchars($GLOBALS['config']['TIMEZONE_ABBREV'] ?? 'UTC', ENT_QUOTES, 'UTF-8'); ?>';
</script>
<script nonce="<?php echo $nonce; ?>">
// Store ticket data in a global variable (using json_encode for XSS safety)
@@ -80,15 +81,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<header class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<a href="/" class="back-link">[ &larr; DASHBOARD ]</a>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name">👤 <?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<?php if ($GLOBALS['currentUser']['is_admin']): ?>
<span class="admin-badge">Admin</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings"></button>
<button class="settings-icon" title="Settings (Alt+S)" id="settingsBtn" aria-label="Settings">[ CFG ]</button>
<?php endif; ?>
</div>
</header>
@@ -132,7 +133,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
}
?>
<div class="ticket-age <?php echo $ageClass; ?>" title="Time since last update">
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '⚠️' : ($ageClass === 'age-warning' ? '⏰' : '📅'); ?></span>
<span class="age-icon"><?php echo $ageClass === 'age-critical' ? '[ ! ]' : ($ageClass === 'age-warning' ? '[ ~ ]' : '[ t ]'); ?></span>
<span class="age-text">Last activity: <?php echo $ageStr; ?> ago</span>
</div>
<div class="ticket-user-info">
@@ -140,13 +141,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$creator = $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System';
echo "Created by: <strong>" . htmlspecialchars($creator) . "</strong>";
if (!empty($ticket['created_at'])) {
echo " on " . date('M d, Y H:i', strtotime($ticket['created_at']));
$createdFmt = date('M d, Y H:i', strtotime($ticket['created_at']));
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $createdFmt . "'>" . $createdFmt . "</span>";
}
if (!empty($ticket['updater_display_name']) || !empty($ticket['updater_username'])) {
$updater = $ticket['updater_display_name'] ?? $ticket['updater_username'];
echo " • Last updated by: <strong>" . htmlspecialchars($updater) . "</strong>";
if (!empty($ticket['updated_at'])) {
echo " on " . date('M d, Y H:i', strtotime($ticket['updated_at']));
$updatedFmt = date('M d, Y H:i', strtotime($ticket['updated_at']));
echo " on <span class='ts-cell' data-ts='" . htmlspecialchars($ticket['updated_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $updatedFmt . "'>" . $updatedFmt . "</span>";
}
}
?>
@@ -219,7 +222,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="confidential" <?php echo $currentVisibility == 'confidential' ? 'selected' : ''; ?>>Confidential</option>
</select>
</div>
<div class="metadata-field" id="visibilityGroupsField" <?php echo $currentVisibility !== 'internal' ? 'style="display: none;"' : ''; ?>>
<div class="metadata-field<?php echo $currentVisibility !== 'internal' ? ' is-hidden' : ''; ?>" id="visibilityGroupsField">
<label class="metadata-label metadata-label-cyan">Allowed Groups:</label>
<div class="visibility-groups-edit">
<?php foreach ($allAvailableGroups as $group):
@@ -242,23 +245,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<div class="header-controls">
<div class="status-priority-group">
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status">
<option value="<?php echo $ticket['status']; ?>" selected>
<?php echo $ticket['status']; ?> (current)
<select id="statusSelect" class="editable status-select status-<?php echo str_replace(' ', '-', strtolower($ticket["status"])); ?>" data-field="status" data-action="update-ticket-status" aria-label="Change ticket status">
<option value="<?php echo htmlspecialchars($ticket['status']); ?>" selected>
<?php echo htmlspecialchars($ticket['status']); ?> (current)
</option>
<?php foreach ($allowedTransitions as $transition): ?>
<option value="<?php echo $transition['to_status']; ?>"
<option value="<?php echo htmlspecialchars($transition['to_status']); ?>"
data-requires-comment="<?php echo $transition['requires_comment'] ? '1' : '0'; ?>"
data-requires-admin="<?php echo $transition['requires_admin'] ? '1' : '0'; ?>">
<?php echo $transition['to_status']; ?>
<?php echo htmlspecialchars($transition['to_status']); ?>
<?php if ($transition['requires_comment']): ?> *<?php endif; ?>
<?php if ($transition['requires_admin']): ?> (Admin)<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button id="editButton" class="btn">Edit Ticket</button>
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">Clone</button>
<button id="editButton" class="btn">EDIT TICKET</button>
<button id="cloneButton" class="btn btn-secondary" title="Create a copy of this ticket">CLONE</button>
</div>
</div>
</div>
@@ -272,11 +275,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-section-header">Content Sections</div>
<div class="ascii-content">
<nav class="ticket-tabs" role="tablist" aria-label="Ticket content sections">
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">Description</button>
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">Comments</button>
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">Attachments</button>
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">Dependencies</button>
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">Activity</button>
<button class="tab-btn active" id="description-tab-btn" data-tab="description" role="tab" aria-selected="true" aria-controls="description-tab">DESCRIPTION</button>
<button class="tab-btn" id="comments-tab-btn" data-tab="comments" role="tab" aria-selected="false" aria-controls="comments-tab">COMMENTS</button>
<button class="tab-btn" id="attachments-tab-btn" data-tab="attachments" role="tab" aria-selected="false" aria-controls="attachments-tab">ATTACHMENTS</button>
<button class="tab-btn" id="dependencies-tab-btn" data-tab="dependencies" role="tab" aria-selected="false" aria-controls="dependencies-tab">DEPENDENCIES</button>
<button class="tab-btn" id="activity-tab-btn" data-tab="activity" role="tab" aria-selected="false" aria-controls="activity-tab">ACTIVITY</button>
</nav>
</div>
@@ -292,7 +295,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="ascii-subsection-header">Description</div>
<div class="detail-group full-width">
<label>Description</label>
<textarea class="editable" data-field="description" disabled><?php echo $ticket["description"]; ?></textarea>
<textarea class="editable" data-field="description" disabled><?php echo htmlspecialchars($ticket["description"] ?? ''); ?></textarea>
</div>
</div>
@@ -321,9 +324,9 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<span class="toggle-label">Preview Markdown</span>
</div>
</div>
<button id="addCommentBtn" class="btn">Add Comment</button>
<button id="addCommentBtn" class="btn">ADD COMMENT</button>
</div>
<div id="markdownPreview" class="markdown-preview" style="display: none;"></div>
<div id="markdownPreview" class="markdown-preview is-hidden"></div>
</div>
</div>
@@ -360,18 +363,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
echo "<span class='comment-user'>" . htmlspecialchars($displayName) . "</span>";
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited">(edited)</span>' : '';
echo "<span class='comment-date'>{$dateStr}{$editedIndicator}</span>";
echo "<span class='comment-date'><span class='ts-cell' data-ts='" . htmlspecialchars($comment['created_at'], ENT_QUOTES, 'UTF-8') . "' title='" . $dateStr . "'>" . $dateStr . "</span>{$editedIndicator}</span>";
// Action buttons
echo "<div class='comment-actions'>";
// Reply button (max depth of 3)
if ($threadDepth < 3) {
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'></button>";
echo "<button type='button' class='comment-action-btn reply-btn' data-action='reply-comment' data-comment-id='{$commentId}' data-user=\"" . htmlspecialchars($displayName, ENT_QUOTES) . "\" title='Reply' aria-label='Reply to comment'>[ &lt;&lt; ]</button>";
}
// Edit/Delete buttons for owner or admin
if ($canModify) {
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>✏️</button>";
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>🗑️</button>";
echo "<button type='button' class='comment-action-btn edit-btn' data-action='edit-comment' data-comment-id='{$commentId}' title='Edit' aria-label='Edit comment'>[ EDIT ]</button>";
echo "<button type='button' class='comment-action-btn delete-btn' data-action='delete-comment' data-comment-id='{$commentId}' title='Delete' aria-label='Delete comment'>[ DEL ]</button>";
}
echo "</div>";
@@ -420,14 +423,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<h3>Upload Files</h3>
<div class="upload-zone" id="uploadZone">
<div class="upload-zone-content">
<div class="upload-icon">📁</div>
<div class="upload-icon">[ + ]</div>
<p>Drag and drop files here or click to browse</p>
<p class="upload-hint">Max file size: <?php echo $GLOBALS['config']['MAX_UPLOAD_SIZE'] ? number_format($GLOBALS['config']['MAX_UPLOAD_SIZE'] / 1048576, 0) . 'MB' : '10MB'; ?></p>
<input type="file" id="fileInput" multiple class="sr-only" aria-label="Upload files">
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">Browse Files</button>
<button type="button" id="browseFilesBtn" class="btn upload-browse-btn">BROWSE FILES</button>
</div>
</div>
<div id="uploadProgress" class="upload-progress" style="display: none;">
<div id="uploadProgress" class="upload-progress is-hidden">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
@@ -461,7 +464,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="relates_to">Relates To</option>
<option value="duplicates">Duplicates</option>
</select>
<button id="addDependencyBtn" class="btn">Add</button>
<button id="addDependencyBtn" class="btn" aria-label="Add ticket dependency">ADD</button>
</div>
</div>
@@ -496,7 +499,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<div class="timeline-header">
<strong><?php echo htmlspecialchars($event['display_name'] ?? $event['username'] ?? 'System'); ?></strong>
<span class="timeline-action"><?php echo formatAction($event); ?></span>
<span class="timeline-date"><?php echo date('M d, Y H:i', strtotime($event['created_at'])); ?></span>
<?php $eventFmt = date('M d, Y H:i', strtotime($event['created_at'])); ?>
<span class="timeline-date ts-cell" data-ts="<?php echo htmlspecialchars($event['created_at'], ENT_QUOTES, 'UTF-8'); ?>" title="<?php echo $eventFmt; ?>"><?php echo $eventFmt; ?></span>
</div>
<?php if (!empty($event['details'])): ?>
<div class="timeline-details">
@@ -556,39 +560,36 @@ $nonce = SecurityHeadersMiddleware::getNonce();
var cloneBtn = document.getElementById('cloneButton');
if (cloneBtn) {
cloneBtn.addEventListener('click', function() {
if (confirm('Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.')) {
showConfirmModal(
'Clone Ticket',
'Create a copy of this ticket? The new ticket will have the same title, description, priority, category, and type.',
'warning',
function() {
cloneBtn.disabled = true;
cloneBtn.textContent = 'Cloning...';
fetch('/api/clone_ticket.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
lt.api.post('/api/clone_ticket.php', {
ticket_id: window.ticketData.ticket_id
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
toast.success('Ticket cloned successfully!');
lt.toast.success('Ticket cloned successfully!');
setTimeout(function() {
window.location.href = '/ticket/' + data.new_ticket_id;
}, 1000);
} else {
toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
lt.toast.error('Failed to clone ticket: ' + (data.error || 'Unknown error'));
cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone';
}
})
.catch(function(error) {
toast.error('Failed to clone ticket: ' + error.message);
lt.toast.error('Failed to clone ticket: ' + error.message);
cloneBtn.disabled = false;
cloneBtn.textContent = 'Clone';
});
}
);
});
}
@@ -653,15 +654,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
});
}
// Settings modal backdrop click
var settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
settingsModal.addEventListener('click', function(e) {
if (e.target.classList.contains('settings-modal')) {
if (typeof closeSettingsModal === 'function') closeSettingsModal();
}
});
}
// Settings modal backdrop click (lt-modal-overlay handles this via data-modal-close)
// Handle change events for data-action
document.addEventListener('change', function(e) {
@@ -688,17 +681,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</script>
<!-- Settings Modal (same as dashboard) -->
<div class="settings-modal" id="settingsModal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
<div class="settings-content">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="settings-header">
<h3 id="ticketSettingsTitle">⚙ System Preferences</h3>
<button class="close-settings" id="closeSettingsBtn" aria-label="Close settings">✗</button>
<div class="lt-modal-overlay" id="settingsModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ticketSettingsTitle">
<div class="lt-modal">
<div class="lt-modal-header">
<span class="lt-modal-title" id="ticketSettingsTitle">[ CFG ] SYSTEM PREFERENCES</span>
<button class="lt-modal-close" data-modal-close aria-label="Close settings">✕</button>
</div>
<div class="settings-body">
<div class="lt-modal-body">
<!-- Display Preferences -->
<div class="settings-section">
<h4>╔══ Display Preferences ══╗</h4>
@@ -816,13 +806,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
<div class="settings-footer">
<button class="btn btn-primary" id="saveSettingsBtn">Save Preferences</button>
<button class="btn btn-secondary" id="cancelSettingsBtn">Cancel</button>
<div class="lt-modal-footer">
<button class="lt-btn lt-btn-primary" id="saveSettingsBtn">SAVE PREFERENCES</button>
<button class="lt-btn lt-btn-ghost" id="cancelSettingsBtn">CANCEL</button>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/keyboard-shortcuts.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/settings.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>

View File

@@ -12,9 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Keys - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
@@ -22,35 +24,34 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: API Keys</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: API Keys</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">API Key Management</div>
<div class="ascii-content">
<!-- Generate New Key Form -->
<div class="ascii-frame-inner" style="margin-bottom: 1.5rem;">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Generate New API Key</h3>
<form id="generateKeyForm" style="display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;">
<div style="flex: 1; min-width: 200px;">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline"
style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
<div class="ascii-frame-inner">
<h3 class="admin-section-title">Generate New API Key</h3>
<form id="generateKeyForm" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="keyName">Key Name *</label>
<input type="text" id="keyName" required placeholder="e.g., CI/CD Pipeline" class="admin-input">
</div>
<div style="min-width: 150px;">
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber); margin-bottom: 0.25rem;">Expires In</label>
<select id="expiresIn" style="width: 100%; padding: 0.5rem; border: 2px solid var(--terminal-green); background: var(--bg-primary); color: var(--terminal-green); font-family: var(--font-mono);">
<div class="admin-form-field">
<label class="admin-label" for="expiresIn">Expires In</label>
<select id="expiresIn" class="admin-input">
<option value="">Never</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
@@ -59,28 +60,28 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</select>
</div>
<div>
<button type="submit" class="btn">Generate Key</button>
<button type="submit" class="btn">GENERATE KEY</button>
</div>
</form>
</div>
<!-- New Key Display (hidden by default) -->
<div id="newKeyDisplay" class="ascii-frame-inner" style="display: none; margin-bottom: 1.5rem; background: rgba(255, 176, 0, 0.1); border-color: var(--terminal-amber);">
<h3 style="color: var(--terminal-amber); margin-bottom: 0.5rem;">New API Key Generated</h3>
<p style="color: var(--priority-1); margin-bottom: 1rem; font-size: 0.9rem;">
<div id="newKeyDisplay" class="ascii-frame-inner key-generated-alert is-hidden">
<h3 class="admin-section-title">New API Key Generated</h3>
<p class="text-danger text-sm mb-1">
Copy this key now. You won't be able to see it again!
</p>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<input type="text" id="newKeyValue" readonly
style="flex: 1; padding: 0.75rem; font-family: var(--font-mono); font-size: 0.85rem; background: var(--bg-primary); border: 2px solid var(--terminal-green); color: var(--terminal-green);">
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">Copy</button>
<div class="admin-form-row">
<input type="text" id="newKeyValue" readonly class="admin-input">
<button data-action="copy-api-key" class="btn" title="Copy to clipboard">COPY</button>
</div>
</div>
<!-- Existing Keys Table -->
<div class="ascii-frame-inner">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">Existing API Keys</h3>
<table style="width: 100%; font-size: 0.9rem;">
<h3 class="admin-section-title">Existing API Keys</h3>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Name</th>
@@ -96,50 +97,45 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody>
<?php if (empty($apiKeys)): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No API keys found. Generate one above.
</td>
<td colspan="8" class="empty-state">No API keys found. Generate one above.</td>
</tr>
<?php else: ?>
<?php foreach ($apiKeys as $key): ?>
<tr id="key-row-<?php echo $key['api_key_id']; ?>">
<td><?php echo htmlspecialchars($key['key_name']); ?></td>
<td style="font-family: var(--font-mono);">
<td class="mono">
<code><?php echo htmlspecialchars($key['key_prefix']); ?>...</code>
</td>
<td><?php echo htmlspecialchars($key['display_name'] ?? $key['username'] ?? 'Unknown'); ?></td>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td style="white-space: nowrap;">
<td class="nowrap"><?php echo date('Y-m-d H:i', strtotime($key['created_at'])); ?></td>
<td class="nowrap">
<?php if ($key['expires_at']): ?>
<?php
$expired = strtotime($key['expires_at']) < time();
$color = $expired ? 'var(--priority-1)' : 'var(--terminal-green)';
?>
<span style="color: <?php echo $color; ?>;">
<?php $expired = strtotime($key['expires_at']) < time(); ?>
<span class="<?php echo $expired ? 'text-danger' : ''; ?>">
<?php echo date('Y-m-d', strtotime($key['expires_at'])); ?>
<?php if ($expired): ?> (Expired)<?php endif; ?>
</span>
<?php else: ?>
<span style="color: var(--terminal-cyan);">Never</span>
<span class="text-cyan">Never</span>
<?php endif; ?>
</td>
<td style="white-space: nowrap;">
<td class="nowrap">
<?php echo $key['last_used'] ? date('Y-m-d H:i', strtotime($key['last_used'])) : 'Never'; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<span style="color: var(--status-open);">Active</span>
<span class="text-open">Active</span>
<?php else: ?>
<span style="color: var(--status-closed);">Revoked</span>
<span class="text-closed">Revoked</span>
<?php endif; ?>
</td>
<td>
<?php if ($key['is_active']): ?>
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;">
Revoke
<button data-action="revoke-key" data-id="<?php echo $key['api_key_id']; ?>" class="btn btn-secondary btn-small">
REVOKE
</button>
<?php else: ?>
<span style="color: var(--text-muted);">-</span>
<span class="text-muted">-</span>
<?php endif; ?>
</td>
</tr>
@@ -148,13 +144,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</tbody>
</table>
</div>
</div>
<!-- API Usage Info -->
<div class="ascii-frame-inner" style="margin-top: 1.5rem;">
<h3 style="color: var(--terminal-amber); margin-bottom: 1rem;">API Usage</h3>
<p style="margin-bottom: 0.5rem;">Include the API key in your requests using the Authorization header:</p>
<pre style="background: var(--bg-primary); padding: 1rem; border: 1px solid var(--terminal-green); overflow-x: auto;"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--text-muted);">
<div class="ascii-frame-inner">
<h3 class="admin-section-title">API Usage</h3>
<p>Include the API key in your requests using the Authorization header:</p>
<pre class="admin-code-block"><code>Authorization: Bearer YOUR_API_KEY</code></pre>
<p class="text-muted text-sm">
API keys provide programmatic access to create and manage tickets. Keep your keys secure and rotate them regularly.
</p>
</div>
@@ -185,40 +182,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const expiresIn = document.getElementById('expiresIn').value;
if (!keyName) {
showToast('Please enter a key name', 'error');
lt.toast.error('Please enter a key name');
return;
}
try {
const response = await fetch('/api/generate_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({
const data = await lt.api.post('/api/generate_api_key.php', {
key_name: keyName,
expires_in_days: expiresIn || null
})
});
const data = await response.json();
if (data.success) {
// Show the new key
document.getElementById('newKeyValue').value = data.api_key;
document.getElementById('newKeyDisplay').style.display = 'block';
document.getElementById('newKeyDisplay').classList.remove('is-hidden');
document.getElementById('keyName').value = '';
showToast('API key generated successfully', 'success');
lt.toast.success('API key generated successfully');
// Reload page after 5 seconds to show new key in table
setTimeout(() => location.reload(), 5000);
} else {
showToast(data.error || 'Failed to generate API key', 'error');
lt.toast.error(data.error || 'Failed to generate API key');
}
} catch (error) {
showToast('Error generating API key: ' + error.message, 'error');
lt.toast.error('Error generating API key: ' + error.message);
}
});
@@ -226,35 +214,24 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const keyInput = document.getElementById('newKeyValue');
keyInput.select();
document.execCommand('copy');
showToast('API key copied to clipboard', 'success');
lt.toast.success('API key copied to clipboard');
}
async function revokeKey(keyId) {
if (!confirm('Are you sure you want to revoke this API key? This action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/revoke_api_key.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify({ key_id: keyId })
});
const data = await response.json();
function revokeKey(keyId) {
showConfirmModal('Revoke API Key', 'Are you sure you want to revoke this API key? This action cannot be undone.', 'error', function() {
lt.api.post('/api/revoke_api_key.php', { key_id: keyId })
.then(data => {
if (data.success) {
showToast('API key revoked successfully', 'success');
lt.toast.success('API key revoked successfully');
location.reload();
} else {
showToast(data.error || 'Failed to revoke API key', 'error');
}
} catch (error) {
showToast('Error revoking API key: ' + error.message, 'error');
lt.toast.error(data.error || 'Failed to revoke API key');
}
})
.catch(error => {
lt.toast.error('Error revoking API key: ' + error.message);
});
});
}
</script>
</body>

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for browsing audit logs
// Receives $auditLogs, $totalPages, $page, $filters from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<html lang="en">
@@ -9,24 +12,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audit Log - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Audit Log</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Audit Log</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1400px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container-wide">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
@@ -34,10 +42,10 @@
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Filters -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap;">
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Action Type</label>
<select name="action_type" class="setting-select">
<form method="GET" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="action_type">Action Type</label>
<select name="action_type" id="action_type" class="admin-input">
<option value="">All Actions</option>
<option value="create" <?php echo ($filters['action_type'] ?? '') === 'create' ? 'selected' : ''; ?>>Create</option>
<option value="update" <?php echo ($filters['action_type'] ?? '') === 'update' ? 'selected' : ''; ?>>Update</option>
@@ -49,9 +57,9 @@
<option value="security" <?php echo ($filters['action_type'] ?? '') === 'security' ? 'selected' : ''; ?>>Security</option>
</select>
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">User</label>
<select name="user_id" class="setting-select">
<div class="admin-form-field">
<label class="admin-label" for="user_id">User</label>
<select name="user_id" id="user_id" class="admin-input">
<option value="">All Users</option>
<?php if (isset($users)): foreach ($users as $user): ?>
<option value="<?php echo $user['user_id']; ?>" <?php echo ($filters['user_id'] ?? '') == $user['user_id'] ? 'selected' : ''; ?>>
@@ -60,22 +68,23 @@
<?php endforeach; endif; ?>
</select>
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="setting-select">
<div class="admin-form-field">
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($filters['date_from'] ?? ''); ?>" class="admin-input">
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="setting-select">
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($filters['date_to'] ?? ''); ?>" class="admin-input">
</div>
<div style="display: flex; align-items: flex-end;">
<button type="submit" class="btn">Filter</button>
<a href="?" class="btn btn-secondary" style="margin-left: 0.5rem;">Reset</a>
<div class="admin-form-actions">
<button type="submit" class="btn">FILTER</button>
<a href="?" class="btn btn-secondary">RESET</a>
</div>
</form>
<!-- Log Table -->
<table style="width: 100%; font-size: 0.9rem;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Timestamp</th>
@@ -90,34 +99,32 @@
<tbody>
<?php if (empty($auditLogs)): ?>
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No audit log entries found.
</td>
<td colspan="7" class="empty-state">No audit log entries found.</td>
</tr>
<?php else: ?>
<?php foreach ($auditLogs as $log): ?>
<tr>
<td style="white-space: nowrap;"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td class="nowrap"><?php echo date('Y-m-d H:i:s', strtotime($log['created_at'])); ?></td>
<td><?php echo htmlspecialchars($log['display_name'] ?? $log['username'] ?? 'System'); ?></td>
<td>
<span style="color: var(--terminal-amber);"><?php echo htmlspecialchars($log['action_type']); ?></span>
<span class="text-amber"><?php echo htmlspecialchars($log['action_type']); ?></span>
</td>
<td><?php echo htmlspecialchars($log['entity_type'] ?? '-'); ?></td>
<td>
<?php if ($log['entity_type'] === 'ticket' && $log['entity_id']): ?>
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" style="color: var(--terminal-green);">
<a href="/ticket/<?php echo htmlspecialchars($log['entity_id']); ?>" class="text-green">
<?php echo htmlspecialchars($log['entity_id']); ?>
</a>
<?php else: ?>
<?php echo htmlspecialchars($log['entity_id'] ?? '-'); ?>
<?php endif; ?>
</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">
<td class="td-truncate">
<?php
if ($log['details']) {
$details = is_string($log['details']) ? json_decode($log['details'], true) : $log['details'];
if (is_array($details)) {
echo '<code style="font-size: 0.8rem;">' . htmlspecialchars(json_encode($details)) . '</code>';
echo '<code>' . htmlspecialchars(json_encode($details)) . '</code>';
} else {
echo htmlspecialchars($log['details']);
}
@@ -126,22 +133,23 @@
}
?>
</td>
<td style="white-space: nowrap; font-size: 0.85rem;"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
<td class="nowrap text-sm"><?php echo htmlspecialchars($log['ip_address'] ?? '-'); ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Pagination -->
<?php if ($totalPages > 1): ?>
<div class="pagination" style="margin-top: 1rem; text-align: center;">
<div class="pagination">
<?php
$params = $_GET;
for ($i = 1; $i <= min($totalPages, 10); $i++) {
$params['page'] = $i;
$activeClass = ($i == $page) ? 'active' : '';
$url = '?' . http_build_query($params);
$url = htmlspecialchars('?' . http_build_query($params), ENT_QUOTES, 'UTF-8');
echo "<a href='$url' class='btn btn-small $activeClass'>$i</a> ";
}
if ($totalPages > 10) {
@@ -153,5 +161,6 @@
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Fields - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Custom Fields</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Custom Fields</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Custom Fields Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ New Field</button>
<div class="admin-header-row">
<h2>Custom Field Definitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW FIELD</button>
</div>
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Order</th>
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody>
<?php if (empty($customFields)): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No custom fields defined.
</td>
<td colspan="8" class="empty-state">No custom fields defined.</td>
</tr>
<?php else: ?>
<?php foreach ($customFields as $field): ?>
@@ -74,13 +76,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($field['category'] ?? 'All'); ?></td>
<td><?php echo $field['is_required'] ? 'Yes' : 'No'; ?></td>
<td>
<span style="color: <?php echo $field['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<span class="<?php echo $field['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $field['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<button data-action="edit-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-field" data-id="<?php echo $field['field_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
@@ -90,17 +92,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="fieldModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 500px;">
<div class="settings-header">
<h3 id="modalTitle">Create Custom Field</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="fieldModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Custom Field</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="fieldForm">
<input type="hidden" id="field_id" name="field_id">
<div class="settings-body">
<div class="lt-modal-body">
<div class="setting-row">
<label for="field_name">Field Name * (internal)</label>
<input type="text" id="field_name" name="field_name" required pattern="[a-z_]+" placeholder="e.g., server_name">
@@ -120,7 +123,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="number">Number</option>
</select>
</div>
<div class="setting-row" id="options_row" style="display: none;">
<div class="setting-row is-hidden" id="options_row">
<label for="field_options">Options (one per line)</label>
<textarea id="field_options" name="field_options" rows="4" placeholder="Option 1&#10;Option 2&#10;Option 3"></textarea>
</div>
@@ -146,15 +149,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Custom Field';
@@ -162,11 +164,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('field_id').value = '';
document.getElementById('is_active').checked = true;
toggleOptionsField();
document.getElementById('fieldModal').style.display = 'flex';
lt.modal.open('fieldModal');
}
function closeModal() {
document.getElementById('fieldModal').style.display = 'none';
lt.modal.close('fieldModal');
}
// Event delegation for data-action handlers
@@ -179,12 +181,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-field':
editField(target.dataset.id);
break;
@@ -208,16 +204,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveField(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function toggleOptionsField() {
const type = document.getElementById('field_type').value;
document.getElementById('options_row').style.display = type === 'select' ? 'block' : 'none';
document.getElementById('options_row').classList.toggle('is-hidden', type !== 'select');
}
function saveField(e) {
@@ -239,30 +230,19 @@ $nonce = SecurityHeadersMiddleware::getNonce();
data.field_options = { options: options };
}
const method = data.field_id ? 'PUT' : 'POST';
const url = '/api/custom_fields.php' + (data.field_id ? '?id=' + data.field_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
const apiCall = data.field_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function editField(id) {
fetch('/api/custom_fields.php?id=' + id)
.then(r => r.json())
lt.api.get('/api/custom_fields.php?id=' + id)
.then(data => {
if (data.success && data.field) {
const f = data.field;
@@ -279,20 +259,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('field_options').value = f.field_options.options.join('\n');
}
document.getElementById('modalTitle').textContent = 'Edit Custom Field';
document.getElementById('fieldModal').style.display = 'flex';
lt.modal.open('fieldModal');
}
});
}
function deleteField(id) {
if (!confirm('Delete this custom field? All values will be lost.')) return;
fetch('/api/custom_fields.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
showConfirmModal('Delete Custom Field', 'Delete this custom field? All values will be lost.', 'error', function() {
lt.api.delete('/api/custom_fields.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recurring Tickets - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
@@ -21,30 +24,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Recurring Tickets</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Recurring Tickets</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Recurring Tickets Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ New Recurring Ticket</button>
<div class="admin-header-row">
<h2>Scheduled Tickets</h2>
<button data-action="show-create-modal" class="btn">+ NEW RECURRING TICKET</button>
</div>
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
@@ -60,9 +64,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody>
<?php if (empty($recurringTickets)): ?>
<tr>
<td colspan="8" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No recurring tickets configured.
</td>
<td colspan="8" class="empty-state">No recurring tickets configured.</td>
</tr>
<?php else: ?>
<?php foreach ($recurringTickets as $rt): ?>
@@ -79,23 +81,23 @@ $nonce = SecurityHeadersMiddleware::getNonce();
$schedule .= ' (Day ' . $rt['schedule_day'] . ')';
}
$schedule .= ' @ ' . substr($rt['schedule_time'], 0, 5);
echo $schedule;
echo htmlspecialchars($schedule);
?>
</td>
<td><?php echo htmlspecialchars($rt['category']); ?></td>
<td><?php echo htmlspecialchars($rt['assigned_name'] ?? $rt['assigned_username'] ?? 'Unassigned'); ?></td>
<td><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td class="nowrap"><?php echo date('M d, Y H:i', strtotime($rt['next_run_at'])); ?></td>
<td>
<span style="color: <?php echo $rt['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<span class="<?php echo $rt['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $rt['is_active'] ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="edit-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="toggle-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small">
<?php echo $rt['is_active'] ? 'Disable' : 'Enable'; ?>
<?php echo $rt['is_active'] ? 'DISABLE' : 'ENABLE'; ?>
</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<button data-action="delete-recurring" data-id="<?php echo $rt['recurring_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
@@ -105,24 +107,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="recurringModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 800px; width: 90%;">
<div class="settings-header">
<h3 id="modalTitle">Create Recurring Ticket</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="recurringModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Recurring Ticket</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="recurringForm">
<input type="hidden" id="recurring_id" name="recurring_id">
<div class="settings-body">
<div class="lt-modal-body">
<div class="setting-row">
<label for="title_template">Title Template *</label>
<input type="text" id="title_template" name="title_template" required style="width: 100%;" placeholder="Use {{date}}, {{month}}, etc.">
<input type="text" id="title_template" name="title_template" required placeholder="Use {{date}}, {{month}}, etc.">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="8" style="width: 100%; min-height: 150px;"></textarea>
<textarea id="description_template" name="description_template" rows="8"></textarea>
</div>
<div class="setting-row">
<label for="schedule_type">Schedule Type *</label>
@@ -132,7 +135,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<option value="monthly">Monthly</option>
</select>
</div>
<div class="setting-row" id="schedule_day_row" style="display: none;">
<div class="setting-row is-hidden" id="schedule_day_row">
<label for="schedule_day">Schedule Day</label>
<select id="schedule_day" name="schedule_day"></select>
</div>
@@ -140,7 +143,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label for="schedule_time">Schedule Time *</label>
<input type="time" id="schedule_time" name="schedule_time" value="09:00" required>
</div>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="setting-grid-2">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
@@ -181,26 +184,25 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
function showCreateModal() {
document.getElementById('modalTitle').textContent = 'Create Recurring Ticket';
document.getElementById('recurringForm').reset();
document.getElementById('recurring_id').value = '';
updateScheduleOptions();
document.getElementById('recurringModal').style.display = 'flex';
lt.modal.open('recurringModal');
}
function closeModal() {
document.getElementById('recurringModal').style.display = 'none';
lt.modal.close('recurringModal');
}
// Event delegation for data-action handlers
@@ -213,12 +215,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-recurring':
editRecurring(target.dataset.id);
break;
@@ -245,12 +241,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveRecurring(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function updateScheduleOptions() {
const type = document.getElementById('schedule_type').value;
@@ -260,15 +251,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
daySelect.innerHTML = '';
if (type === 'daily') {
dayRow.style.display = 'none';
dayRow.classList.add('is-hidden');
} else if (type === 'weekly') {
dayRow.style.display = 'flex';
dayRow.classList.remove('is-hidden');
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
days.forEach((day, i) => {
daySelect.innerHTML += `<option value="${i + 1}">${day}</option>`;
});
} else if (type === 'monthly') {
dayRow.style.display = 'flex';
dayRow.classList.remove('is-hidden');
for (let i = 1; i <= 28; i++) {
daySelect.innerHTML += `<option value="${i}">Day ${i}</option>`;
}
@@ -280,53 +271,37 @@ $nonce = SecurityHeadersMiddleware::getNonce();
const form = new FormData(document.getElementById('recurringForm'));
const data = Object.fromEntries(form);
const method = data.recurring_id ? 'PUT' : 'POST';
const url = '/api/manage_recurring.php' + (data.recurring_id ? '?id=' + data.recurring_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
const apiCall = data.recurring_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(data.error || 'Failed to save');
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function toggleRecurring(id) {
fetch('/api/manage_recurring.php?action=toggle&id=' + id, {
method: 'POST',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
lt.api.post('/api/manage_recurring.php?action=toggle&id=' + id)
.then(data => {
if (data.success) window.location.reload();
});
else lt.toast.error(data.error || 'Failed to toggle');
}).catch(err => lt.toast.error('Failed to toggle'));
}
function deleteRecurring(id) {
if (!confirm('Delete this recurring ticket schedule?')) return;
fetch('/api/manage_recurring.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
showConfirmModal('Delete Schedule', 'Delete this recurring ticket schedule?', 'error', function() {
lt.api.delete('/api/manage_recurring.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
function editRecurring(id) {
fetch('/api/manage_recurring.php?id=' + id)
.then(r => r.json())
lt.api.get('/api/manage_recurring.php?id=' + id)
.then(data => {
if (data.success && data.recurring) {
const rt = data.recurring;
@@ -342,15 +317,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('priority').value = rt.priority || 4;
document.getElementById('assigned_to').value = rt.assigned_to || '';
document.getElementById('modalTitle').textContent = 'Edit Recurring Ticket';
document.getElementById('recurringModal').style.display = 'flex';
lt.modal.open('recurringModal');
}
});
}
// Load users for assignee dropdown
function loadUsers() {
fetch('/api/get_users.php')
.then(r => r.json())
lt.api.get('/api/get_users.php')
.then(data => {
if (data.success && data.users) {
const select = document.getElementById('assigned_to');

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Management - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
@@ -21,34 +24,35 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Templates</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Templates</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Ticket Template Management</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ New Template</button>
<div class="admin-header-row">
<h2>Ticket Templates</h2>
<button data-action="show-create-modal" class="btn">+ NEW TEMPLATE</button>
</div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
<p class="text-muted-green mb-1">
Templates pre-fill ticket creation forms with standard content for common ticket types.
</p>
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Template Name</th>
@@ -62,9 +66,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody>
<?php if (empty($templates)): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No templates defined. Create templates to speed up ticket creation.
</td>
<td colspan="6" class="empty-state">No templates defined. Create templates to speed up ticket creation.</td>
</tr>
<?php else: ?>
<?php foreach ($templates as $tpl): ?>
@@ -74,13 +76,13 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<td><?php echo htmlspecialchars($tpl['type'] ?? 'Any'); ?></td>
<td>P<?php echo $tpl['default_priority'] ?? '4'; ?></td>
<td>
<span style="color: <?php echo ($tpl['is_active'] ?? 1) ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<span class="<?php echo ($tpl['is_active'] ?? 1) ? 'text-open' : 'text-closed'; ?>">
<?php echo ($tpl['is_active'] ?? 1) ? 'Active' : 'Inactive'; ?>
</span>
</td>
<td>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<button data-action="edit-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-template" data-id="<?php echo $tpl['template_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
@@ -90,30 +92,31 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="templateModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 800px; width: 90%;">
<div class="settings-header">
<h3 id="modalTitle">Create Template</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="templateModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-lg">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Template</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="templateForm">
<input type="hidden" id="template_id" name="template_id">
<div class="settings-body">
<div class="lt-modal-body">
<div class="setting-row">
<label for="template_name">Template Name *</label>
<input type="text" id="template_name" name="template_name" required style="width: 100%;">
<input type="text" id="template_name" name="template_name" required>
</div>
<div class="setting-row">
<label for="title_template">Title Template</label>
<input type="text" id="title_template" name="title_template" style="width: 100%;" placeholder="Pre-filled title text">
<input type="text" id="title_template" name="title_template" placeholder="Pre-filled title text">
</div>
<div class="setting-row">
<label for="description_template">Description Template</label>
<textarea id="description_template" name="description_template" rows="10" style="width: 100%; min-height: 200px;" placeholder="Pre-filled description content"></textarea>
<textarea id="description_template" name="description_template" rows="10" placeholder="Pre-filled description content"></textarea>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<div class="setting-grid-3">
<div class="setting-row setting-row-compact">
<label for="category">Category</label>
<select id="category" name="category">
@@ -152,15 +155,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const templates = <?php echo json_encode($templates ?? []); ?>;
@@ -169,11 +171,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('templateForm').reset();
document.getElementById('template_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('templateModal').style.display = 'flex';
lt.modal.open('templateModal');
}
function closeModal() {
document.getElementById('templateModal').style.display = 'none';
lt.modal.close('templateModal');
}
// Event delegation for data-action handlers
@@ -186,12 +188,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-template':
editTemplate(target.dataset.id);
break;
@@ -206,12 +202,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveTemplate(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function saveTemplate(e) {
e.preventDefault();
@@ -226,25 +217,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const method = data.template_id ? 'PUT' : 'POST';
const url = '/api/manage_templates.php' + (data.template_id ? '?id=' + data.template_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
const apiCall = data.template_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function editTemplate(id) {
@@ -260,18 +241,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('priority').value = tpl.default_priority || 4;
document.getElementById('is_active').checked = (tpl.is_active ?? 1) == 1;
document.getElementById('modalTitle').textContent = 'Edit Template';
document.getElementById('templateModal').style.display = 'flex';
lt.modal.open('templateModal');
}
function deleteTemplate(id) {
if (!confirm('Delete this template?')) return;
fetch('/api/manage_templates.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
showConfirmModal('Delete Template', 'Delete this template?', 'error', function() {
lt.api.delete('/api/manage_templates.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>

View File

@@ -1,6 +1,9 @@
<?php
// Admin view for user activity reports
// Receives $userStats, $dateRange from controller
require_once __DIR__ . '/../../middleware/SecurityHeadersMiddleware.php';
require_once __DIR__ . '/../../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
?>
<!DOCTYPE html>
<html lang="en">
@@ -9,24 +12,29 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Activity - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
</head>
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: User Activity</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: User Activity</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
@@ -34,37 +42,38 @@
<div class="ascii-content">
<div class="ascii-frame-inner">
<!-- Date Range Filter -->
<form method="GET" style="display: flex; gap: 1rem; margin-bottom: 1.5rem; align-items: flex-end;">
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date From</label>
<input type="date" name="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="setting-select">
<form method="GET" class="admin-form-row">
<div class="admin-form-field">
<label class="admin-label" for="date_from">Date From</label>
<input type="date" name="date_from" id="date_from" value="<?php echo htmlspecialchars($dateRange['from'] ?? ''); ?>" class="admin-input">
</div>
<div>
<label style="display: block; font-size: 0.8rem; color: var(--terminal-amber);">Date To</label>
<input type="date" name="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="setting-select">
<div class="admin-form-field">
<label class="admin-label" for="date_to">Date To</label>
<input type="date" name="date_to" id="date_to" value="<?php echo htmlspecialchars($dateRange['to'] ?? ''); ?>" class="admin-input">
</div>
<div class="admin-form-actions">
<button type="submit" class="btn">APPLY</button>
<a href="?" class="btn btn-secondary">RESET</a>
</div>
<button type="submit" class="btn">Apply</button>
<a href="?" class="btn btn-secondary">Reset</a>
</form>
<!-- User Activity Table -->
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>User</th>
<th style="text-align: center;">Tickets Created</th>
<th style="text-align: center;">Tickets Resolved</th>
<th style="text-align: center;">Comments Added</th>
<th style="text-align: center;">Tickets Assigned</th>
<th style="text-align: center;">Last Activity</th>
<th class="text-center">Tickets Created</th>
<th class="text-center">Tickets Resolved</th>
<th class="text-center">Comments Added</th>
<th class="text-center">Tickets Assigned</th>
<th class="text-center">Last Activity</th>
</tr>
</thead>
<tbody>
<?php if (empty($userStats)): ?>
<tr>
<td colspan="6" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No user activity data available.
</td>
<td colspan="6" class="empty-state">No user activity data available.</td>
</tr>
<?php else: ?>
<?php foreach ($userStats as $user): ?>
@@ -72,22 +81,22 @@
<td>
<strong><?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?></strong>
<?php if ($user['is_admin']): ?>
<span class="admin-badge" style="font-size: 0.7rem;">Admin</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-green); font-weight: bold;"><?php echo $user['tickets_created'] ?? 0; ?></span>
<td class="text-center">
<span class="text-green fw-bold"><?php echo $user['tickets_created'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--status-open); font-weight: bold;"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
<td class="text-center">
<span class="text-open fw-bold"><?php echo $user['tickets_resolved'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-cyan); font-weight: bold;"><?php echo $user['comments_added'] ?? 0; ?></span>
<td class="text-center">
<span class="text-cyan fw-bold"><?php echo $user['comments_added'] ?? 0; ?></span>
</td>
<td style="text-align: center;">
<span style="color: var(--terminal-amber); font-weight: bold;"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
<td class="text-center">
<span class="text-amber fw-bold"><?php echo $user['tickets_assigned'] ?? 0; ?></span>
</td>
<td style="text-align: center; font-size: 0.9rem;">
<td class="text-center text-sm">
<?php echo $user['last_activity'] ? date('M d, Y H:i', strtotime($user['last_activity'])) : 'Never'; ?>
</td>
</tr>
@@ -95,41 +104,32 @@
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Summary Stats -->
<?php if (!empty($userStats)): ?>
<div style="margin-top: 2rem; padding: 1rem; border: 1px solid var(--terminal-green);">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Summary</h4>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; text-align: center;">
<div class="admin-stats-grid">
<div>
<div style="font-size: 1.5rem; color: var(--terminal-green); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'tickets_created')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Created</div>
<div class="admin-stat-value text-green"><?php echo array_sum(array_column($userStats, 'tickets_created')); ?></div>
<div class="admin-stat-label">Total Created</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--status-open); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Resolved</div>
<div class="admin-stat-value text-open"><?php echo array_sum(array_column($userStats, 'tickets_resolved')); ?></div>
<div class="admin-stat-label">Total Resolved</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--terminal-cyan); font-weight: bold;">
<?php echo array_sum(array_column($userStats, 'comments_added')); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Total Comments</div>
<div class="admin-stat-value text-cyan"><?php echo array_sum(array_column($userStats, 'comments_added')); ?></div>
<div class="admin-stat-label">Total Comments</div>
</div>
<div>
<div style="font-size: 1.5rem; color: var(--terminal-amber); font-weight: bold;">
<?php echo count($userStats); ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim);">Active Users</div>
</div>
<div class="admin-stat-value text-amber"><?php echo count($userStats); ?></div>
<div class="admin-stat-label">Active Users</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<script nonce="<?php echo $nonce; ?>">if (window.lt) lt.keys.initDefaults();</script>
</body>
</html>

View File

@@ -12,8 +12,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Workflow Designer - Admin</title>
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css">
<link rel="stylesheet" href="/assets/css/base.css">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260320">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260320">
<script nonce="<?php echo $nonce; ?>" src="/assets/js/base.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/utils.js?v=20260320"></script>
<script nonce="<?php echo $nonce; ?>">
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
</script>
@@ -21,47 +24,47 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<body>
<div class="user-header">
<div class="user-header-left">
<a href="/" class="back-link">← Dashboard</a>
<span style="margin-left: 1rem; color: var(--terminal-amber);">Admin: Workflow Designer</span>
<a href="/" class="back-link">[ ← DASHBOARD ]</a>
<span class="admin-page-title">Admin: Workflow Designer</span>
</div>
<div class="user-header-right">
<?php if (isset($GLOBALS['currentUser'])): ?>
<span class="user-name"><?php echo htmlspecialchars($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username']); ?></span>
<span class="admin-badge">Admin</span>
<span class="user-name">[ <?php echo htmlspecialchars(strtoupper($GLOBALS['currentUser']['display_name'] ?? $GLOBALS['currentUser']['username'])); ?> ]</span>
<span class="admin-badge">[ ADMIN ]</span>
<?php endif; ?>
</div>
</div>
<div class="ascii-frame-outer" style="max-width: 1200px; margin: 2rem auto;">
<div class="ascii-frame-outer admin-container">
<span class="bottom-left-corner">╚</span>
<span class="bottom-right-corner">╝</span>
<div class="ascii-section-header">Status Workflow Designer</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ New Transition</button>
<div class="admin-header-row">
<h2>Status Transitions</h2>
<button data-action="show-create-modal" class="btn">+ NEW TRANSITION</button>
</div>
<p style="color: var(--terminal-green-dim); margin-bottom: 1rem;">
<p class="text-muted-green mb-1">
Define which status transitions are allowed. This controls what options appear in the status dropdown.
</p>
<!-- Visual Workflow Diagram -->
<div style="margin-bottom: 2rem; padding: 1rem; border: 1px solid var(--terminal-green); background: var(--bg-secondary);">
<h4 style="color: var(--terminal-amber); margin-bottom: 1rem;">Workflow Diagram</h4>
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap;">
<div class="workflow-diagram">
<h4 class="admin-section-title">Workflow Diagram</h4>
<div class="workflow-diagram-nodes">
<?php
$statuses = ['Open', 'Pending', 'In Progress', 'Closed'];
foreach ($statuses as $status):
$statusClass = 'status-' . str_replace(' ', '-', strtolower($status));
?>
<div style="text-align: center;">
<div class="<?php echo $statusClass; ?>" style="padding: 0.5rem 1rem; display: inline-block;">
<div class="workflow-diagram-node">
<div class="<?php echo $statusClass; ?>">
<?php echo $status; ?>
</div>
<div style="font-size: 0.8rem; color: var(--terminal-green-dim); margin-top: 0.5rem;">
<div class="text-muted-green workflow-diagram-node-label">
<?php
$toCount = 0;
if (isset($workflows)) {
@@ -78,7 +81,8 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
<!-- Transitions Table -->
<table style="width: 100%;">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>From Status</th>
@@ -93,9 +97,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<tbody>
<?php if (empty($workflows)): ?>
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--terminal-green-dim);">
No transitions defined. Add transitions to enable status changes.
</td>
<td colspan="7" class="empty-state">No transitions defined. Add transitions to enable status changes.</td>
</tr>
<?php else: ?>
<?php foreach ($workflows as $wf): ?>
@@ -105,22 +107,22 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<?php echo htmlspecialchars($wf['from_status']); ?>
</span>
</td>
<td style="text-align: center; color: var(--terminal-amber);">→</td>
<td class="text-amber text-center">→</td>
<td>
<span class="status-<?php echo str_replace(' ', '-', strtolower($wf['to_status'])); ?>">
<?php echo htmlspecialchars($wf['to_status']); ?>
</span>
</td>
<td style="text-align: center;"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td style="text-align: center;"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td style="text-align: center;">
<span style="color: <?php echo $wf['is_active'] ? 'var(--status-open)' : 'var(--status-closed)'; ?>;">
<td class="text-center"><?php echo $wf['requires_comment'] ? '✓' : ''; ?></td>
<td class="text-center"><?php echo $wf['requires_admin'] ? '✓' : ''; ?></td>
<td class="text-center">
<span class="<?php echo $wf['is_active'] ? 'text-open' : 'text-closed'; ?>">
<?php echo $wf['is_active'] ? '✓' : '✗'; ?>
</span>
</td>
<td>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">Edit</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">Delete</button>
<button data-action="edit-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small">EDIT</button>
<button data-action="delete-transition" data-id="<?php echo $wf['transition_id']; ?>" class="btn btn-small btn-danger">DELETE</button>
</td>
</tr>
<?php endforeach; ?>
@@ -130,17 +132,18 @@ $nonce = SecurityHeadersMiddleware::getNonce();
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="settings-modal" id="workflowModal" style="display: none;" data-action="close-modal-backdrop">
<div class="settings-content" style="max-width: 450px;">
<div class="settings-header">
<h3 id="modalTitle">Create Transition</h3>
<button class="close-settings" data-action="close-modal">×</button>
<div class="lt-modal-overlay" id="workflowModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="lt-modal lt-modal-sm">
<div class="lt-modal-header">
<span class="lt-modal-title" id="modalTitle">Create Transition</span>
<button class="lt-modal-close" data-modal-close aria-label="Close"></button>
</div>
<form id="workflowForm">
<input type="hidden" id="transition_id" name="transition_id">
<div class="settings-body">
<div class="lt-modal-body">
<div class="setting-row">
<label for="from_status">From Status *</label>
<select id="from_status" name="from_status" required>
@@ -169,15 +172,14 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<label><input type="checkbox" id="is_active" name="is_active" checked> Active</label>
</div>
</div>
<div class="settings-footer">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" data-action="close-modal">Cancel</button>
<div class="lt-modal-footer">
<button type="submit" class="lt-btn lt-btn-primary">SAVE</button>
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>CANCEL</button>
</div>
</form>
</div>
</div>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>">
const workflows = <?php echo json_encode($workflows ?? []); ?>;
@@ -186,11 +188,11 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('workflowForm').reset();
document.getElementById('transition_id').value = '';
document.getElementById('is_active').checked = true;
document.getElementById('workflowModal').style.display = 'flex';
lt.modal.open('workflowModal');
}
function closeModal() {
document.getElementById('workflowModal').style.display = 'none';
lt.modal.close('workflowModal');
}
// Event delegation for data-action handlers
@@ -203,12 +205,6 @@ $nonce = SecurityHeadersMiddleware::getNonce();
case 'show-create-modal':
showCreateModal();
break;
case 'close-modal':
closeModal();
break;
case 'close-modal-backdrop':
if (event.target === target) closeModal();
break;
case 'edit-transition':
editTransition(target.dataset.id);
break;
@@ -223,12 +219,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
saveTransition(e);
});
// Close modal on ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
}
});
if (window.lt) lt.keys.initDefaults();
function saveTransition(e) {
e.preventDefault();
@@ -241,25 +232,15 @@ $nonce = SecurityHeadersMiddleware::getNonce();
is_active: document.getElementById('is_active').checked ? 1 : 0
};
const method = data.transition_id ? 'PUT' : 'POST';
const url = '/api/manage_workflows.php' + (data.transition_id ? '?id=' + data.transition_id : '');
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.CSRF_TOKEN
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
const apiCall = data.transition_id ? lt.api.put(url, data) : lt.api.post(url, data);
apiCall.then(result => {
if (result.success) {
window.location.reload();
} else {
toast.error(result.error || 'Failed to save');
lt.toast.error(result.error || 'Failed to save');
}
});
}).catch(err => lt.toast.error('Failed to save'));
}
function editTransition(id) {
@@ -273,18 +254,16 @@ $nonce = SecurityHeadersMiddleware::getNonce();
document.getElementById('requires_admin').checked = wf.requires_admin == 1;
document.getElementById('is_active').checked = wf.is_active == 1;
document.getElementById('modalTitle').textContent = 'Edit Transition';
document.getElementById('workflowModal').style.display = 'flex';
lt.modal.open('workflowModal');
}
function deleteTransition(id) {
if (!confirm('Delete this status transition?')) return;
fetch('/api/manage_workflows.php?id=' + id, {
method: 'DELETE',
headers: { 'X-CSRF-Token': window.CSRF_TOKEN }
})
.then(r => r.json())
showConfirmModal('Delete Transition', 'Delete this status transition?', 'error', function() {
lt.api.delete('/api/manage_workflows.php?id=' + id)
.then(data => {
if (data.success) window.location.reload();
else lt.toast.error(data.error || 'Failed to delete');
}).catch(err => lt.toast.error('Failed to delete'));
});
}
</script>