| @@ -12,7 +12,8 @@ | |||
| "Bash(Format-Table FullName)", | |||
| "Bash(dir /b /s)", | |||
| "Bash(findstr \"^app\")", | |||
| "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)" | |||
| "PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)", | |||
| "Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -5,15 +5,15 @@ DB_HOST=sqlserver | |||
| DB_PORT=1433 | |||
| DB_DATABASE=Campaign_Tracker | |||
| DB_USERNAME=sa | |||
| DB_PASSWORD=Dev_Password123! | |||
| DB_PASSWORD= | |||
| # ── Keycloak ─────────────────────────────────────────────────────────────────── | |||
| # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | |||
| # Keycloak 17+ (no /auth prefix): http://localhost:8080 | |||
| # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth | |||
| KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/ | |||
| KEYCLOAK_REALM=KCI | |||
| KEYCLOAK_CLIENT_ID=canopy-web | |||
| KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y | |||
| KEYCLOAK_BASE_URL= | |||
| KEYCLOAK_REALM= | |||
| KEYCLOAK_CLIENT_ID= | |||
| KEYCLOAK_CLIENT_SECRET= | |||
| KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback | |||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | |||
| @@ -997,5 +997,318 @@ Visual rhythm: | |||
| - Dense-data readability: | |||
| - Preserve minimum font sizes and row heights that remain readable during long operational sessions | |||
| # UI desgin | |||
| - Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html | |||
| # UI Design | |||
| - Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html | |||
| --- | |||
| ## UI/UX Component Rules | |||
| These rules define the established UI patterns for this project. Follow them exactly when building new pages, views, or components. Do not invent new patterns — extend the existing ones. | |||
| --- | |||
| ### Navigation | |||
| **Link structure** | |||
| Every nav link must be an `<a class="nav-link">` with: | |||
| - An inline SVG icon (14×14, `fill="none"`, `stroke="currentColor"`, `stroke-width="1.75"`, Heroicons-style path). | |||
| - The label text immediately after the icon. | |||
| - `aria-hidden="true"` on the SVG. | |||
| ```html | |||
| <a class="nav-link" href="/campaigns"> | |||
| <svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"> | |||
| <path stroke-linecap="round" stroke-linejoin="round" d="..."/> | |||
| </svg> | |||
| Campaigns | |||
| </a> | |||
| ``` | |||
| **Group separators** | |||
| Nav links are divided into logical groups separated by `<span class="nav-sep" aria-hidden="true"></span>`: | |||
| - Group 1: Home | |||
| - Group 2: Campaigns, Campaign Types | |||
| - Group 3: Jobs, Job Types | |||
| - Then a separator before the logged-in user name and logout button. | |||
| Add a separator between groups and before the auth area. Never put a separator at the very start or end of the nav. | |||
| **Active state** | |||
| Add `is-active` class to the current page's link. Use exact match for `/`, prefix match (`str_starts_with`) for all others. | |||
| **Logout button** | |||
| The logout button must use `class="nav-logout-btn"` — never `button button-secondary`. It is a ghost outlined button styled for the dark header background. Do not use the standard button classes here. | |||
| **Contrast** | |||
| Nav link text uses `rgba(255, 255, 255, 0.88)`. Never go below `0.85` — the minimum for WCAG AA on the `#1F4E79` header. | |||
| **Focus rings** | |||
| All nav links and the logout button have `outline: 2px solid rgba(255, 255, 255, 0.6)` on `:focus-visible`. Do not remove this. | |||
| --- | |||
| ### Page Layout | |||
| **Every page content area** uses a `.content-stack` as its root element. This is a CSS grid with `gap: 24px` that stacks the page toolbar, panels, and other sections vertically. | |||
| ```html | |||
| <section class="content-stack"> | |||
| <div class="page-toolbar">...</div> | |||
| <section class="section-panel">...</section> | |||
| </section> | |||
| ``` | |||
| **Page toolbar** (`.page-toolbar`) always contains: | |||
| - A `.section-heading` div with an `<h1>` and optional `<p>` description on the left. | |||
| - A primary action button (or back link) on the right. | |||
| ```html | |||
| <div class="page-toolbar"> | |||
| <div class="section-heading"> | |||
| <h1>Page Title</h1> | |||
| <p>One-line description of what this page manages.</p> | |||
| </div> | |||
| <a class="button button-primary" href="/resource/create">+ New Resource</a> | |||
| </div> | |||
| ``` | |||
| **Page h1 size** is 32px / 40px line-height (set in `.section-heading h1`). Do not override this inline. | |||
| --- | |||
| ### Section Panels | |||
| Every content block on a page must be wrapped in `<section class="section-panel">`. Key rules: | |||
| - The left edge always has `border-left: 3px solid var(--primary)` — this is set globally in CSS and must not be removed or overridden. | |||
| - Every panel that has a header must use `.panel-header`, which automatically adds a `border-bottom` separator below it. | |||
| - `.panel-header` contains a left `<div>` with `<h2>` + optional `<p>`, and a right side for action buttons. | |||
| - Panel `<h2>` is 18px. Do not make it larger than the page `<h1>`. | |||
| ```html | |||
| <section class="section-panel"> | |||
| <div class="panel-header"> | |||
| <div> | |||
| <h2>Section Title</h2> | |||
| <p>Brief description of this section's content.</p> | |||
| </div> | |||
| <button class="button button-secondary" type="button">Action</button> | |||
| </div> | |||
| <!-- panel body content here --> | |||
| </section> | |||
| ``` | |||
| --- | |||
| ### Stat Cards (Dashboard Metrics) | |||
| Use `.stat-card` for any numeric summary. Rules: | |||
| - When a stat card navigates somewhere, use `<a class="stat-card" href="...">` — not a `<div>`. The CSS `a.stat-card` handles hover lift and the "View all →" affordance automatically. | |||
| - When a stat card is purely informational (no destination), use `<div class="stat-card">`. | |||
| - Numbers display at 38px / font-weight 700 in `var(--primary)`. | |||
| - Labels use the `.stat-card span` style: 11px uppercase, `var(--text-secondary)`. | |||
| - Never put more than one number in a stat card. | |||
| - Place stat cards in a `.stats-grid` container. For 4 cards use `.stats-grid.stats-grid-4`. For 3 use `.stats-grid` alone (default 3-column). | |||
| ```html | |||
| <div class="stats-grid stats-grid-4"> | |||
| <a class="stat-card" href="/campaigns"> | |||
| <span>Campaigns</span> | |||
| <strong><?= e((string) $model->totalCampaigns) ?></strong> | |||
| </a> | |||
| <!-- ... --> | |||
| </div> | |||
| ``` | |||
| --- | |||
| ### Dashboard Pages | |||
| When a page is an overview/metrics page (like the home dashboard): | |||
| 1. Start with a `.stats-grid` of stat cards covering the main record counts. | |||
| 2. Follow with a `.dashboard-panels` grid of two side-by-side `.section-panel` blocks. | |||
| 3. Left panel: a recent-records list using `.dashboard-table`. | |||
| 4. Right panel: a breakdown or summary using `.type-breakdown`. | |||
| **Dashboard table** (`.dashboard-table`) rules: | |||
| - Columns: ID (`.dashboard-table-id`), name/type, date (`.dashboard-table-date`), action link (`.dashboard-table-action`). | |||
| - No JavaScript — server-rendered, static HTML table. | |||
| - Always include a link column to the resource's edit page. | |||
| - Always include an empty state (`.empty-state`) with a helpful create link for when there is no data. | |||
| **Type breakdown** (`.type-breakdown`) rules: | |||
| - Each row is a `.type-breakdown-row` showing: label, proportional bar, count. | |||
| - When the rows link somewhere use `<a class="type-breakdown-row" href="...">`. The CSS handles hover and focus-visible. | |||
| - Calculate the bar width as `round(count / max * 100)%`. Pre-calculate `$max` before the loop. | |||
| --- | |||
| ### Forms | |||
| **Structure** | |||
| All multi-section forms use `.ct-form` (a CSS grid with `gap: 24px`) containing one or more `.form-section` blocks, ending with `.form-actions`. | |||
| ```html | |||
| <form method="post" action="..." class="ct-form" novalidate> | |||
| <?= csrf_field() ?> | |||
| <div class="form-section"> | |||
| <label class="field field-full"> | |||
| <span>Field label <span class="required-mark">*</span></span> | |||
| <input class="input" type="text" name="name" required> | |||
| </label> | |||
| </div> | |||
| <div class="form-actions"> | |||
| <button class="button button-primary" type="submit">Save</button> | |||
| <a class="button button-secondary" href="/resource">Cancel</a> | |||
| </div> | |||
| </form> | |||
| ``` | |||
| **Sticky save bar** | |||
| `.ct-form .form-actions` is `position: sticky; bottom: 0` — the save bar sticks to the viewport bottom when the form is taller than the screen. This is set globally in CSS. Do not override or move form-actions outside `.ct-form`. | |||
| **Native validation** | |||
| Use HTML5 `required`, `maxlength`, `pattern`, `min`, `max` on inputs. The CSS rule `.input:invalid:not(:placeholder-shown)` automatically shows an error border without JavaScript. Use `novalidate` on the form tag to prevent browser-native popups while still benefiting from the CSS pseudo-class. | |||
| **Required mark** | |||
| Mark required fields with `<span class="required-mark">*</span>` inside the label. Color is `var(--error)` via CSS. | |||
| **Error messages** | |||
| Server-side validation errors go in `<small class="field-error">` immediately after the input. Add `input-error` class to the input when it has a server error. | |||
| --- | |||
| ### Attribute Builder (Drag-Reorder Lists) | |||
| When a form includes a reorderable list of items (like campaign type attributes): | |||
| - Each row is `.attribute-row` with `draggable="true"` and Alpine.js drag event handlers. | |||
| - The first element of every row must be `<span class="attr-drag-handle" title="Drag to reorder">↕</span>`. | |||
| The CSS hides the unicode character with `font-size: 0` and replaces it with a `::before` radial-gradient dot grid — this is automatic. Do not change the HTML to use an SVG or other icon. | |||
| - `is-dragging` and `is-drag-over` classes are applied by Alpine and styled by CSS — do not add custom drag styles. | |||
| --- | |||
| ### Loading States | |||
| **Never use plain text** for loading indicators in content areas. Use `.skeleton-rows` with `.skeleton-row` children. | |||
| ```html | |||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| </div> | |||
| ``` | |||
| Rules: | |||
| - Use 5 skeleton rows for full-page table loaders. | |||
| - Use 3 skeleton rows for smaller/drilldown table loaders. | |||
| - Always pair with `x-cloak` and `x-show` on Alpine-controlled sections so the skeleton only shows while loading. | |||
| - The shimmer animation and fade-out opacity are set automatically by CSS via `:nth-child` selectors. | |||
| --- | |||
| ### Interactive Link Patterns | |||
| **Cards, rows, and panels that navigate** must be `<a>` elements, not `<div>` elements with `onclick`. This applies to: | |||
| - Stat cards with a destination | |||
| - Type breakdown rows | |||
| - Any list row that is entirely clickable | |||
| Use the appropriate CSS modifier class (`a.stat-card`, `a.type-breakdown-row`) — the hover and focus styles are already defined. | |||
| **Inline table action links** (Edit, Delete) in Tabulator formatter functions must use these conventions: | |||
| - Edit: `class="button button-secondary button-sm"` | |||
| - Delete: `class="button button-danger button-sm"` with a `window.confirmXxx()` guard function | |||
| - Jobs/drill-down: `class="button button-primary button-sm"` | |||
| --- | |||
| ### Accessibility | |||
| **Focus rings** | |||
| - All content-area links get `outline: 2px solid var(--accent); outline-offset: 2px` on `:focus-visible` — set globally in CSS via `.page-content a:focus-visible`. Do not suppress this. | |||
| - All buttons already have `outline: 2px solid var(--accent); outline-offset: 2px` via `.button:focus-visible`. | |||
| - Nav links and logout button have white outline rings — see the Navigation section above. | |||
| **Contrast minimums** | |||
| - Normal body text: minimum 4.5:1 against its background. | |||
| - Nav link text: minimum `rgba(255,255,255, 0.85)` against `#1F4E79`. | |||
| - Secondary text (`var(--text-secondary): #4B5563`) on white: ~7.2:1 — acceptable. | |||
| - Do not use `var(--text-muted)` (`#6B7280`) for body text — only for supplementary labels and counts. | |||
| **ARIA** | |||
| - All SVG icons in nav links must have `aria-hidden="true"`. | |||
| - All `.nav-sep` elements must have `aria-hidden="true"`. | |||
| - Empty states must be descriptive — include both what is missing and a create action link. | |||
| --- | |||
| ### Repository Patterns for Metrics | |||
| When a page needs aggregate data (counts, recent records, breakdowns), add methods to the relevant repository. Do not inline aggregate SQL in the controller. | |||
| **Count method pattern** | |||
| ```php | |||
| public function count(): int | |||
| { | |||
| $row = $this->database->first('SELECT COUNT(*) AS total FROM table_name'); | |||
| return (int) ($row['total'] ?? 0); | |||
| } | |||
| ``` | |||
| **Recent records pattern** | |||
| Inline the `TOP` limit directly — SQL Server does not accept bound parameters in `TOP` expressions. | |||
| ```php | |||
| public function recentWithType(int $limit = 5): array | |||
| { | |||
| return $this->database->query( | |||
| "SELECT TOP ({$limit}) t.id, t.created_at, ref.name AS type_name | |||
| FROM main_table t | |||
| INNER JOIN ref_table ref ON t.ref_id = ref.id | |||
| ORDER BY t.id DESC" | |||
| ); | |||
| } | |||
| ``` | |||
| **Grouped count pattern** | |||
| Use `LEFT JOIN` so that reference rows with zero records still appear. | |||
| ```php | |||
| public function countByType(): array | |||
| { | |||
| return $this->database->query( | |||
| 'SELECT ref.name AS type_name, COUNT(t.id) AS record_count | |||
| FROM ref_table ref | |||
| LEFT JOIN main_table t ON t.ref_id = ref.id | |||
| GROUP BY ref.id, ref.name | |||
| ORDER BY record_count DESC, ref.name ASC' | |||
| ); | |||
| } | |||
| ``` | |||
| --- | |||
| ### ViewModels for Dashboard Pages | |||
| Dashboard pages use a dedicated ViewModel. Metric fields use typed defaults so the view is never working with `null`. | |||
| ```php | |||
| class MyDashboardViewModel | |||
| { | |||
| public int $totalThings = 0; | |||
| public int $totalTypes = 0; | |||
| public array $recentItems = []; | |||
| public array $itemsByType = []; | |||
| } | |||
| ``` | |||
| Populate it in the controller using private `repo()` methods or inline `new Repository(database())` calls — consistent with how other controllers are written in this project. | |||
| @@ -29,7 +29,13 @@ | |||
| <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | |||
| </div> | |||
| <div class="inline-indicator" x-cloak x-show="isLoading">Loading campaigns...</div> | |||
| <div class="skeleton-rows" x-cloak x-show="isLoading"> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| </div> | |||
| <div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | |||
| <div id="campaign-table" class="tabulator-host"></div> | |||
| </section> | |||
| @@ -46,7 +52,11 @@ | |||
| </div> | |||
| </div> | |||
| <div class="inline-indicator" x-show="isJobsLoading">Loading jobs...</div> | |||
| <div class="skeleton-rows" x-show="isJobsLoading"> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| <div class="skeleton-row"></div> | |||
| </div> | |||
| <div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div> | |||
| <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | |||
| </section> | |||
| @@ -16,22 +16,22 @@ foreach ($model->campaignsByType as $row) { | |||
| </div> | |||
| <div class="stats-grid stats-grid-4"> | |||
| <div class="stat-card"> | |||
| <a class="stat-card" href="/campaign-types"> | |||
| <span>Campaign Types</span> | |||
| <strong><?= e((string) $model->totalCampaignTypes) ?></strong> | |||
| </div> | |||
| <div class="stat-card"> | |||
| </a> | |||
| <a class="stat-card" href="/campaigns"> | |||
| <span>Campaigns</span> | |||
| <strong><?= e((string) $model->totalCampaigns) ?></strong> | |||
| </div> | |||
| <div class="stat-card"> | |||
| </a> | |||
| <a class="stat-card" href="/job-types"> | |||
| <span>Job Types</span> | |||
| <strong><?= e((string) $model->totalJobTypes) ?></strong> | |||
| </div> | |||
| <div class="stat-card"> | |||
| </a> | |||
| <a class="stat-card" href="/jobs"> | |||
| <span>Jobs</span> | |||
| <strong><?= e((string) $model->totalJobs) ?></strong> | |||
| </div> | |||
| </a> | |||
| </div> | |||
| <div class="dashboard-panels"> | |||
| @@ -92,13 +92,13 @@ foreach ($model->campaignsByType as $row) { | |||
| <div class="type-breakdown"> | |||
| <?php foreach ($model->campaignsByType as $row): ?> | |||
| <?php $pct = $maxCount > 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?> | |||
| <div class="type-breakdown-row"> | |||
| <a class="type-breakdown-row" href="/campaigns"> | |||
| <span class="type-name"><?= e($row['campaign_type_name']) ?></span> | |||
| <span class="type-bar-wrap"> | |||
| <span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span> | |||
| </span> | |||
| <span class="type-count"><?= e((string) $row['campaign_count']) ?></span> | |||
| </div> | |||
| </a> | |||
| <?php endforeach; ?> | |||
| </div> | |||
| <?php endif; ?> | |||
| @@ -44,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||
| </a> | |||
| <nav class="site-nav" aria-label="Primary navigation"> | |||
| <?php foreach ($navigationItems as $item): ?> | |||
| <?php | |||
| <?php | |||
| $navIcons = [ | |||
| '/' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>', | |||
| '/campaigns' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6m-6 4h4"/></svg>', | |||
| '/campaign-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M3 3h7.5L21 12l-9 9-10.5-10.5V3z"/></svg>', | |||
| '/jobs' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>', | |||
| '/job-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>', | |||
| ]; | |||
| $prevHref = null; | |||
| $group1 = ['/']; | |||
| $group2 = ['/campaigns', '/campaign-types']; | |||
| $group3 = ['/jobs', '/job-types']; | |||
| foreach ($navigationItems as $item): | |||
| $isActive = $item['href'] === '/' | |||
| ? $currentPath === '/' | |||
| : str_starts_with($currentPath, $item['href']); | |||
| ?> | |||
| $needsSep = ($prevHref !== null) && ( | |||
| (in_array($prevHref, $group1) && in_array($item['href'], $group2)) || | |||
| (in_array($prevHref, $group2) && in_array($item['href'], $group3)) | |||
| ); | |||
| if ($needsSep): ?> | |||
| <span class="nav-sep" aria-hidden="true"></span> | |||
| <?php endif; ?> | |||
| <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | |||
| <?= $navIcons[$item['href']] ?? '' ?> | |||
| <?= e($item['label']) ?> | |||
| </a> | |||
| <?php endforeach; ?> | |||
| <?php $prevHref = $item['href']; | |||
| endforeach; ?> | |||
| <?php if (auth()->check()): ?> | |||
| <span class="nav-sep" aria-hidden="true"></span> | |||
| <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | |||
| <form method="post" action="/logout" class="nav-logout-form"> | |||
| <?= csrf_field() ?> | |||
| <button type="submit" class="button button-secondary button-sm">Log out</button> | |||
| <button type="submit" class="nav-logout-btn">Log out</button> | |||
| </form> | |||
| <?php endif; ?> | |||
| </nav> | |||
| @@ -12,6 +12,8 @@ class Session | |||
| return; | |||
| } | |||
| session_save_path('/tmp'); | |||
| $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | |||
| session_set_cookie_params([ | |||
| @@ -43,6 +43,12 @@ class Response | |||
| public function send(): void | |||
| { | |||
| // Flush session to disk before sending headers so it is persisted before | |||
| // the browser follows any redirect (critical for OAuth state survival). | |||
| if (session_status() === PHP_SESSION_ACTIVE) { | |||
| session_write_close(); | |||
| } | |||
| http_response_code($this->status); | |||
| foreach ($this->headers as $name => $value) { | |||
| @@ -1,14 +1,14 @@ | |||
| #Requires -Version 5.1 | |||
| <# | |||
| .SYNOPSIS | |||
| Builds the Campaign Tracker Docker image and pushes it to the private registry. | |||
| Copies .env to the server, then SSHes in to pull the repo and start the container. | |||
| .EXAMPLE | |||
| .\docker-publish.ps1 | |||
| .\docker-publish.ps1 -SkipBuild # push a previously built image | |||
| .\docker-publish.ps1 -SshKey "~/.ssh/id_rsa" | |||
| #> | |||
| param( | |||
| [switch]$SkipBuild | |||
| [string]$SshKey = "" | |||
| ) | |||
| Set-StrictMode -Version Latest | |||
| @@ -17,9 +17,10 @@ $ErrorActionPreference = "Stop" | |||
| # --------------------------------------------------------------------------- | |||
| # Configuration | |||
| # --------------------------------------------------------------------------- | |||
| $REGISTRY_HOST = "192.168.1.200" # change port if needed, e.g. "192.168.1.200:5000" | |||
| $IMAGE_NAME = "campaign-tracker" | |||
| $FULL_TAG = "$REGISTRY_HOST/$IMAGE_NAME`:latest" | |||
| $SSH_HOST = "192.168.1.200" | |||
| $SSH_USER = "root" | |||
| $REPO_PATH = "/root/campaign-tracker" | |||
| $REPO_URL = "https://onefortheroadgit.sytes.net/dcovington/KCI-CAMPAIGN-TRACKER.git" | |||
| # --------------------------------------------------------------------------- | |||
| # Helpers | |||
| @@ -28,26 +29,44 @@ function Write-Step([string]$msg) { | |||
| Write-Host "`n==> $msg" -ForegroundColor Cyan | |||
| } | |||
| function Assert-DockerRunning { | |||
| docker info > $null 2>&1 | |||
| if ($LASTEXITCODE -ne 0) { | |||
| Write-Error "Docker is not running or not reachable. Start Docker Desktop and retry." | |||
| } | |||
| function Get-BaseArgs { | |||
| $a = @("-o", "StrictHostKeyChecking=accept-new") | |||
| if ($SshKey -ne "") { $a += @("-i", $SshKey) } | |||
| return $a | |||
| } | |||
| # --------------------------------------------------------------------------- | |||
| # Main | |||
| # Step 1 — copy .env (first password prompt) | |||
| # --------------------------------------------------------------------------- | |||
| Assert-DockerRunning | |||
| Write-Step "Copying .env to $SSH_USER@$SSH_HOST" | |||
| $scpArgs = Get-BaseArgs | |||
| $scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env" | |||
| scp @scpArgs | |||
| if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." } | |||
| if (-not $SkipBuild) { | |||
| Write-Step "Building image: $FULL_TAG" | |||
| docker build --tag $FULL_TAG . | |||
| if ($LASTEXITCODE -ne 0) { Write-Error "docker build failed (exit $LASTEXITCODE)." } | |||
| } | |||
| # --------------------------------------------------------------------------- | |||
| # Step 2 — deploy (second password prompt) | |||
| # --------------------------------------------------------------------------- | |||
| Write-Step "Deploying on $SSH_USER@$SSH_HOST" | |||
| # Deploy whichever branch is currently checked out locally. | |||
| $BRANCH = (git rev-parse --abbrev-ref HEAD).Trim() | |||
| Write-Host "Branch: $BRANCH" -ForegroundColor Yellow | |||
| # Build as a single semicolon-separated string — no newlines, no CRLF risk. | |||
| $remoteCmd = "set -e; " | |||
| $remoteCmd += "if [ -d '$REPO_PATH/.git' ]; then " | |||
| $remoteCmd += "cd '$REPO_PATH' && git fetch origin && git checkout $BRANCH && git pull origin $BRANCH; " | |||
| $remoteCmd += "else " | |||
| $remoteCmd += "mkdir -p '$REPO_PATH' && git clone --branch $BRANCH '$REPO_URL' '$REPO_PATH'; " | |||
| $remoteCmd += "fi; " | |||
| $remoteCmd += "docker ps -aq | xargs -r docker rm -f; " | |||
| $remoteCmd += "docker run --rm -v '$REPO_PATH':/app -w /app composer:latest install --no-dev --optimize-autoloader --no-interaction; " | |||
| $remoteCmd += "cd '$REPO_PATH' && docker compose up -d" | |||
| Write-Step "Pushing image: $FULL_TAG" | |||
| docker push $FULL_TAG | |||
| if ($LASTEXITCODE -ne 0) { Write-Error "docker push failed (exit $LASTEXITCODE)." } | |||
| $sshArgs = Get-BaseArgs | |||
| $sshArgs += "$SSH_USER@$SSH_HOST", $remoteCmd | |||
| ssh @sshArgs | |||
| if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed (exit $LASTEXITCODE)." } | |||
| Write-Host "`nDone. Image available at $FULL_TAG" -ForegroundColor Green | |||
| Write-Host "`nDone. Campaign Tracker is running on $SSH_HOST." -ForegroundColor Green | |||
| @@ -179,8 +179,11 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| } | |||
| .nav-link { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: 6px; | |||
| text-decoration: none; | |||
| color: rgba(255, 255, 255, 0.75); | |||
| color: rgba(255, 255, 255, 0.88); | |||
| font-size: 13px; | |||
| font-weight: 600; | |||
| padding: 6px 12px; | |||
| @@ -188,10 +191,16 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| transition: background-color 120ms ease, color 120ms ease; | |||
| } | |||
| .nav-link:hover, | |||
| .nav-link:hover { | |||
| color: #fff; | |||
| background: rgba(255, 255, 255, 0.12); | |||
| } | |||
| .nav-link:focus-visible { | |||
| color: #fff; | |||
| background: rgba(255, 255, 255, 0.12); | |||
| outline: 2px solid rgba(255, 255, 255, 0.6); | |||
| outline-offset: 2px; | |||
| } | |||
| .nav-link.is-active { | |||
| @@ -199,6 +208,41 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| background: rgba(255, 255, 255, 0.18); | |||
| } | |||
| .nav-sep { | |||
| width: 1px; | |||
| height: 18px; | |||
| background: rgba(255, 255, 255, 0.2); | |||
| margin: 0 4px; | |||
| flex-shrink: 0; | |||
| } | |||
| .nav-logout-btn { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| padding: 4px 10px; | |||
| border-radius: var(--radius-xs); | |||
| font-family: inherit; | |||
| font-size: 12px; | |||
| font-weight: 600; | |||
| line-height: 20px; | |||
| cursor: pointer; | |||
| background: transparent; | |||
| color: rgba(255, 255, 255, 0.75); | |||
| border: 1px solid rgba(255, 255, 255, 0.22); | |||
| transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; | |||
| } | |||
| .nav-logout-btn:hover { | |||
| background: rgba(255, 255, 255, 0.1); | |||
| border-color: rgba(255, 255, 255, 0.4); | |||
| color: #fff; | |||
| } | |||
| .nav-logout-btn:focus-visible { | |||
| outline: 2px solid rgba(255, 255, 255, 0.6); | |||
| outline-offset: 2px; | |||
| } | |||
| /* ── Page Content ────────────────────────────────────────────────────── */ | |||
| .page-content { | |||
| flex: 1; | |||
| @@ -217,8 +261,8 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| .section-heading h1 { | |||
| margin: 4px 0 8px; | |||
| font-size: 28px; | |||
| line-height: 36px; | |||
| font-size: 32px; | |||
| line-height: 40px; | |||
| } | |||
| .section-heading p { | |||
| @@ -393,6 +437,7 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| padding: 24px; | |||
| border-radius: var(--radius-lg); | |||
| min-width: 0; | |||
| border-left: 3px solid var(--primary); | |||
| } | |||
| .panel-header { | |||
| @@ -401,7 +446,9 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| justify-content: space-between; | |||
| gap: 16px; | |||
| flex-wrap: wrap; | |||
| margin-bottom: 16px; | |||
| margin-bottom: 20px; | |||
| padding-bottom: 16px; | |||
| border-bottom: 1px solid var(--border); | |||
| } | |||
| .panel-actions { | |||
| @@ -499,6 +546,11 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); | |||
| } | |||
| .input:invalid:not(:placeholder-shown) { | |||
| border-color: var(--error); | |||
| box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.08); | |||
| } | |||
| .field-error { | |||
| color: var(--error); | |||
| font-size: 12px; | |||
| @@ -512,6 +564,17 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| gap: 8px; | |||
| } | |||
| .ct-form .form-actions { | |||
| position: sticky; | |||
| bottom: 0; | |||
| z-index: 2; | |||
| background: var(--surface); | |||
| margin: 8px -24px -24px; | |||
| padding: 12px 24px; | |||
| border-top: 1px solid var(--border); | |||
| border-radius: 0 0 var(--radius-lg) var(--radius-lg); | |||
| } | |||
| /* ── Buttons ─────────────────────────────────────────────────────────── */ | |||
| .button { | |||
| display: inline-flex; | |||
| @@ -630,12 +693,48 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| .stat-card strong { | |||
| display: block; | |||
| margin-top: 6px; | |||
| font-size: 28px; | |||
| margin-top: 8px; | |||
| font-size: 38px; | |||
| line-height: 1; | |||
| font-weight: 700; | |||
| color: var(--primary); | |||
| } | |||
| a.stat-card { | |||
| text-decoration: none; | |||
| cursor: pointer; | |||
| transition: transform 100ms ease, box-shadow 100ms ease, border-color 100ms ease; | |||
| } | |||
| a.stat-card:hover { | |||
| transform: translateY(-2px); | |||
| box-shadow: var(--shadow); | |||
| border-color: var(--border-strong); | |||
| } | |||
| a.stat-card:focus-visible { | |||
| outline: 2px solid var(--accent); | |||
| outline-offset: 2px; | |||
| } | |||
| a.stat-card::after { | |||
| content: 'View all →'; | |||
| display: block; | |||
| margin-top: 10px; | |||
| font-size: 11px; | |||
| font-weight: 600; | |||
| letter-spacing: 0.04em; | |||
| color: var(--text-muted); | |||
| opacity: 0; | |||
| transform: translateX(-4px); | |||
| transition: opacity 150ms ease, transform 150ms ease; | |||
| } | |||
| a.stat-card:hover::after { | |||
| opacity: 1; | |||
| transform: translateX(0); | |||
| } | |||
| .summary-feature { | |||
| margin-top: 16px; | |||
| padding: 14px 16px; | |||
| @@ -1032,17 +1131,37 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| .attr-drag-handle { | |||
| cursor: grab; | |||
| padding: 0 4px; | |||
| color: var(--text-muted); | |||
| font-size: 16px; | |||
| user-select: none; | |||
| align-self: flex-end; | |||
| padding-bottom: 8px; | |||
| line-height: 1; | |||
| font-size: 0; | |||
| display: inline-flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| width: 18px; | |||
| height: 34px; | |||
| flex-shrink: 0; | |||
| opacity: 0.5; | |||
| transition: opacity 100ms ease; | |||
| } | |||
| .attr-drag-handle::before { | |||
| content: ''; | |||
| display: block; | |||
| width: 6px; | |||
| height: 16px; | |||
| background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px); | |||
| background-size: 3px 4px; | |||
| background-repeat: repeat; | |||
| } | |||
| .attr-drag-handle:hover { | |||
| opacity: 1; | |||
| } | |||
| .attr-drag-handle:active { | |||
| cursor: grabbing; | |||
| opacity: 1; | |||
| } | |||
| .attribute-row.is-dragging { | |||
| @@ -1158,6 +1277,58 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| width: 100%; | |||
| } | |||
| /* ── Global Focus Visible ────────────────────────────────────────────── */ | |||
| .page-content a:focus-visible { | |||
| outline: 2px solid var(--accent); | |||
| outline-offset: 2px; | |||
| border-radius: 2px; | |||
| } | |||
| /* ── Skeleton Loading ────────────────────────────────────────────────── */ | |||
| @keyframes skeleton-shimmer { | |||
| 0% { background-position: 200% 0; } | |||
| 100% { background-position: -200% 0; } | |||
| } | |||
| .skeleton-rows { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: 8px; | |||
| padding: 4px 0; | |||
| } | |||
| .skeleton-row { | |||
| height: 38px; | |||
| border-radius: var(--radius-sm); | |||
| background: linear-gradient(90deg, var(--surface-raised) 25%, var(--border) 50%, var(--surface-raised) 75%); | |||
| background-size: 400% 100%; | |||
| animation: skeleton-shimmer 1.5s ease-in-out infinite; | |||
| } | |||
| .skeleton-row:nth-child(2) { animation-delay: 0.1s; opacity: 0.85; } | |||
| .skeleton-row:nth-child(3) { animation-delay: 0.2s; opacity: 0.7; } | |||
| .skeleton-row:nth-child(4) { animation-delay: 0.3s; opacity: 0.55; } | |||
| .skeleton-row:nth-child(5) { animation-delay: 0.4s; opacity: 0.4; } | |||
| /* ── Type Breakdown Links ────────────────────────────────────────────── */ | |||
| a.type-breakdown-row { | |||
| text-decoration: none; | |||
| color: inherit; | |||
| padding: 4px 6px; | |||
| margin: 0 -6px; | |||
| border-radius: var(--radius-sm); | |||
| transition: background-color 100ms ease; | |||
| } | |||
| a.type-breakdown-row:hover { | |||
| background: var(--surface-raised); | |||
| } | |||
| a.type-breakdown-row:focus-visible { | |||
| outline: 2px solid var(--accent); | |||
| outline-offset: 2px; | |||
| } | |||
| /* ── Responsive ──────────────────────────────────────────────────────── */ | |||
| @media (max-width: 860px) { | |||
| .header-inner, | |||
| @@ -1218,6 +1389,10 @@ h4 { font-size: 16px; line-height: 24px; } | |||
| text-align: center; | |||
| } | |||
| .nav-sep { | |||
| display: none; | |||
| } | |||
| .hero h1 { | |||
| font-size: 22px; | |||
| } | |||
Powered by TurnKey Linux.