| @@ -12,7 +12,8 @@ | |||||
| "Bash(Format-Table FullName)", | "Bash(Format-Table FullName)", | ||||
| "Bash(dir /b /s)", | "Bash(dir /b /s)", | ||||
| "Bash(findstr \"^app\")", | "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_PORT=1433 | ||||
| DB_DATABASE=Campaign_Tracker | DB_DATABASE=Campaign_Tracker | ||||
| DB_USERNAME=sa | DB_USERNAME=sa | ||||
| DB_PASSWORD=Dev_Password123! | |||||
| DB_PASSWORD= | |||||
| # ── Keycloak ─────────────────────────────────────────────────────────────────── | # ── Keycloak ─────────────────────────────────────────────────────────────────── | ||||
| # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | # KEYCLOAK_BASE_URL: Base URL of your Keycloak server. | ||||
| # Keycloak 17+ (no /auth prefix): http://localhost:8080 | # Keycloak 17+ (no /auth prefix): http://localhost:8080 | ||||
| # Keycloak < 17 (has /auth prefix): http://localhost:8080/auth | # 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_REDIRECT_URI=http://localhost:8801/auth/callback | ||||
| KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login | ||||
| @@ -997,5 +997,318 @@ Visual rhythm: | |||||
| - Dense-data readability: | - Dense-data readability: | ||||
| - Preserve minimum font sizes and row heights that remain readable during long operational sessions | - 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> | <button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button> | ||||
| </div> | </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 class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div> | ||||
| <div id="campaign-table" class="tabulator-host"></div> | <div id="campaign-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -46,7 +52,11 @@ | |||||
| </div> | </div> | ||||
| </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 class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div> | ||||
| <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | <div id="campaign-jobs-drilldown-table" class="tabulator-host"></div> | ||||
| </section> | </section> | ||||
| @@ -16,22 +16,22 @@ foreach ($model->campaignsByType as $row) { | |||||
| </div> | </div> | ||||
| <div class="stats-grid stats-grid-4"> | <div class="stats-grid stats-grid-4"> | ||||
| <div class="stat-card"> | |||||
| <a class="stat-card" href="/campaign-types"> | |||||
| <span>Campaign Types</span> | <span>Campaign Types</span> | ||||
| <strong><?= e((string) $model->totalCampaignTypes) ?></strong> | <strong><?= e((string) $model->totalCampaignTypes) ?></strong> | ||||
| </div> | |||||
| <div class="stat-card"> | |||||
| </a> | |||||
| <a class="stat-card" href="/campaigns"> | |||||
| <span>Campaigns</span> | <span>Campaigns</span> | ||||
| <strong><?= e((string) $model->totalCampaigns) ?></strong> | <strong><?= e((string) $model->totalCampaigns) ?></strong> | ||||
| </div> | |||||
| <div class="stat-card"> | |||||
| </a> | |||||
| <a class="stat-card" href="/job-types"> | |||||
| <span>Job Types</span> | <span>Job Types</span> | ||||
| <strong><?= e((string) $model->totalJobTypes) ?></strong> | <strong><?= e((string) $model->totalJobTypes) ?></strong> | ||||
| </div> | |||||
| <div class="stat-card"> | |||||
| </a> | |||||
| <a class="stat-card" href="/jobs"> | |||||
| <span>Jobs</span> | <span>Jobs</span> | ||||
| <strong><?= e((string) $model->totalJobs) ?></strong> | <strong><?= e((string) $model->totalJobs) ?></strong> | ||||
| </div> | |||||
| </a> | |||||
| </div> | </div> | ||||
| <div class="dashboard-panels"> | <div class="dashboard-panels"> | ||||
| @@ -92,13 +92,13 @@ foreach ($model->campaignsByType as $row) { | |||||
| <div class="type-breakdown"> | <div class="type-breakdown"> | ||||
| <?php foreach ($model->campaignsByType as $row): ?> | <?php foreach ($model->campaignsByType as $row): ?> | ||||
| <?php $pct = $maxCount > 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?> | <?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-name"><?= e($row['campaign_type_name']) ?></span> | ||||
| <span class="type-bar-wrap"> | <span class="type-bar-wrap"> | ||||
| <span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span> | <span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span> | ||||
| </span> | </span> | ||||
| <span class="type-count"><?= e((string) $row['campaign_count']) ?></span> | <span class="type-count"><?= e((string) $row['campaign_count']) ?></span> | ||||
| </div> | |||||
| </a> | |||||
| <?php endforeach; ?> | <?php endforeach; ?> | ||||
| </div> | </div> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| @@ -44,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time(); | |||||
| </a> | </a> | ||||
| <nav class="site-nav" aria-label="Primary navigation"> | <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'] === '/' | $isActive = $item['href'] === '/' | ||||
| ? $currentPath === '/' | ? $currentPath === '/' | ||||
| : str_starts_with($currentPath, $item['href']); | : 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']) ?>"> | <a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>"> | ||||
| <?= $navIcons[$item['href']] ?? '' ?> | |||||
| <?= e($item['label']) ?> | <?= e($item['label']) ?> | ||||
| </a> | </a> | ||||
| <?php endforeach; ?> | |||||
| <?php $prevHref = $item['href']; | |||||
| endforeach; ?> | |||||
| <?php if (auth()->check()): ?> | <?php if (auth()->check()): ?> | ||||
| <span class="nav-sep" aria-hidden="true"></span> | |||||
| <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | <span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span> | ||||
| <form method="post" action="/logout" class="nav-logout-form"> | <form method="post" action="/logout" class="nav-logout-form"> | ||||
| <?= csrf_field() ?> | <?= 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> | </form> | ||||
| <?php endif; ?> | <?php endif; ?> | ||||
| </nav> | </nav> | ||||
| @@ -12,6 +12,8 @@ class Session | |||||
| return; | return; | ||||
| } | } | ||||
| session_save_path('/tmp'); | |||||
| $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; | ||||
| session_set_cookie_params([ | session_set_cookie_params([ | ||||
| @@ -43,6 +43,12 @@ class Response | |||||
| public function send(): void | 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); | http_response_code($this->status); | ||||
| foreach ($this->headers as $name => $value) { | foreach ($this->headers as $name => $value) { | ||||
| @@ -1,14 +1,14 @@ | |||||
| #Requires -Version 5.1 | #Requires -Version 5.1 | ||||
| <# | <# | ||||
| .SYNOPSIS | .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 | .EXAMPLE | ||||
| .\docker-publish.ps1 | .\docker-publish.ps1 | ||||
| .\docker-publish.ps1 -SkipBuild # push a previously built image | |||||
| .\docker-publish.ps1 -SshKey "~/.ssh/id_rsa" | |||||
| #> | #> | ||||
| param( | param( | ||||
| [switch]$SkipBuild | |||||
| [string]$SshKey = "" | |||||
| ) | ) | ||||
| Set-StrictMode -Version Latest | Set-StrictMode -Version Latest | ||||
| @@ -17,9 +17,10 @@ $ErrorActionPreference = "Stop" | |||||
| # --------------------------------------------------------------------------- | # --------------------------------------------------------------------------- | ||||
| # Configuration | # 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 | # Helpers | ||||
| @@ -28,26 +29,44 @@ function Write-Step([string]$msg) { | |||||
| Write-Host "`n==> $msg" -ForegroundColor Cyan | 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 { | .nav-link { | ||||
| display: inline-flex; | |||||
| align-items: center; | |||||
| gap: 6px; | |||||
| text-decoration: none; | text-decoration: none; | ||||
| color: rgba(255, 255, 255, 0.75); | |||||
| color: rgba(255, 255, 255, 0.88); | |||||
| font-size: 13px; | font-size: 13px; | ||||
| font-weight: 600; | font-weight: 600; | ||||
| padding: 6px 12px; | padding: 6px 12px; | ||||
| @@ -188,10 +191,16 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| transition: background-color 120ms ease, color 120ms ease; | 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 { | .nav-link:focus-visible { | ||||
| color: #fff; | color: #fff; | ||||
| background: rgba(255, 255, 255, 0.12); | background: rgba(255, 255, 255, 0.12); | ||||
| outline: 2px solid rgba(255, 255, 255, 0.6); | |||||
| outline-offset: 2px; | |||||
| } | } | ||||
| .nav-link.is-active { | .nav-link.is-active { | ||||
| @@ -199,6 +208,41 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| background: rgba(255, 255, 255, 0.18); | 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 ────────────────────────────────────────────────────── */ | ||||
| .page-content { | .page-content { | ||||
| flex: 1; | flex: 1; | ||||
| @@ -217,8 +261,8 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| .section-heading h1 { | .section-heading h1 { | ||||
| margin: 4px 0 8px; | margin: 4px 0 8px; | ||||
| font-size: 28px; | |||||
| line-height: 36px; | |||||
| font-size: 32px; | |||||
| line-height: 40px; | |||||
| } | } | ||||
| .section-heading p { | .section-heading p { | ||||
| @@ -393,6 +437,7 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| padding: 24px; | padding: 24px; | ||||
| border-radius: var(--radius-lg); | border-radius: var(--radius-lg); | ||||
| min-width: 0; | min-width: 0; | ||||
| border-left: 3px solid var(--primary); | |||||
| } | } | ||||
| .panel-header { | .panel-header { | ||||
| @@ -401,7 +446,9 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| justify-content: space-between; | justify-content: space-between; | ||||
| gap: 16px; | gap: 16px; | ||||
| flex-wrap: wrap; | flex-wrap: wrap; | ||||
| margin-bottom: 16px; | |||||
| margin-bottom: 20px; | |||||
| padding-bottom: 16px; | |||||
| border-bottom: 1px solid var(--border); | |||||
| } | } | ||||
| .panel-actions { | .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); | 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 { | .field-error { | ||||
| color: var(--error); | color: var(--error); | ||||
| font-size: 12px; | font-size: 12px; | ||||
| @@ -512,6 +564,17 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| gap: 8px; | 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 ─────────────────────────────────────────────────────────── */ | /* ── Buttons ─────────────────────────────────────────────────────────── */ | ||||
| .button { | .button { | ||||
| display: inline-flex; | display: inline-flex; | ||||
| @@ -630,12 +693,48 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| .stat-card strong { | .stat-card strong { | ||||
| display: block; | display: block; | ||||
| margin-top: 6px; | |||||
| font-size: 28px; | |||||
| margin-top: 8px; | |||||
| font-size: 38px; | |||||
| line-height: 1; | line-height: 1; | ||||
| font-weight: 700; | |||||
| color: var(--primary); | 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 { | .summary-feature { | ||||
| margin-top: 16px; | margin-top: 16px; | ||||
| padding: 14px 16px; | padding: 14px 16px; | ||||
| @@ -1032,17 +1131,37 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| .attr-drag-handle { | .attr-drag-handle { | ||||
| cursor: grab; | cursor: grab; | ||||
| padding: 0 4px; | |||||
| color: var(--text-muted); | |||||
| font-size: 16px; | |||||
| user-select: none; | user-select: none; | ||||
| align-self: flex-end; | align-self: flex-end; | ||||
| padding-bottom: 8px; | 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 { | .attr-drag-handle:active { | ||||
| cursor: grabbing; | cursor: grabbing; | ||||
| opacity: 1; | |||||
| } | } | ||||
| .attribute-row.is-dragging { | .attribute-row.is-dragging { | ||||
| @@ -1158,6 +1277,58 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| width: 100%; | 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 ──────────────────────────────────────────────────────── */ | /* ── Responsive ──────────────────────────────────────────────────────── */ | ||||
| @media (max-width: 860px) { | @media (max-width: 860px) { | ||||
| .header-inner, | .header-inner, | ||||
| @@ -1218,6 +1389,10 @@ h4 { font-size: 16px; line-height: 24px; } | |||||
| text-align: center; | text-align: center; | ||||
| } | } | ||||
| .nav-sep { | |||||
| display: none; | |||||
| } | |||||
| .hero h1 { | .hero h1 { | ||||
| font-size: 22px; | font-size: 22px; | ||||
| } | } | ||||
Powered by TurnKey Linux.