-
Open Campaign Types
-
See Highlights
+campaignsByType as $row) {
+ if ((int) $row['campaign_count'] > $maxCount) {
+ $maxCount = (int) $row['campaign_count'];
+ }
+}
+?>
+
+
+
+
+
Dashboard
+
Overview of your campaign tracking data.
-
- Request Flow
- Browser -> public/index.php -> Dispatcher -> Router -> Controller -> View
+
-
-
-
+
+
+
+
+
+ recentCampaigns)): ?>
+
+
+
+
+
+ ID
+ Type
+ Created
+
+
+
+
+ recentCampaigns as $row): ?>
+
+ #= e((string) $row['id']) ?>
+ = e($row['campaign_type_name']) ?>
+ = e(date('M j, Y', strtotime((string) $row['created_at']))) ?>
+ Edit
+
+
+
+
+
+
+
+
+
+
+ campaignsByType)): ?>
+
+
+
+
+
+
+
-
-
- Readable by design
- Small files, explicit routing, and plain PHP views keep the framework approachable for day-to-day work.
-
-
-
- Classic MVC feel
- Controllers, repositories, and view models stay separate so request handling remains predictable and easy to follow.
-
-
-
- SQL Server ready
- Typed PHP 8.3 code, Composer autoloading, PDO access, and migration support make the project feel current without becoming heavyweight.
-
diff --git a/app/Views/partials/header.php b/app/Views/partials/header.php
index 7d9bc73..50f0e49 100644
--- a/app/Views/partials/header.php
+++ b/app/Views/partials/header.php
@@ -20,6 +20,9 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time();
= e($pageTitle ?? 'Campaign Tracker') ?>
+
+
+
@@ -41,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time();
-
- ' ',
+ '/campaigns' => ' ',
+ '/campaign-types' => ' ',
+ '/jobs' => ' ',
+ '/job-types' => ' ',
+ ];
+ $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): ?>
+
+
+ = $navIcons[$item['href']] ?? '' ?>
= e($item['label']) ?>
-
+
check()): ?>
+
= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?>
diff --git a/core/Http/Session.php b/core/Http/Session.php
index 0e36732..38fb2ea 100644
--- a/core/Http/Session.php
+++ b/core/Http/Session.php
@@ -12,6 +12,8 @@ class Session
return;
}
+ session_save_path('/tmp');
+
$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
diff --git a/core/Response.php b/core/Response.php
index afd3a0c..63ba0b0 100644
--- a/core/Response.php
+++ b/core/Response.php
@@ -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) {
diff --git a/docker-publish.ps1 b/docker-publish.ps1
index 6b307c3..7331c1d 100644
--- a/docker-publish.ps1
+++ b/docker-publish.ps1
@@ -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,45 @@ 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 --wait; "
+$remoteCmd += "docker exec campaign-tracker-app php scripts/migrate.php up"
-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
diff --git a/public/css/site.css b/public/css/site.css
index 606f944..12085ab 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -1,19 +1,49 @@
+/* ── Design System Tokens ────────────────────────────────────────────── */
:root {
- --page-background: #f4efe7;
- --surface: rgba(255, 252, 247, 0.88);
- --surface-strong: #fffdf8;
- --surface-border: rgba(26, 72, 64, 0.12);
- --text-primary: #143631;
- --text-secondary: #4f655f;
- --accent: #1d7a6d;
- --accent-strong: #135c52;
- --accent-soft: #daf1ec;
- --highlight: #ef7c4d;
- --shadow-soft: 0 18px 50px rgba(20, 54, 49, 0.1);
- --shadow-card: 0 20px 40px rgba(20, 54, 49, 0.08);
-}
-
-* {
+ --primary: #1F4E79;
+ --primary-dark: #163b5c;
+ --primary-light: #dbeafe;
+ --secondary: #0F766E;
+ --accent: #2563EB;
+
+ --success: #2E7D32;
+ --success-bg: #F0FDF4;
+ --success-border: #BBF7D0;
+ --warning: #B45309;
+ --warning-bg: #FFFBEB;
+ --warning-border: #FDE68A;
+ --error: #B91C1C;
+ --error-bg: #FEF2F2;
+ --error-border: #FECACA;
+ --info: #2563EB;
+ --info-bg: #EFF6FF;
+ --info-border: #BFDBFE;
+ --overdue: #7F1D1D;
+
+ --bg: #F7F9FC;
+ --surface: #FFFFFF;
+ --surface-raised: #F8FAFC;
+ --border: #D0D7E2;
+ --border-strong: #9FB6D6;
+
+ --text: #111827;
+ --text-secondary: #4B5563;
+ --text-muted: #6B7280;
+
+ --radius-xs: 4px;
+ --radius-sm: 6px;
+ --radius: 8px;
+ --radius-lg: 10px;
+ --radius-xl: 12px;
+
+ --shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.06);
+ --shadow-sm: 0 1px 4px rgba(15, 23, 42, 0.08);
+ --shadow: 0 4px 12px rgba(15, 23, 42, 0.10);
+ --shadow-md: 0 8px 24px rgba(15, 23, 42, 0.12);
+}
+
+/* ── Reset / Base ────────────────────────────────────────────────────── */
+*, *::before, *::after {
box-sizing: border-box;
}
@@ -28,12 +58,13 @@ html {
body {
margin: 0;
min-height: 100vh;
- font-family: "Trebuchet MS", "Lucida Sans Unicode", "Lucida Grande", sans-serif;
- color: var(--text-primary);
- background:
- radial-gradient(circle at top left, rgba(239, 124, 77, 0.18), transparent 28%),
- radial-gradient(circle at top right, rgba(29, 122, 109, 0.18), transparent 32%),
- linear-gradient(180deg, #f8f2e8 0%, var(--page-background) 48%, #efe6da 100%);
+ font-family: "Public Sans", "Segoe UI", Arial, sans-serif;
+ font-size: 14px;
+ line-height: 22px;
+ font-weight: 400;
+ color: var(--text);
+ background: var(--bg);
+ overflow-x: hidden;
}
a {
@@ -41,9 +72,22 @@ a {
}
code {
- font-family: Consolas, "Courier New", monospace;
+ font-family: "IBM Plex Mono", Consolas, "Courier New", monospace;
+ font-size: 13px;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ margin: 0;
+ font-weight: 600;
+ color: var(--text);
}
+h1 { font-size: 28px; line-height: 36px; }
+h2 { font-size: 22px; line-height: 30px; }
+h3 { font-size: 18px; line-height: 26px; }
+h4 { font-size: 16px; line-height: 24px; }
+
+/* ── Layout ──────────────────────────────────────────────────────────── */
.page-shell {
min-height: 100vh;
display: flex;
@@ -51,17 +95,18 @@ code {
}
.container {
- width: min(1120px, calc(100% - 2rem));
+ width: min(1200px, calc(100% - 2rem));
margin: 0 auto;
}
+/* ── Site Header ─────────────────────────────────────────────────────── */
.site-header {
position: sticky;
top: 0;
z-index: 20;
- backdrop-filter: blur(14px);
- background: rgba(248, 242, 232, 0.78);
- border-bottom: 1px solid rgba(20, 54, 49, 0.08);
+ background: var(--primary);
+ border-bottom: 1px solid var(--primary-dark);
+ box-shadow: var(--shadow-sm);
}
.header-inner {
@@ -69,13 +114,14 @@ code {
align-items: center;
justify-content: space-between;
gap: 1rem;
- padding: 1rem 0;
+ padding: 0;
+ height: 52px;
}
.brand {
display: inline-flex;
align-items: center;
- gap: 0.85rem;
+ gap: 10px;
text-decoration: none;
}
@@ -83,44 +129,48 @@ code {
display: inline-flex;
align-items: center;
justify-content: center;
- width: 2.75rem;
- height: 2.75rem;
- border-radius: 0.95rem;
- background: linear-gradient(135deg, var(--accent), var(--highlight));
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-sm);
+ background: rgba(255, 255, 255, 0.18);
color: #fff;
- font-weight: 700;
- letter-spacing: 0.08em;
- box-shadow: var(--shadow-soft);
+ font-weight: 800;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ flex-shrink: 0;
}
.brand-copy {
display: flex;
flex-direction: column;
- line-height: 1.1;
+ line-height: 1.2;
}
.brand-copy strong {
- font-size: 1rem;
+ font-size: 14px;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: 0.01em;
}
.brand-copy small {
- color: var(--text-secondary);
- font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.6);
+ font-size: 11px;
text-transform: uppercase;
- letter-spacing: 0.14em;
+ letter-spacing: 0.1em;
}
.site-nav {
display: flex;
align-items: center;
- gap: 0.6rem;
+ gap: 2px;
flex-wrap: wrap;
}
.nav-user {
- padding: 0 0.4rem;
- color: var(--text-secondary);
- font-size: 0.88rem;
+ padding: 0 8px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 13px;
font-weight: 600;
}
@@ -129,54 +179,104 @@ code {
}
.nav-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
text-decoration: none;
- color: var(--text-secondary);
+ color: rgba(255, 255, 255, 0.88);
+ font-size: 13px;
font-weight: 600;
- padding: 0.7rem 1rem;
- border-radius: 999px;
- transition: background-color 160ms ease, color 160ms ease, transform 160ms ease;
+ padding: 6px 12px;
+ border-radius: var(--radius-sm);
+ transition: background-color 120ms ease, color 120ms ease;
+}
+
+.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:hover,
-.nav-link:focus-visible,
.nav-link.is-active {
- color: var(--accent-strong);
- background: rgba(29, 122, 109, 0.12);
- transform: translateY(-1px);
+ color: #fff;
+ 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;
- padding: 3.5rem 0 4rem;
+ padding: 32px 0 40px;
}
.content-stack {
display: grid;
- gap: 1.5rem;
+ gap: 24px;
}
+/* ── Section Heading ─────────────────────────────────────────────────── */
.section-heading {
- max-width: 46rem;
+ max-width: 720px;
}
.section-heading h1 {
- margin: 0.3rem 0 0.8rem;
- font-size: clamp(2.4rem, 5vw, 4rem);
- line-height: 1;
- letter-spacing: -0.04em;
+ margin: 4px 0 8px;
+ font-size: 32px;
+ line-height: 40px;
}
.section-heading p {
margin: 0;
color: var(--text-secondary);
- line-height: 1.8;
- font-size: 1.05rem;
+ line-height: 22px;
+ font-size: 14px;
}
+/* ── Hero (home page) ────────────────────────────────────────────────── */
.hero {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
- gap: 1.5rem;
+ gap: 16px;
align-items: stretch;
}
@@ -187,155 +287,141 @@ code {
.alert,
.empty-state {
background: var(--surface);
- border: 1px solid var(--surface-border);
- box-shadow: var(--shadow-card);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-sm);
}
.hero-copy {
- padding: 3rem;
- border-radius: 2rem;
+ padding: 32px;
+ border-radius: var(--radius-lg);
}
.eyebrow {
display: inline-block;
- margin-bottom: 1rem;
- padding: 0.4rem 0.75rem;
+ margin-bottom: 16px;
+ padding: 3px 10px;
border-radius: 999px;
- background: var(--accent-soft);
- color: var(--accent-strong);
- font-size: 0.78rem;
+ background: var(--info-bg);
+ border: 1px solid var(--info-border);
+ color: var(--accent);
+ font-size: 11px;
font-weight: 700;
text-transform: uppercase;
- letter-spacing: 0.14em;
+ letter-spacing: 0.1em;
}
.hero h1 {
- margin: 0;
- font-size: clamp(2.8rem, 6vw, 4.8rem);
- line-height: 0.98;
- letter-spacing: -0.04em;
+ font-size: clamp(24px, 4vw, 36px);
+ line-height: 1.15;
+ letter-spacing: -0.02em;
+ color: var(--primary);
}
.hero-text {
- max-width: 44rem;
- margin: 1.25rem 0 0;
- font-size: 1.12rem;
- line-height: 1.8;
+ max-width: 560px;
+ margin: 16px 0 0;
+ font-size: 14px;
+ line-height: 22px;
color: var(--text-secondary);
}
.hero-actions {
display: flex;
flex-wrap: wrap;
- gap: 0.85rem;
- margin-top: 2rem;
-}
-
-.button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.9rem 1.35rem;
- border-radius: 999px;
- text-decoration: none;
- font-weight: 700;
-}
-
-.button-primary {
- background: linear-gradient(135deg, var(--accent), var(--accent-strong));
- color: #fff;
- box-shadow: 0 18px 30px rgba(19, 92, 82, 0.25);
-}
-
-.button-secondary {
- background: rgba(29, 122, 109, 0.08);
- color: var(--accent-strong);
+ gap: 8px;
+ margin-top: 24px;
}
.hero-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
- padding: 2rem;
- border-radius: 1.8rem;
+ padding: 24px;
+ border-radius: var(--radius-lg);
}
.panel-label {
- margin: 0 0 1rem;
- font-size: 0.78rem;
+ margin: 0 0 12px;
+ font-size: 11px;
font-weight: 700;
- letter-spacing: 0.16em;
+ letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
}
.hero-panel code {
display: block;
- padding: 1rem 1.1rem;
- border-radius: 1.2rem;
- background: #173d37;
- color: #eefbf6;
- line-height: 1.7;
+ padding: 12px 14px;
+ border-radius: var(--radius);
+ background: #0f172a;
+ color: #e2e8f0;
+ font-family: "IBM Plex Mono", Consolas, monospace;
+ font-size: 12px;
+ line-height: 20px;
white-space: normal;
}
.route-callout {
- margin-top: 1.5rem;
- padding: 1rem 1.1rem;
- border-radius: 1.2rem;
- background: var(--surface-strong);
+ margin-top: 16px;
+ padding: 12px 14px;
+ border-radius: var(--radius);
+ background: var(--surface-raised);
+ border: 1px solid var(--border);
}
.route-callout span {
display: block;
- margin-bottom: 0.45rem;
+ margin-bottom: 6px;
color: var(--text-secondary);
- font-size: 0.92rem;
+ font-size: 13px;
}
.route-callout a {
- color: var(--highlight);
+ color: var(--accent);
font-weight: 700;
text-decoration: none;
}
+/* ── Feature Grid ────────────────────────────────────────────────────── */
.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 1.25rem;
- margin-top: 1.5rem;
+ gap: 16px;
+ margin-top: 16px;
}
.feature-card {
- padding: 1.75rem;
- border-radius: 1.6rem;
+ padding: 20px;
+ border-radius: var(--radius-lg);
}
.feature-card h2 {
margin-top: 0;
- margin-bottom: 0.8rem;
- font-size: 1.25rem;
+ margin-bottom: 8px;
+ font-size: 16px;
+ line-height: 24px;
}
.feature-card p {
margin: 0;
color: var(--text-secondary);
- line-height: 1.7;
+ line-height: 22px;
+ font-size: 13px;
}
+/* ── Panels & Controls ───────────────────────────────────────────────── */
.controls-panel,
.table-shell {
overflow: hidden;
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 242, 232, 0.88)),
- var(--surface);
+ background: var(--surface);
+ border: 1px solid var(--border);
}
.controls-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
- gap: 1rem;
+ gap: 16px;
}
.search-row {
@@ -348,47 +434,53 @@ code {
}
.section-panel {
- padding: 1.75rem;
- border-radius: 1.8rem;
+ padding: 24px;
+ border-radius: var(--radius-lg);
+ min-width: 0;
+ border-left: 3px solid var(--primary);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
- gap: 1rem;
+ gap: 16px;
flex-wrap: wrap;
- margin-bottom: 1.5rem;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border);
}
.panel-actions {
display: flex;
align-items: center;
- gap: 0.5rem;
+ gap: 8px;
flex-wrap: wrap;
}
.panel-header h2 {
- margin: 0 0 0.45rem;
- font-size: 1.45rem;
+ margin: 0 0 4px;
+ font-size: 18px;
+ line-height: 26px;
}
.panel-header p {
margin: 0;
color: var(--text-secondary);
- line-height: 1.7;
+ line-height: 22px;
+ font-size: 13px;
}
.job-type-table-stack {
display: grid;
- gap: 1.5rem;
+ gap: 16px;
}
.job-type-table-group {
display: grid;
- gap: 0.85rem;
- padding-top: 1.25rem;
- border-top: 1px solid rgba(20, 54, 49, 0.1);
+ gap: 8px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
}
.job-type-table-group:first-child {
@@ -400,97 +492,68 @@ code {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 1rem;
+ gap: 16px;
flex-wrap: wrap;
}
.job-type-table-heading h3 {
margin: 0;
- font-size: 1.08rem;
+ font-size: 15px;
+ line-height: 22px;
}
.job-type-table-heading span {
color: var(--text-secondary);
- font-size: 0.86rem;
+ font-size: 12px;
font-weight: 700;
}
+/* ── Forms ───────────────────────────────────────────────────────────── */
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 1rem;
-}
-
-/* Campaign jobs table — horizontal scroll inside the panel */
-#campaign-jobs-page-table {
- overflow-x: auto;
- width: 100%;
-}
-
-.import-tabs {
- display: flex;
- gap: 0.25rem;
- margin-bottom: 1.25rem;
- border-bottom: 1px solid var(--surface-border);
- padding-bottom: 0;
-}
-
-.import-tab {
- padding: 0.55rem 1.1rem;
- border: none;
- background: none;
- cursor: pointer;
- font: inherit;
- font-weight: 600;
- color: var(--text-secondary);
- border-bottom: 2px solid transparent;
- margin-bottom: -1px;
- border-radius: 0;
- transition: color 120ms, border-color 120ms;
-}
-
-.import-tab:hover { color: var(--accent); }
-.import-tab.is-active { color: var(--accent-strong); border-bottom-color: var(--accent-strong); }
-
-.import-grid {
- display: grid;
- grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 0.8fr) minmax(180px, 0.8fr);
- gap: 1rem;
-}
-
-.import-actions {
- margin-top: 1rem;
- flex-wrap: wrap;
+ gap: 16px;
}
.field {
display: grid;
- gap: 0.45rem;
+ gap: 6px;
font-weight: 600;
+ font-size: 13px;
}
.field span {
- font-size: 0.96rem;
+ font-size: 13px;
+ color: var(--text);
}
.input {
width: 100%;
- padding: 0.95rem 1rem;
- border: 1px solid rgba(20, 54, 49, 0.16);
- border-radius: 1rem;
- background: rgba(255, 255, 255, 0.92);
- color: var(--text-primary);
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--text);
font: inherit;
+ font-size: 14px;
+ line-height: 20px;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
- outline: 2px solid rgba(29, 122, 109, 0.22);
- border-color: rgba(29, 122, 109, 0.45);
+ outline: none;
+ border-color: var(--accent);
+ 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: #a43d1f;
- font-size: 0.88rem;
+ color: var(--error);
+ font-size: 12px;
font-weight: 600;
}
@@ -498,136 +561,360 @@ code {
display: flex;
justify-content: flex-start;
align-items: center;
- gap: 0.85rem;
+ 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 {
- border: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 7px 16px;
+ border-radius: var(--radius-sm);
+ text-decoration: none;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 20px;
+ border: 1px solid transparent;
cursor: pointer;
+ transition: background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
-.htmx-indicator {
- display: none;
+.button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
}
-.htmx-request .htmx-indicator,
-.htmx-request.htmx-indicator {
- display: inline-flex;
+.button-primary {
+ background: var(--primary);
+ color: #fff;
+ border-color: var(--primary-dark);
}
-.inline-indicator {
- color: var(--text-secondary);
- font-size: 0.9rem;
- font-weight: 600;
+.button-primary:hover {
+ background: var(--primary-dark);
+}
+
+.button-secondary {
+ background: var(--surface);
+ color: var(--text);
+ border-color: var(--border);
+}
+
+.button-secondary:hover {
+ background: var(--surface-raised);
+ border-color: var(--border-strong);
+}
+
+.button-danger {
+ background: var(--error);
+ color: #fff;
+ border-color: #991B1B;
+}
+
+.button-danger:hover,
+.button-danger:focus-visible {
+ background: #991B1B;
+}
+
+.button-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+ border-radius: var(--radius-xs);
+ border: 1px solid transparent;
+ cursor: pointer;
}
+/* ── Alerts & Empty States ───────────────────────────────────────────── */
.alert,
.empty-state {
- padding: 1rem 1.15rem;
- border-radius: 1.2rem;
+ padding: 12px 16px;
+ border-radius: var(--radius);
+ font-size: 14px;
}
.alert-success {
- background: rgba(218, 241, 236, 0.92);
- color: var(--accent-strong);
+ background: var(--success-bg);
+ border-color: var(--success-border);
+ color: var(--success);
}
.alert-error {
- background: rgba(239, 124, 77, 0.14);
- color: #8f3518;
+ background: var(--error-bg);
+ border-color: var(--error-border);
+ color: var(--error);
}
.empty-state p {
margin: 0;
color: var(--text-secondary);
- line-height: 1.7;
+ line-height: 22px;
}
.empty-state p + p {
- margin-top: 0.45rem;
+ margin-top: 6px;
}
+/* ── Stats Grid ──────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 0.9rem;
+ gap: 12px;
}
.stat-card {
- padding: 1rem;
- border-radius: 1.3rem;
- background: rgba(255, 255, 255, 0.72);
- border: 1px solid rgba(20, 54, 49, 0.08);
+ padding: 16px;
+ border-radius: var(--radius);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow-xs);
}
.stat-card span {
display: block;
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
+ font-weight: 600;
}
.stat-card strong {
display: block;
- margin-top: 0.45rem;
- font-size: 1.7rem;
+ 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: 1rem;
- padding: 1.15rem;
- border-radius: 1.3rem;
- background: linear-gradient(135deg, rgba(29, 122, 109, 0.12), rgba(239, 124, 77, 0.12));
+ margin-top: 16px;
+ padding: 14px 16px;
+ border-radius: var(--radius);
+ background: var(--info-bg);
+ border: 1px solid var(--info-border);
}
.summary-label {
display: block;
color: var(--text-secondary);
- font-size: 0.82rem;
+ font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
+ font-weight: 600;
}
.summary-feature h3 {
- margin: 0.55rem 0 0.3rem;
- font-size: 1.35rem;
+ margin: 6px 0 4px;
+ font-size: 16px;
+ line-height: 24px;
+ color: var(--primary);
}
.summary-feature p {
margin: 0;
color: var(--text-secondary);
+ font-size: 13px;
+}
+
+/* ── Dashboard ───────────────────────────────────────────────────────── */
+.stats-grid-4 {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.dashboard-panels {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ align-items: start;
+}
+
+.dashboard-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+
+.dashboard-table th {
+ text-align: left;
+ padding: 6px 10px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border);
+}
+
+.dashboard-table td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ color: var(--text-primary);
+}
+
+.dashboard-table tr:last-child td {
+ border-bottom: none;
+}
+
+.dashboard-table-id {
+ color: var(--text-secondary);
+ font-variant-numeric: tabular-nums;
+ width: 48px;
+}
+
+.dashboard-table-date {
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.dashboard-table-action {
+ text-align: right;
+ width: 40px;
+}
+
+.dashboard-table-action a {
+ color: var(--primary);
+ font-size: 12px;
+}
+
+.type-breakdown {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 4px 0;
+}
+
+.type-breakdown-row {
+ display: grid;
+ grid-template-columns: 140px 1fr 36px;
+ align-items: center;
+ gap: 10px;
+ font-size: 13px;
+}
+
+.type-name {
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.type-bar-wrap {
+ height: 8px;
+ background: var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.type-bar {
+ display: block;
+ height: 100%;
+ background: var(--primary);
+ border-radius: 4px;
+ min-width: 4px;
+ transition: width 0.3s ease;
+}
+
+.type-count {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+@media (max-width: 860px) {
+ .stats-grid-4 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .dashboard-panels {
+ grid-template-columns: 1fr;
+ }
}
+/* ── Table Toolbar ───────────────────────────────────────────────────── */
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 1rem;
- margin-bottom: 1rem;
+ gap: 16px;
+ margin-bottom: 12px;
flex-wrap: wrap;
- padding: 0.9rem 1rem;
- border: 1px solid rgba(20, 54, 49, 0.08);
- border-radius: 1rem;
- background: rgba(255, 255, 255, 0.58);
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
}
.table-pill {
display: inline-flex;
align-items: center;
- padding: 0.5rem 0.8rem;
+ padding: 3px 10px;
border-radius: 999px;
- background: rgba(29, 122, 109, 0.12);
- color: var(--accent-strong);
- font-size: 0.82rem;
+ background: var(--info-bg);
+ border: 1px solid var(--info-border);
+ color: var(--accent);
+ font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
.table-caption {
color: var(--text-secondary);
- font-size: 0.92rem;
+ font-size: 12px;
+}
+
+/* ── Tabulator ───────────────────────────────────────────────────────── */
+.tabulator-host {
+ overflow-x: auto;
+ width: 100%;
}
.directory-panel .tabulator-host {
@@ -635,24 +922,22 @@ code {
}
.tabulator-host .tabulator {
- border: 1px solid var(--surface-border);
- border-radius: 1.35rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
overflow: hidden;
- background: rgba(255, 255, 255, 0.82);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.5),
- 0 18px 35px rgba(20, 54, 49, 0.08);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
}
.tabulator-host .tabulator-header {
- border-bottom: 1px solid rgba(20, 54, 49, 0.08);
- background: linear-gradient(180deg, rgba(29, 122, 109, 0.14), rgba(29, 122, 109, 0.08));
+ border-bottom: 1px solid var(--border);
+ background: var(--surface-raised);
}
.tabulator-host .tabulator-header .tabulator-col {
- min-height: 3.25rem;
+ min-height: 36px;
background: transparent;
- border-right: 1px solid rgba(20, 54, 49, 0.06);
+ border-right: 1px solid var(--border);
}
.tabulator-host .tabulator-header .tabulator-col:last-child {
@@ -660,20 +945,20 @@ code {
}
.tabulator-host .tabulator-header .tabulator-col .tabulator-col-content {
- padding: 0.9rem 0.95rem 0.85rem;
+ padding: 8px 10px;
}
.tabulator-host .tabulator-header .tabulator-col .tabulator-col-title {
- font-size: 0.78rem;
- font-weight: 800;
- letter-spacing: 0.08em;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
text-transform: uppercase;
- color: var(--accent-strong);
+ color: var(--text-secondary);
}
.tabulator-host .tabulator-col,
.tabulator-host .tabulator-cell {
- border-right: 1px solid rgba(20, 54, 49, 0.06);
+ border-right: 1px solid var(--border);
}
.tabulator-host .tabulator-row .tabulator-cell:last-child {
@@ -681,157 +966,117 @@ code {
}
.tabulator-host .tabulator-row {
- background: rgba(255, 255, 255, 0.96);
- border-bottom: 1px solid rgba(20, 54, 49, 0.06);
- transition: background-color 160ms ease, transform 160ms ease;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ transition: background-color 100ms ease;
}
.tabulator-host .tabulator-row:nth-child(even) {
- background: rgba(248, 242, 232, 0.82);
+ background: var(--surface-raised);
}
.tabulator-host .tabulator-row:hover {
- background: rgba(218, 241, 236, 0.72);
+ background: var(--info-bg);
}
.tabulator-host .tabulator-row.tabulator-selected {
- background: rgba(29, 122, 109, 0.18);
+ background: var(--primary-light);
}
.tabulator-host .tabulator-cell {
- padding: 0.95rem 0.95rem;
- font-size: 0.96rem;
- line-height: 1.4;
+ padding: 8px 10px;
+ font-size: 13px;
+ line-height: 20px;
}
.tabulator-host .tabulator-row .tabulator-cell:first-child {
- font-weight: 700;
- color: var(--text-primary);
+ font-weight: 600;
+ color: var(--text);
}
.tabulator-host .tabulator-footer {
- padding: 0.55rem 0.7rem;
- background: rgba(255, 255, 255, 0.88);
- border-top: 1px solid rgba(20, 54, 49, 0.08);
+ padding: 6px 8px;
+ background: var(--surface-raised);
+ border-top: 1px solid var(--border);
}
.tabulator-host .tabulator-footer .tabulator-paginator {
font-family: inherit;
+ font-size: 13px;
}
.tabulator-host .tabulator-footer .tabulator-page {
- margin: 0 0.2rem;
- padding: 0.45rem 0.7rem;
- border: 1px solid rgba(20, 54, 49, 0.1);
- border-radius: 0.8rem;
- background: rgba(255, 255, 255, 0.9);
+ margin: 0 2px;
+ padding: 4px 8px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xs);
+ background: var(--surface);
color: var(--text-secondary);
- font-weight: 700;
+ font-weight: 600;
+ font-size: 12px;
}
.tabulator-host .tabulator-footer .tabulator-page.active,
.tabulator-host .tabulator-footer .tabulator-page:hover {
- background: linear-gradient(135deg, var(--accent), var(--accent-strong));
- border-color: transparent;
+ background: var(--primary);
+ border-color: var(--primary-dark);
color: #fff;
}
.tabulator-host .tabulator-footer .tabulator-page:disabled {
- opacity: 0.45;
+ opacity: 0.4;
}
.tabulator-host .tabulator-placeholder {
- padding: 2.5rem 1rem;
+ padding: 32px 16px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+/* ── HTMX Indicators ─────────────────────────────────────────────────── */
+.htmx-indicator {
+ display: none;
+}
+
+.htmx-request .htmx-indicator,
+.htmx-request.htmx-indicator {
+ display: inline-flex;
+}
+
+.inline-indicator {
color: var(--text-secondary);
- font-size: 1rem;
+ font-size: 13px;
font-weight: 600;
}
+/* ── Site Footer ─────────────────────────────────────────────────────── */
.site-footer {
margin-top: auto;
- border-top: 1px solid rgba(20, 54, 49, 0.08);
- background: rgba(255, 252, 247, 0.72);
+ border-top: 1px solid var(--border);
+ background: var(--surface);
}
.footer-inner {
display: flex;
justify-content: space-between;
- gap: 1rem;
- padding: 1.25rem 0 2rem;
- color: var(--text-secondary);
- font-size: 0.95rem;
+ gap: 16px;
+ padding: 16px 0 24px;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 18px;
}
.footer-inner p {
margin: 0;
}
-@media (max-width: 860px) {
- .header-inner,
- .footer-inner {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .hero,
- .feature-grid {
- grid-template-columns: 1fr;
- }
-
- .controls-header,
- .table-toolbar {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .hero-copy,
- .hero-panel {
- padding: 2rem;
- }
-
- .form-grid {
- grid-template-columns: 1fr;
- }
-
- .import-grid {
- grid-template-columns: 1fr;
- }
-
- .stats-grid {
- grid-template-columns: 1fr;
- }
-
- .page-content {
- padding-top: 2rem;
- }
-}
-
-@media (max-width: 560px) {
- .container {
- width: min(100% - 1.25rem, 1120px);
- }
-
- .site-nav {
- width: 100%;
- }
-
- .nav-link {
- width: 100%;
- text-align: center;
- }
-
- .hero h1 {
- font-size: 2.5rem;
- }
-}
-
-/* ── Campaign Types ─────────────────────────────────────────────────── */
-
+/* ── Page Toolbar ────────────────────────────────────────────────────── */
.page-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
- gap: 1.5rem;
+ gap: 16px;
flex-wrap: wrap;
}
@@ -840,82 +1085,83 @@ code {
}
.page-toolbar .section-heading h1 {
- margin: 0 0 0.4rem;
-}
-
-.button-danger {
- background: linear-gradient(135deg, #c0392b, #962d22);
- color: #fff;
- box-shadow: 0 8px 20px rgba(192, 57, 43, 0.28);
- border: none;
- cursor: pointer;
-}
-
-.button-danger:hover,
-.button-danger:focus-visible {
- background: linear-gradient(135deg, #d44637, #c0392b);
-}
-
-.button-sm {
- padding: 0.4rem 0.85rem;
- font-size: 0.82rem;
- border-radius: 999px;
- border: none;
- cursor: pointer;
+ margin: 0 0 4px;
}
+/* ── Campaign Types / Import Forms ───────────────────────────────────── */
.ct-form {
display: grid;
- gap: 2rem;
+ gap: 24px;
}
.form-section {
display: grid;
- gap: 1rem;
+ gap: 12px;
}
.form-section h3 {
margin: 0;
- font-size: 1.05rem;
+ font-size: 15px;
+ line-height: 22px;
}
.attributes-header {
display: flex;
flex-direction: column;
- gap: 0.25rem;
+ gap: 4px;
}
.attributes-hint {
margin: 0;
color: var(--text-secondary);
- font-size: 0.9rem;
+ font-size: 13px;
}
.attribute-list {
display: grid;
- gap: 0.6rem;
+ gap: 6px;
}
.attribute-row {
display: flex;
align-items: flex-end;
- gap: 0.75rem;
+ gap: 8px;
flex-wrap: wrap;
}
.attr-drag-handle {
cursor: grab;
- padding: 0 0.3rem;
- color: var(--text-secondary);
- font-size: 1.25rem;
user-select: none;
align-self: flex-end;
- padding-bottom: 0.6rem;
- line-height: 1;
+ padding-bottom: 8px;
+ 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 {
@@ -924,8 +1170,8 @@ code {
.attribute-row.is-drag-over {
outline: 2px dashed var(--accent);
- border-radius: 0.8rem;
- background: var(--accent-soft);
+ border-radius: var(--radius-sm);
+ background: var(--info-bg);
}
.attribute-order-field {
@@ -951,42 +1197,203 @@ code {
padding-bottom: 0.1rem;
}
-.field-full {
- width: 100%;
-}
-
.input-error {
- border-color: #c0392b !important;
+ border-color: var(--error) !important;
}
.required-mark {
- color: #c0392b;
+ color: var(--error);
}
.delete-zone {
- margin-top: 2.5rem;
- padding-top: 1.5rem;
- border-top: 1px solid rgba(192, 57, 43, 0.2);
+ margin-top: 32px;
+ padding-top: 16px;
+ border-top: 1px solid var(--error-border);
}
.delete-zone h4 {
- margin: 0 0 0.35rem;
- color: #c0392b;
- font-size: 0.95rem;
+ margin: 0 0 4px;
+ color: var(--error);
+ font-size: 14px;
}
.delete-zone p {
- margin: 0 0 1rem;
+ margin: 0 0 12px;
color: var(--text-secondary);
- font-size: 0.88rem;
+ font-size: 13px;
}
.attr-summary {
color: var(--text-secondary);
- font-size: 0.88rem;
+ font-size: 13px;
}
.attr-empty {
+ color: var(--text-muted);
+ opacity: 0.6;
+}
+
+/* ── Import Tabs ─────────────────────────────────────────────────────── */
+.import-tabs {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0;
+}
+
+.import-tab {
+ padding: 6px 14px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
color: var(--text-secondary);
- opacity: 0.45;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ border-radius: 0;
+ transition: color 120ms, border-color 120ms;
+}
+
+.import-tab:hover { color: var(--accent); }
+.import-tab.is-active { color: var(--primary); border-bottom-color: var(--primary); }
+
+.import-grid {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.4fr) minmax(180px, 0.8fr) minmax(180px, 0.8fr);
+ gap: 16px;
+}
+
+.import-actions {
+ margin-top: 16px;
+ flex-wrap: wrap;
+}
+
+/* Campaign jobs table — horizontal scroll inside the panel */
+#campaign-jobs-page-table {
+ overflow-x: auto;
+ 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,
+ .footer-inner {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .header-inner {
+ height: auto;
+ padding: 12px 0;
+ }
+
+ .hero,
+ .feature-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .controls-header,
+ .table-toolbar {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .hero-copy,
+ .hero-panel {
+ padding: 24px;
+ }
+
+ .form-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .import-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .page-content {
+ padding-top: 24px;
+ }
+}
+
+@media (max-width: 560px) {
+ .container {
+ width: min(100% - 1rem, 1200px);
+ }
+
+ .site-nav {
+ width: 100%;
+ }
+
+ .nav-link {
+ width: 100%;
+ text-align: center;
+ }
+
+ .nav-sep {
+ display: none;
+ }
+
+ .hero h1 {
+ font-size: 22px;
+ }
}
diff --git a/public/js/app.js b/public/js/app.js
index 5b11ae3..e9193d4 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1,5 +1,9 @@
// ── Shared util ───────────────────────────────────────────────────────────────
+const PAGE_SIZES = [10, 25, 50, 100];
+const PAGE_SIZES_SM = [5, 10, 25, 50];
+
+
function _postDelete(action) {
const form = document.createElement('form');
form.method = 'POST';
@@ -50,10 +54,23 @@ window.campaignTypeTable = function () {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No campaign types found.',
initialSort: [{ column: 'name', dir: 'asc' }],
columns: [
+ {
+ title: 'Actions',
+ field: 'id',
+ width: 160,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ const id = cell.getValue();
+ return '
Edit ' +
+ '
Delete ';
+ },
+ },
{ title: 'Name', field: 'name', minWidth: 200 },
{
title: 'Attributes',
@@ -67,18 +84,6 @@ window.campaignTypeTable = function () {
},
{ title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
{ title: 'Created', field: 'created_at', minWidth: 160 },
- {
- title: 'Actions',
- field: 'id',
- width: 160,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const id = cell.getValue();
- return '
Edit ' +
- '
Delete ';
- },
- },
],
});
},
@@ -200,6 +205,7 @@ window.campaignTable = function () {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No campaigns found.',
initialSort: [{ column: 'campaign_type_name', dir: 'asc' }],
@@ -275,6 +281,19 @@ window.campaignTable = function () {
columnsForAttributes(attributes) {
const columns = [
+ {
+ title: 'Actions',
+ field: 'id',
+ width: 230,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ const id = cell.getValue();
+ return '
Jobs ' +
+ '
Edit ' +
+ '
Delete ';
+ },
+ },
{
title: 'Campaign Type',
field: 'campaign_type_name',
@@ -297,20 +316,7 @@ window.campaignTable = function () {
});
columns.push(
- { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
- {
- title: 'Actions',
- field: 'id',
- width: 230,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const id = cell.getValue();
- return '
Jobs ' +
- '
Edit ' +
- '
Delete ';
- },
- }
+ { title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' }
);
return columns;
@@ -387,6 +393,7 @@ window.campaignTable = function () {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No jobs found for this campaign.',
initialSort: [{ column: 'job_type_name', dir: 'asc' }],
@@ -446,6 +453,16 @@ window.campaignTable = function () {
jobColumnsForAttributes(attributes) {
const columns = [
+ {
+ title: 'Actions',
+ field: 'edit_url',
+ width: 90,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ return '
Edit ';
+ },
+ },
{ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
{ title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
{ title: 'Job Type ID', field: 'job_type_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
@@ -467,17 +484,7 @@ window.campaignTable = function () {
columns.push(
{ title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
- { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' },
- {
- title: 'Actions',
- field: 'edit_url',
- width: 90,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- return '
Edit ';
- },
- }
+ { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
);
return columns;
@@ -565,6 +572,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No jobs found for this campaign.',
initialSort: [{ column: 'job_type_name', dir: 'asc' }],
@@ -644,6 +652,16 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) {
columnsForAttributes(attributes) {
const columns = [
+ {
+ title: 'Actions',
+ field: 'edit_url',
+ width: 90,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ return '
Edit ';
+ },
+ },
{ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center', headerFilter: 'input' },
{ title: 'Campaign ID', field: 'campaign_id', width: 120, hozAlign: 'center', headerFilter: 'input' },
{ title: 'Campaign Type', field: 'campaign_type_name', minWidth: 160, headerFilter: 'input' },
@@ -666,17 +684,7 @@ window.campaignJobsPageTable = function (campaignId, jobTypes) {
columns.push(
{ title: 'Created', field: 'created_at', minWidth: 160, headerFilter: 'input' },
- { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' },
- {
- title: 'Actions',
- field: 'edit_url',
- width: 90,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- return '
Edit ';
- },
- }
+ { title: 'Updated', field: 'updated_at', minWidth: 160, headerFilter: 'input' }
);
return columns;
@@ -1020,6 +1028,7 @@ window.campaignJobsTable = function (campaignId) {
pagination: true,
paginationMode: 'local',
paginationSize: 5,
+ paginationSizeSelector: PAGE_SIZES_SM,
movableColumns: true,
placeholder: 'No jobs found for this job type.',
initialSort: [{ column: 'created_at', dir: 'desc' }],
@@ -1029,7 +1038,18 @@ window.campaignJobsTable = function (campaignId) {
},
columnsForGroup(group) {
- const columns = group.attributes.map((attr, index) => ({
+ const actions = {
+ title: 'Actions',
+ field: 'edit_url',
+ width: 90,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ return '
Edit ';
+ },
+ };
+
+ const attrColumns = group.attributes.map((attr, index) => ({
title: attr.name,
field: 'attr_' + index,
minWidth: 150,
@@ -1039,25 +1059,11 @@ window.campaignJobsTable = function (campaignId) {
},
}));
- if (columns.length === 0) {
- columns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' });
+ if (attrColumns.length === 0) {
+ attrColumns.push({ title: 'Job ID', field: 'id', width: 90, hozAlign: 'center' });
}
- columns.push(
- { title: 'Created', field: 'created_at', minWidth: 160 },
- {
- title: 'Actions',
- field: 'edit_url',
- width: 90,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- return '
Edit ';
- },
- }
- );
-
- return columns;
+ return [actions, ...attrColumns, { title: 'Created', field: 'created_at', minWidth: 160 }];
},
destroyTables() {
@@ -1094,10 +1100,23 @@ window.jobTypeTable = function () {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No job types found.',
initialSort: [{ column: 'name', dir: 'asc' }],
columns: [
+ {
+ title: 'Actions',
+ field: 'id',
+ width: 160,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ const id = cell.getValue();
+ return '
Edit ' +
+ '
Delete ';
+ },
+ },
{ title: 'Name', field: 'name', minWidth: 200 },
{
title: 'Attributes',
@@ -1111,18 +1130,6 @@ window.jobTypeTable = function () {
},
{ title: 'Count', field: 'attribute_count', hozAlign: 'center', width: 80 },
{ title: 'Created', field: 'created_at', minWidth: 160 },
- {
- title: 'Actions',
- field: 'id',
- width: 160,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const id = cell.getValue();
- return '
Edit ' +
- '
Delete ';
- },
- },
],
});
},
@@ -1218,10 +1225,23 @@ window.jobTable = function () {
pagination: true,
paginationMode: 'local',
paginationSize: 10,
+ paginationSizeSelector: PAGE_SIZES,
movableColumns: true,
placeholder: 'No jobs found.',
initialSort: [{ column: 'job_type_name', dir: 'asc' }],
columns: [
+ {
+ title: 'Actions',
+ field: 'id',
+ width: 160,
+ hozAlign: 'center',
+ headerSort: false,
+ formatter: function (cell) {
+ const id = cell.getValue();
+ return '
Edit ' +
+ '
Delete ';
+ },
+ },
{ title: 'Campaign', field: 'campaign_type_name', minWidth: 160 },
{ title: 'Job Type', field: 'job_type_name', minWidth: 160 },
{
@@ -1235,18 +1255,6 @@ window.jobTable = function () {
},
},
{ title: 'Created', field: 'created_at', minWidth: 160 },
- {
- title: 'Actions',
- field: 'id',
- width: 160,
- hozAlign: 'center',
- headerSort: false,
- formatter: function (cell) {
- const id = cell.getValue();
- return '
Edit ' +
- '
Delete ';
- },
- },
],
});
},