Преглед изворни кода

gui and deploy fixes

pull/1/head
Daniel Covington пре 1 дан
родитељ
комит
e84151ff01
10 измењених фајлова са 604 додато и 58 уклоњено
  1. +2
    -1
      .claude/settings.local.json
  2. +5
    -5
      .env.example
  3. +315
    -2
      AGENTS.md
  4. +12
    -2
      app/Views/campaigns/index.php
  5. +10
    -10
      app/Views/home/index.php
  6. +25
    -5
      app/Views/partials/header.php
  7. +2
    -0
      core/Http/Session.php
  8. +6
    -0
      core/Response.php
  9. +41
    -22
      docker-publish.ps1
  10. +186
    -11
      public/css/site.css

+ 2
- 1
.claude/settings.local.json Прегледај датотеку

@@ -12,7 +12,8 @@
"Bash(Format-Table FullName)",
"Bash(dir /b /s)",
"Bash(findstr \"^app\")",
"PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)"
"PowerShell(cd \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\"; Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch '\\\\.git|\\\\.claude|node_modules' } | Sort-Object | Select-Object -First 30)",
"Bash(git -C \"d:\\\\Development\\\\PHP\\\\Campaign-Tracker\" remote get-url origin)"
]
}
}

+ 5
- 5
.env.example Прегледај датотеку

@@ -5,15 +5,15 @@ DB_HOST=sqlserver
DB_PORT=1433
DB_DATABASE=Campaign_Tracker
DB_USERNAME=sa
DB_PASSWORD=Dev_Password123!
DB_PASSWORD=

# ── Keycloak ───────────────────────────────────────────────────────────────────
# KEYCLOAK_BASE_URL: Base URL of your Keycloak server.
# Keycloak 17+ (no /auth prefix): http://localhost:8080
# Keycloak < 17 (has /auth prefix): http://localhost:8080/auth
KEYCLOAK_BASE_URL=http://kci-app01.ntp.kentcommunications.com:8180/
KEYCLOAK_REALM=KCI
KEYCLOAK_CLIENT_ID=canopy-web
KEYCLOAK_CLIENT_SECRET=LHWXp5UUuES00Dz2iCjTJJgX9su6co0y
KEYCLOAK_BASE_URL=
KEYCLOAK_REALM=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
KEYCLOAK_REDIRECT_URI=http://localhost:8801/auth/callback
KEYCLOAK_LOGOUT_REDIRECT_URI=http://localhost:8801/login

+ 315
- 2
AGENTS.md Прегледај датотеку

@@ -997,5 +997,318 @@ Visual rhythm:
- Dense-data readability:
- Preserve minimum font sizes and row heights that remain readable during long operational sessions

# UI desgin
- Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html
# UI Design
- Refer to template info in the files docs\ux-color-themes.html and docs\ux-design-directions.html

---

## UI/UX Component Rules

These rules define the established UI patterns for this project. Follow them exactly when building new pages, views, or components. Do not invent new patterns — extend the existing ones.

---

### Navigation

**Link structure**
Every nav link must be an `<a class="nav-link">` with:
- An inline SVG icon (14×14, `fill="none"`, `stroke="currentColor"`, `stroke-width="1.75"`, Heroicons-style path).
- The label text immediately after the icon.
- `aria-hidden="true"` on the SVG.

```html
<a class="nav-link" href="/campaigns">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="..."/>
</svg>
Campaigns
</a>
```

**Group separators**
Nav links are divided into logical groups separated by `<span class="nav-sep" aria-hidden="true"></span>`:
- Group 1: Home
- Group 2: Campaigns, Campaign Types
- Group 3: Jobs, Job Types
- Then a separator before the logged-in user name and logout button.

Add a separator between groups and before the auth area. Never put a separator at the very start or end of the nav.

**Active state**
Add `is-active` class to the current page's link. Use exact match for `/`, prefix match (`str_starts_with`) for all others.

**Logout button**
The logout button must use `class="nav-logout-btn"` — never `button button-secondary`. It is a ghost outlined button styled for the dark header background. Do not use the standard button classes here.

**Contrast**
Nav link text uses `rgba(255, 255, 255, 0.88)`. Never go below `0.85` — the minimum for WCAG AA on the `#1F4E79` header.

**Focus rings**
All nav links and the logout button have `outline: 2px solid rgba(255, 255, 255, 0.6)` on `:focus-visible`. Do not remove this.

---

### Page Layout

**Every page content area** uses a `.content-stack` as its root element. This is a CSS grid with `gap: 24px` that stacks the page toolbar, panels, and other sections vertically.

```html
<section class="content-stack">
<div class="page-toolbar">...</div>
<section class="section-panel">...</section>
</section>
```

**Page toolbar** (`.page-toolbar`) always contains:
- A `.section-heading` div with an `<h1>` and optional `<p>` description on the left.
- A primary action button (or back link) on the right.

```html
<div class="page-toolbar">
<div class="section-heading">
<h1>Page Title</h1>
<p>One-line description of what this page manages.</p>
</div>
<a class="button button-primary" href="/resource/create">+ New Resource</a>
</div>
```

**Page h1 size** is 32px / 40px line-height (set in `.section-heading h1`). Do not override this inline.

---

### Section Panels

Every content block on a page must be wrapped in `<section class="section-panel">`. Key rules:

- The left edge always has `border-left: 3px solid var(--primary)` — this is set globally in CSS and must not be removed or overridden.
- Every panel that has a header must use `.panel-header`, which automatically adds a `border-bottom` separator below it.
- `.panel-header` contains a left `<div>` with `<h2>` + optional `<p>`, and a right side for action buttons.
- Panel `<h2>` is 18px. Do not make it larger than the page `<h1>`.

```html
<section class="section-panel">
<div class="panel-header">
<div>
<h2>Section Title</h2>
<p>Brief description of this section's content.</p>
</div>
<button class="button button-secondary" type="button">Action</button>
</div>

<!-- panel body content here -->
</section>
```

---

### Stat Cards (Dashboard Metrics)

Use `.stat-card` for any numeric summary. Rules:

- When a stat card navigates somewhere, use `<a class="stat-card" href="...">` — not a `<div>`. The CSS `a.stat-card` handles hover lift and the "View all →" affordance automatically.
- When a stat card is purely informational (no destination), use `<div class="stat-card">`.
- Numbers display at 38px / font-weight 700 in `var(--primary)`.
- Labels use the `.stat-card span` style: 11px uppercase, `var(--text-secondary)`.
- Never put more than one number in a stat card.
- Place stat cards in a `.stats-grid` container. For 4 cards use `.stats-grid.stats-grid-4`. For 3 use `.stats-grid` alone (default 3-column).

```html
<div class="stats-grid stats-grid-4">
<a class="stat-card" href="/campaigns">
<span>Campaigns</span>
<strong><?= e((string) $model->totalCampaigns) ?></strong>
</a>
<!-- ... -->
</div>
```

---

### Dashboard Pages

When a page is an overview/metrics page (like the home dashboard):

1. Start with a `.stats-grid` of stat cards covering the main record counts.
2. Follow with a `.dashboard-panels` grid of two side-by-side `.section-panel` blocks.
3. Left panel: a recent-records list using `.dashboard-table`.
4. Right panel: a breakdown or summary using `.type-breakdown`.

**Dashboard table** (`.dashboard-table`) rules:
- Columns: ID (`.dashboard-table-id`), name/type, date (`.dashboard-table-date`), action link (`.dashboard-table-action`).
- No JavaScript — server-rendered, static HTML table.
- Always include a link column to the resource's edit page.
- Always include an empty state (`.empty-state`) with a helpful create link for when there is no data.

**Type breakdown** (`.type-breakdown`) rules:
- Each row is a `.type-breakdown-row` showing: label, proportional bar, count.
- When the rows link somewhere use `<a class="type-breakdown-row" href="...">`. The CSS handles hover and focus-visible.
- Calculate the bar width as `round(count / max * 100)%`. Pre-calculate `$max` before the loop.

---

### Forms

**Structure**
All multi-section forms use `.ct-form` (a CSS grid with `gap: 24px`) containing one or more `.form-section` blocks, ending with `.form-actions`.

```html
<form method="post" action="..." class="ct-form" novalidate>
<?= csrf_field() ?>

<div class="form-section">
<label class="field field-full">
<span>Field label <span class="required-mark">*</span></span>
<input class="input" type="text" name="name" required>
</label>
</div>

<div class="form-actions">
<button class="button button-primary" type="submit">Save</button>
<a class="button button-secondary" href="/resource">Cancel</a>
</div>
</form>
```

**Sticky save bar**
`.ct-form .form-actions` is `position: sticky; bottom: 0` — the save bar sticks to the viewport bottom when the form is taller than the screen. This is set globally in CSS. Do not override or move form-actions outside `.ct-form`.

**Native validation**
Use HTML5 `required`, `maxlength`, `pattern`, `min`, `max` on inputs. The CSS rule `.input:invalid:not(:placeholder-shown)` automatically shows an error border without JavaScript. Use `novalidate` on the form tag to prevent browser-native popups while still benefiting from the CSS pseudo-class.

**Required mark**
Mark required fields with `<span class="required-mark">*</span>` inside the label. Color is `var(--error)` via CSS.

**Error messages**
Server-side validation errors go in `<small class="field-error">` immediately after the input. Add `input-error` class to the input when it has a server error.

---

### Attribute Builder (Drag-Reorder Lists)

When a form includes a reorderable list of items (like campaign type attributes):

- Each row is `.attribute-row` with `draggable="true"` and Alpine.js drag event handlers.
- The first element of every row must be `<span class="attr-drag-handle" title="Drag to reorder">&#8597;</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.

+ 12
- 2
app/Views/campaigns/index.php Прегледај датотеку

@@ -29,7 +29,13 @@
<button class="button button-secondary" type="button" x-on:click="reloadTable()">Refresh</button>
</div>

<div class="inline-indicator" x-cloak x-show="isLoading">Loading campaigns...</div>
<div class="skeleton-rows" x-cloak x-show="isLoading">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
<div class="alert alert-error" x-cloak x-show="errorMessage" x-text="errorMessage"></div>
<div id="campaign-table" class="tabulator-host"></div>
</section>
@@ -46,7 +52,11 @@
</div>
</div>

<div class="inline-indicator" x-show="isJobsLoading">Loading jobs...</div>
<div class="skeleton-rows" x-show="isJobsLoading">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
<div class="alert alert-error" x-show="jobsErrorMessage" x-text="jobsErrorMessage"></div>
<div id="campaign-jobs-drilldown-table" class="tabulator-host"></div>
</section>


+ 10
- 10
app/Views/home/index.php Прегледај датотеку

@@ -16,22 +16,22 @@ foreach ($model->campaignsByType as $row) {
</div>

<div class="stats-grid stats-grid-4">
<div class="stat-card">
<a class="stat-card" href="/campaign-types">
<span>Campaign Types</span>
<strong><?= e((string) $model->totalCampaignTypes) ?></strong>
</div>
<div class="stat-card">
</a>
<a class="stat-card" href="/campaigns">
<span>Campaigns</span>
<strong><?= e((string) $model->totalCampaigns) ?></strong>
</div>
<div class="stat-card">
</a>
<a class="stat-card" href="/job-types">
<span>Job Types</span>
<strong><?= e((string) $model->totalJobTypes) ?></strong>
</div>
<div class="stat-card">
</a>
<a class="stat-card" href="/jobs">
<span>Jobs</span>
<strong><?= e((string) $model->totalJobs) ?></strong>
</div>
</a>
</div>

<div class="dashboard-panels">
@@ -92,13 +92,13 @@ foreach ($model->campaignsByType as $row) {
<div class="type-breakdown">
<?php foreach ($model->campaignsByType as $row): ?>
<?php $pct = $maxCount > 0 ? round((int) $row['campaign_count'] / $maxCount * 100) : 0; ?>
<div class="type-breakdown-row">
<a class="type-breakdown-row" href="/campaigns">
<span class="type-name"><?= e($row['campaign_type_name']) ?></span>
<span class="type-bar-wrap">
<span class="type-bar" style="width: <?= e((string) $pct) ?>%"></span>
</span>
<span class="type-count"><?= e((string) $row['campaign_count']) ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>


+ 25
- 5
app/Views/partials/header.php Прегледај датотеку

@@ -44,22 +44,42 @@ $jsVersion = filemtime(__DIR__ . '/../../../public/js/app.js') ?: time();
</a>

<nav class="site-nav" aria-label="Primary navigation">
<?php foreach ($navigationItems as $item): ?>
<?php
<?php
$navIcons = [
'/' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>',
'/campaigns' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 12h6m-6 4h4"/></svg>',
'/campaign-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M3 3h7.5L21 12l-9 9-10.5-10.5V3z"/></svg>',
'/jobs' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>',
'/job-types' => '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/></svg>',
];
$prevHref = null;
$group1 = ['/'];
$group2 = ['/campaigns', '/campaign-types'];
$group3 = ['/jobs', '/job-types'];
foreach ($navigationItems as $item):
$isActive = $item['href'] === '/'
? $currentPath === '/'
: str_starts_with($currentPath, $item['href']);
?>
$needsSep = ($prevHref !== null) && (
(in_array($prevHref, $group1) && in_array($item['href'], $group2)) ||
(in_array($prevHref, $group2) && in_array($item['href'], $group3))
);
if ($needsSep): ?>
<span class="nav-sep" aria-hidden="true"></span>
<?php endif; ?>
<a class="nav-link<?= $isActive ? ' is-active' : '' ?>" href="<?= e($item['href']) ?>">
<?= $navIcons[$item['href']] ?? '' ?>
<?= e($item['label']) ?>
</a>
<?php endforeach; ?>
<?php $prevHref = $item['href'];
endforeach; ?>

<?php if (auth()->check()): ?>
<span class="nav-sep" aria-hidden="true"></span>
<span class="nav-user"><?= e(auth()->user()?->displayName ?: auth()->user()?->username ?? '') ?></span>
<form method="post" action="/logout" class="nav-logout-form">
<?= csrf_field() ?>
<button type="submit" class="button button-secondary button-sm">Log out</button>
<button type="submit" class="nav-logout-btn">Log out</button>
</form>
<?php endif; ?>
</nav>


+ 2
- 0
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([


+ 6
- 0
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) {


+ 41
- 22
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,44 @@ function Write-Step([string]$msg) {
Write-Host "`n==> $msg" -ForegroundColor Cyan
}

function Assert-DockerRunning {
docker info > $null 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "Docker is not running or not reachable. Start Docker Desktop and retry."
}
function Get-BaseArgs {
$a = @("-o", "StrictHostKeyChecking=accept-new")
if ($SshKey -ne "") { $a += @("-i", $SshKey) }
return $a
}

# ---------------------------------------------------------------------------
# Main
# Step 1 — copy .env (first password prompt)
# ---------------------------------------------------------------------------
Assert-DockerRunning
Write-Step "Copying .env to $SSH_USER@$SSH_HOST"
$scpArgs = Get-BaseArgs
$scpArgs += ".env", "${SSH_USER}@${SSH_HOST}:${REPO_PATH}/.env"
scp @scpArgs
if ($LASTEXITCODE -ne 0) { Write-Error "scp failed (exit $LASTEXITCODE)." }

if (-not $SkipBuild) {
Write-Step "Building image: $FULL_TAG"
docker build --tag $FULL_TAG .
if ($LASTEXITCODE -ne 0) { Write-Error "docker build failed (exit $LASTEXITCODE)." }
}
# ---------------------------------------------------------------------------
# Step 2 — deploy (second password prompt)
# ---------------------------------------------------------------------------
Write-Step "Deploying on $SSH_USER@$SSH_HOST"

# Deploy whichever branch is currently checked out locally.
$BRANCH = (git rev-parse --abbrev-ref HEAD).Trim()
Write-Host "Branch: $BRANCH" -ForegroundColor Yellow

# Build as a single semicolon-separated string — no newlines, no CRLF risk.
$remoteCmd = "set -e; "
$remoteCmd += "if [ -d '$REPO_PATH/.git' ]; then "
$remoteCmd += "cd '$REPO_PATH' && git fetch origin && git checkout $BRANCH && git pull origin $BRANCH; "
$remoteCmd += "else "
$remoteCmd += "mkdir -p '$REPO_PATH' && git clone --branch $BRANCH '$REPO_URL' '$REPO_PATH'; "
$remoteCmd += "fi; "
$remoteCmd += "docker ps -aq | xargs -r docker rm -f; "
$remoteCmd += "docker run --rm -v '$REPO_PATH':/app -w /app composer:latest install --no-dev --optimize-autoloader --no-interaction; "
$remoteCmd += "cd '$REPO_PATH' && docker compose up -d"

Write-Step "Pushing image: $FULL_TAG"
docker push $FULL_TAG
if ($LASTEXITCODE -ne 0) { Write-Error "docker push failed (exit $LASTEXITCODE)." }
$sshArgs = Get-BaseArgs
$sshArgs += "$SSH_USER@$SSH_HOST", $remoteCmd
ssh @sshArgs
if ($LASTEXITCODE -ne 0) { Write-Error "Deployment failed (exit $LASTEXITCODE)." }

Write-Host "`nDone. Image available at $FULL_TAG" -ForegroundColor Green
Write-Host "`nDone. Campaign Tracker is running on $SSH_HOST." -ForegroundColor Green

+ 186
- 11
public/css/site.css Прегледај датотеку

@@ -179,8 +179,11 @@ h4 { font-size: 16px; line-height: 24px; }
}

.nav-link {
display: inline-flex;
align-items: center;
gap: 6px;
text-decoration: none;
color: rgba(255, 255, 255, 0.75);
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
@@ -188,10 +191,16 @@ h4 { font-size: 16px; line-height: 24px; }
transition: background-color 120ms ease, color 120ms ease;
}

.nav-link:hover,
.nav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}

.nav-link:focus-visible {
color: #fff;
background: rgba(255, 255, 255, 0.12);
outline: 2px solid rgba(255, 255, 255, 0.6);
outline-offset: 2px;
}

.nav-link.is-active {
@@ -199,6 +208,41 @@ h4 { font-size: 16px; line-height: 24px; }
background: rgba(255, 255, 255, 0.18);
}

.nav-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
flex-shrink: 0;
}

.nav-logout-btn {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: var(--radius-xs);
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 20px;
cursor: pointer;
background: transparent;
color: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.22);
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}

.nav-logout-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}

.nav-logout-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.6);
outline-offset: 2px;
}

/* ── Page Content ────────────────────────────────────────────────────── */
.page-content {
flex: 1;
@@ -217,8 +261,8 @@ h4 { font-size: 16px; line-height: 24px; }

.section-heading h1 {
margin: 4px 0 8px;
font-size: 28px;
line-height: 36px;
font-size: 32px;
line-height: 40px;
}

.section-heading p {
@@ -393,6 +437,7 @@ h4 { font-size: 16px; line-height: 24px; }
padding: 24px;
border-radius: var(--radius-lg);
min-width: 0;
border-left: 3px solid var(--primary);
}

.panel-header {
@@ -401,7 +446,9 @@ h4 { font-size: 16px; line-height: 24px; }
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}

.panel-actions {
@@ -499,6 +546,11 @@ h4 { font-size: 16px; line-height: 24px; }
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}

.input:invalid:not(:placeholder-shown) {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.08);
}

.field-error {
color: var(--error);
font-size: 12px;
@@ -512,6 +564,17 @@ h4 { font-size: 16px; line-height: 24px; }
gap: 8px;
}

.ct-form .form-actions {
position: sticky;
bottom: 0;
z-index: 2;
background: var(--surface);
margin: 8px -24px -24px;
padding: 12px 24px;
border-top: 1px solid var(--border);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}

/* ── Buttons ─────────────────────────────────────────────────────────── */
.button {
display: inline-flex;
@@ -630,12 +693,48 @@ h4 { font-size: 16px; line-height: 24px; }

.stat-card strong {
display: block;
margin-top: 6px;
font-size: 28px;
margin-top: 8px;
font-size: 38px;
line-height: 1;
font-weight: 700;
color: var(--primary);
}

a.stat-card {
text-decoration: none;
cursor: pointer;
transition: transform 100ms ease, box-shadow 100ms ease, border-color 100ms ease;
}

a.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
border-color: var(--border-strong);
}

a.stat-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

a.stat-card::after {
content: 'View all →';
display: block;
margin-top: 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-muted);
opacity: 0;
transform: translateX(-4px);
transition: opacity 150ms ease, transform 150ms ease;
}

a.stat-card:hover::after {
opacity: 1;
transform: translateX(0);
}

.summary-feature {
margin-top: 16px;
padding: 14px 16px;
@@ -1032,17 +1131,37 @@ h4 { font-size: 16px; line-height: 24px; }

.attr-drag-handle {
cursor: grab;
padding: 0 4px;
color: var(--text-muted);
font-size: 16px;
user-select: none;
align-self: flex-end;
padding-bottom: 8px;
line-height: 1;
font-size: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 34px;
flex-shrink: 0;
opacity: 0.5;
transition: opacity 100ms ease;
}

.attr-drag-handle::before {
content: '';
display: block;
width: 6px;
height: 16px;
background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px);
background-size: 3px 4px;
background-repeat: repeat;
}

.attr-drag-handle:hover {
opacity: 1;
}

.attr-drag-handle:active {
cursor: grabbing;
opacity: 1;
}

.attribute-row.is-dragging {
@@ -1158,6 +1277,58 @@ h4 { font-size: 16px; line-height: 24px; }
width: 100%;
}

/* ── Global Focus Visible ────────────────────────────────────────────── */
.page-content a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}

/* ── Skeleton Loading ────────────────────────────────────────────────── */
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

.skeleton-rows {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}

.skeleton-row {
height: 38px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--surface-raised) 25%, var(--border) 50%, var(--surface-raised) 75%);
background-size: 400% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}

.skeleton-row:nth-child(2) { animation-delay: 0.1s; opacity: 0.85; }
.skeleton-row:nth-child(3) { animation-delay: 0.2s; opacity: 0.7; }
.skeleton-row:nth-child(4) { animation-delay: 0.3s; opacity: 0.55; }
.skeleton-row:nth-child(5) { animation-delay: 0.4s; opacity: 0.4; }

/* ── Type Breakdown Links ────────────────────────────────────────────── */
a.type-breakdown-row {
text-decoration: none;
color: inherit;
padding: 4px 6px;
margin: 0 -6px;
border-radius: var(--radius-sm);
transition: background-color 100ms ease;
}

a.type-breakdown-row:hover {
background: var(--surface-raised);
}

a.type-breakdown-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}

/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 860px) {
.header-inner,
@@ -1218,6 +1389,10 @@ h4 { font-size: 16px; line-height: 24px; }
text-align: center;
}

.nav-sep {
display: none;
}

.hero h1 {
font-size: 22px;
}


Loading…
Откажи
Сачувај

Powered by TurnKey Linux.