Theme Addition

This commit is contained in:
Brian Fertig 2026-07-09 15:54:57 -06:00
parent 25d749cb29
commit 10bc170362
47 changed files with 767 additions and 122 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ db-data/
src/vendor/
src/public/css/app.css
src/public/vendor/swagger-ui/
src/public/uploads/
src/composer.lock
src/node_modules/
src/storage/app/*

View File

@ -16,6 +16,9 @@ if [ -d /opt/swagger-ui ] && [ ! -f public/vendor/swagger-ui/swagger-ui-bundle.j
cp /opt/swagger-ui/* public/vendor/swagger-ui/
fi
# Directory for admin-uploaded theme assets (logo)
mkdir -p public/uploads/theme
# Compile Tailwind CSS
if [ -f resources/css/app.css ]; then
echo "Building Tailwind CSS..."

View File

@ -0,0 +1,37 @@
<?php
namespace App\Enums;
enum NavGradientDirection: string
{
case None = 'none';
case ToRight = 'to-r';
case ToBottom = 'to-b';
case ToBottomRight = 'to-br';
case ToTopRight = 'to-tr';
case Radial = 'radial';
public function label(): string
{
return match ($this) {
self::None => 'Solid (no gradient)',
self::ToRight => 'Left to right',
self::ToBottom => 'Top to bottom',
self::ToBottomRight => 'Upper-left to lower-right',
self::ToTopRight => 'Lower-left to upper-right',
self::Radial => 'Radial (from top center)',
};
}
/** Linear-gradient direction keyword; null for solid and radial. */
public function cssKeyword(): ?string
{
return match ($this) {
self::None, self::Radial => null,
self::ToRight => 'to right',
self::ToBottom => 'to bottom',
self::ToBottomRight => 'to bottom right',
self::ToTopRight => 'to top right',
};
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Enums\NavGradientDirection;
use App\Models\Theme;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ThemeController extends Controller
{
private const HEX = 'regex:/^#[0-9a-fA-F]{6}$/';
public function edit(): View
{
return view('admin.theme.edit', ['theme' => Theme::current()]);
}
public function update(Request $request): RedirectResponse
{
$data = $request->validate([
'primary_color' => ['required', self::HEX],
'secondary_color' => ['required', self::HEX],
'tertiary_color' => ['required', self::HEX],
'nav_gradient_from' => ['required', self::HEX],
'nav_gradient_mid' => ['nullable', self::HEX],
'nav_gradient_to' => ['nullable', self::HEX, 'required_unless:nav_gradient_direction,none'],
'nav_gradient_direction' => ['required', Rule::enum(NavGradientDirection::class)],
'logo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,webp', 'max:2048'],
'remove_logo' => ['nullable', 'boolean'],
], [
'nav_gradient_to.required_unless' => 'A gradient needs an end color — or choose "Solid (no gradient)".',
'*.regex' => 'Colors must be a 6-digit hex value like #2563eb.',
]);
$theme = Theme::current();
if ($request->boolean('remove_logo') || $request->hasFile('logo')) {
$this->deleteLogoFile($theme);
$theme->logo_path = null;
}
if ($request->hasFile('logo')) {
$file = $request->file('logo');
$name = 'logo-' . now()->format('YmdHis') . '.' . strtolower($file->getClientOriginalExtension());
$file->move(public_path('uploads/theme'), $name);
$theme->logo_path = 'uploads/theme/' . $name;
}
$theme->fill(Arr::except($data, ['logo', 'remove_logo']))->save();
return redirect()
->route('admin.theme.edit')
->with('status', 'Theme updated.');
}
public function reset(): RedirectResponse
{
$theme = Theme::current();
$this->deleteLogoFile($theme);
$theme->fill(Theme::DEFAULTS + ['logo_path' => null])->save();
return redirect()
->route('admin.theme.edit')
->with('status', 'Theme reset to defaults.');
}
private function deleteLogoFile(Theme $theme): void
{
if ($theme->logo_path && is_file(public_path($theme->logo_path))) {
@unlink(public_path($theme->logo_path));
}
}
}

158
src/app/Models/Theme.php Normal file
View File

@ -0,0 +1,158 @@
<?php
namespace App\Models;
use App\Enums\NavGradientDirection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Theme extends Model
{
protected $table = 'site_themes';
protected $guarded = ['id'];
protected $casts = [
'nav_gradient_direction' => NavGradientDirection::class,
];
/** Stock values — must match the migration defaults and the @theme tokens in app.css. */
public const DEFAULTS = [
'primary_color' => '#2563eb',
'secondary_color' => '#3b82f6',
'tertiary_color' => '#1d4ed8',
'nav_gradient_from' => '#ffffff',
'nav_gradient_mid' => null,
'nav_gradient_to' => null,
'nav_gradient_direction' => 'none',
];
protected static function booted(): void
{
static::saved(fn () => Cache::forget('site-theme'));
static::deleted(fn () => Cache::forget('site-theme'));
}
/** The single site-wide theme row, cached until the next save. */
public static function current(): self
{
// refresh() hydrates the DB column defaults into the fresh model.
return Cache::rememberForever(
'site-theme',
fn () => static::query()->first() ?? static::query()->create([])->refresh()
);
}
/** True when the theme matches the stock look exactly (no override needed). */
public function isDefault(): bool
{
if ($this->logo_path !== null) {
return false;
}
foreach (self::DEFAULTS as $key => $default) {
$value = $this->{$key};
if ($value instanceof \BackedEnum) {
$value = $value->value;
}
if ($value === null || $default === null) {
if ($value !== $default) {
return false;
}
continue;
}
if (strtolower((string) $value) !== strtolower((string) $default)) {
return false;
}
}
return true;
}
public function primaryHover(): string
{
return self::darken($this->primary_color, 0.15);
}
/** CSS background value for the top bar: solid color or gradient. */
public function navBackgroundCss(): string
{
$stops = $this->activeGradientStops();
if ($this->nav_gradient_direction === NavGradientDirection::None || count($stops) < 2) {
return $this->nav_gradient_from;
}
$list = implode(', ', $stops);
return $this->nav_gradient_direction === NavGradientDirection::Radial
? "radial-gradient(ellipse at top, {$list})"
: "linear-gradient({$this->nav_gradient_direction->cssKeyword()}, {$list})";
}
/** Whether the nav background is dark enough to need white text (WCAG contrast). */
public function navIsDark(): bool
{
$stops = $this->nav_gradient_direction === NavGradientDirection::None
? [$this->nav_gradient_from]
: $this->activeGradientStops();
$l = array_sum(array_map(self::luminance(...), $stops)) / count($stops);
// White text wins when its contrast beats gray-900's (luminance ~0.0106).
return (1.05 / ($l + 0.05)) >= (($l + 0.05) / 0.0606);
}
public function navLinkColor(): string
{
return $this->navIsDark() ? '#ffffff' : $this->primary_color;
}
public function navTextColor(): string
{
return $this->navIsDark() ? '#ffffff' : '#111827';
}
public function navMutedColor(): string
{
return $this->navIsDark() ? 'rgba(255, 255, 255, 0.75)' : '#6b7280';
}
/** @return list<string> */
private function activeGradientStops(): array
{
return array_values(array_filter([
$this->nav_gradient_from,
$this->nav_gradient_mid,
$this->nav_gradient_to,
]));
}
/** WCAG relative luminance of a #rrggbb color. */
private static function luminance(string $hex): float
{
[$r, $g, $b] = sscanf($hex, '#%02x%02x%02x');
$lin = function (int $c): float {
$c = $c / 255;
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * $lin($r) + 0.7152 * $lin($g) + 0.0722 * $lin($b);
}
private static function darken(string $hex, float $amount): string
{
[$r, $g, $b] = sscanf($hex, '#%02x%02x%02x');
return sprintf(
'#%02x%02x%02x',
(int) round($r * (1 - $amount)),
(int) round($g * (1 - $amount)),
(int) round($b * (1 - $amount))
);
}
}

View File

@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Theme;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -13,6 +15,8 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
//
View::composer('layouts.app', function ($view) {
$view->with('theme', Theme::current());
});
}
}

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Single-row site theme: logo, brand colors, and top-bar gradient.
* Defaults mirror the stock look (white header, Tailwind blues) so a
* fresh install renders identically until an admin customizes it.
*/
public function up(): void
{
Schema::create('site_themes', function (Blueprint $table) {
$table->id();
$table->string('logo_path')->nullable();
$table->string('primary_color', 7)->default('#2563eb');
$table->string('secondary_color', 7)->default('#3b82f6');
$table->string('tertiary_color', 7)->default('#1d4ed8');
$table->string('nav_gradient_from', 7)->default('#ffffff');
$table->string('nav_gradient_mid', 7)->nullable();
$table->string('nav_gradient_to', 7)->nullable();
$table->string('nav_gradient_direction', 10)->default('none');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('site_themes');
}
};

File diff suppressed because one or more lines are too long

View File

@ -3,6 +3,30 @@
/* Scan Blade templates for utility classes */
@source "../views";
/* --- Theme tokens ------------------------------------------------------ */
/* Semantic brand colors. Defaults are the stock blues; a customized theme
overrides these :root variables via an inline <style> in the layout head
(values come from the site_themes table), so no CSS rebuild is needed.
Must stay in sync with Theme::DEFAULTS. */
@theme {
--color-primary: #2563eb; /* blue-600 */
--color-primary-hover: #1d4ed8; /* blue-700 */
--color-secondary: #3b82f6; /* blue-500 */
--color-tertiary: #1d4ed8; /* blue-700 */
--color-nav-link: #2563eb;
--color-nav-text: #111827; /* gray-900 */
--color-nav-muted: #6b7280; /* gray-500 */
}
:root {
--theme-nav-bg: #ffffff;
--theme-chart-line: #2a78d6;
}
.site-nav {
background: var(--theme-nav-bg);
}
/* --- Destructive-action confirm modal ---------------------------------- */
#confirm-delete-modal::backdrop {
background: rgba(17, 24, 39, 0.5);

View File

@ -12,7 +12,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -21,14 +21,14 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create group</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create group</button>
<a href="{{ route('admin.groups.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -13,7 +13,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $group->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -22,14 +22,14 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $group->description) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description', $group->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save changes</button>
<a href="{{ route('admin.groups.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,12 +5,12 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Groups</h1>
<a href="{{ route('admin.groups.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New group</a>
<a href="{{ route('admin.groups.create') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">New group</a>
</div>
@if ($groups->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No groups yet. <a href="{{ route('admin.groups.create') }}" class="text-blue-600 hover:underline">Create the first one.</a>
No groups yet. <a href="{{ route('admin.groups.create') }}" class="text-primary hover:underline">Create the first one.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
@ -30,7 +30,7 @@
<td class="px-4 py-3 font-medium">{{ $group->name }}</td>
<td class="px-4 py-3 text-gray-600">{{ $group->description ?? '—' }}</td>
<td class="px-4 py-3">
<a href="{{ route('admin.groups.members', $group) }}" class="text-blue-600 hover:underline">
<a href="{{ route('admin.groups.members', $group) }}" class="text-primary hover:underline">
{{ $group->users_count }} {{ Str::plural('member', $group->users_count) }}
</a>
</td>

View File

@ -35,7 +35,7 @@
@csrf
@method('PUT')
<select name="role" onchange="this.form.submit()"
class="cursor-pointer rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="cursor-pointer rounded border border-gray-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-secondary/40">
<option value="member" {{ $member->pivot->role === 'member' ? 'selected' : '' }}>Member</option>
<option value="owner" {{ $member->pivot->role === 'owner' ? 'selected' : '' }}>Owner</option>
</select>
@ -68,7 +68,7 @@
<div class="mb-3">
<label for="user_id" class="mb-1 block font-semibold">User</label>
<select id="user_id" name="user_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<option value="">Select a user…</option>
@foreach ($available as $user)
<option value="{{ $user->id }}">{{ $user->name }} {{ $user->email }}</option>
@ -81,12 +81,12 @@
<div class="mb-4">
<label for="role" class="mb-1 block font-semibold">Role</label>
<select id="role" name="role"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<option value="member">Member</option>
<option value="owner">Owner</option>
</select>
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add to group</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Add to group</button>
</form>
@endif
</div>

View File

@ -0,0 +1,280 @@
@extends('layouts.app')
@section('title', 'Theme — ' . config('app.name'))
@section('content')
@php
$brandColors = [
['name' => 'primary_color', 'label' => 'Primary', 'hint' => 'Buttons, links, and the chart value line.'],
['name' => 'secondary_color', 'label' => 'Secondary', 'hint' => 'Focus rings and input highlights.'],
['name' => 'tertiary_color', 'label' => 'Tertiary', 'hint' => 'Chips, badges, and info panels.'],
];
$gradientStops = [
['name' => 'nav_gradient_from', 'label' => 'From', 'optional' => false],
['name' => 'nav_gradient_mid', 'label' => 'Middle (optional)', 'optional' => true],
['name' => 'nav_gradient_to', 'label' => 'To', 'optional' => true],
];
$presets = [
'Stock' => ['primary_color' => '#2563eb', 'secondary_color' => '#3b82f6', 'tertiary_color' => '#1d4ed8', 'nav_gradient_from' => '#ffffff', 'nav_gradient_mid' => '', 'nav_gradient_to' => '', 'nav_gradient_direction' => 'none'],
'Ocean' => ['primary_color' => '#0369a1', 'secondary_color' => '#0ea5e9', 'tertiary_color' => '#075985', 'nav_gradient_from' => '#0c4a6e', 'nav_gradient_mid' => '', 'nav_gradient_to' => '#0369a1', 'nav_gradient_direction' => 'to-r'],
'Forest' => ['primary_color' => '#15803d', 'secondary_color' => '#22c55e', 'tertiary_color' => '#166534', 'nav_gradient_from' => '#14532d', 'nav_gradient_mid' => '', 'nav_gradient_to' => '#15803d', 'nav_gradient_direction' => 'to-br'],
'Plum' => ['primary_color' => '#7e22ce', 'secondary_color' => '#a855f7', 'tertiary_color' => '#6b21a8', 'nav_gradient_from' => '#581c87', 'nav_gradient_mid' => '', 'nav_gradient_to' => '#7e22ce', 'nav_gradient_direction' => 'to-tr'],
'Slate' => ['primary_color' => '#334155', 'secondary_color' => '#64748b', 'tertiary_color' => '#1e293b', 'nav_gradient_from' => '#0f172a', 'nav_gradient_mid' => '', 'nav_gradient_to' => '#334155', 'nav_gradient_direction' => 'to-b'],
];
$inputClasses = 'rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40';
@endphp
<div class="mx-auto max-w-3xl rounded-lg border border-gray-200 bg-white p-8">
<h1 class="mb-1 text-2xl font-bold">Theme</h1>
<p class="mb-6 text-sm text-gray-500">Brand the site with your logo, colors, and a menu bar gradient. Changes apply to everyone.</p>
{{-- Live preview --}}
<div id="theme-preview" class="mb-8 rounded-lg border border-gray-200 bg-gray-50 p-4">
<p class="mb-3 text-xs font-semibold uppercase tracking-wide text-gray-500">Preview</p>
<div class="site-nav mb-3 flex items-center justify-between rounded-md border border-gray-200 px-4 py-3">
<span class="flex items-center text-lg font-bold text-nav-text" data-preview-brand>
<img src="{{ $theme->logo_path ? asset($theme->logo_path) : '' }}" alt="" class="h-8 w-auto {{ $theme->logo_path ? '' : 'hidden' }}" data-preview-logo>
<span class="{{ $theme->logo_path ? 'hidden' : '' }}" data-preview-name>{{ config('app.name', 'Bowler') }}</span>
</span>
<span class="flex items-center gap-4 text-sm">
<span class="text-nav-link">Charts</span>
<span class="text-nav-link">Data</span>
<span class="text-nav-muted">{{ auth()->user()->name }}</span>
</span>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-white">Primary button</span>
<span class="text-sm text-primary underline">A link</span>
<span class="rounded-md border border-secondary px-3 py-2 text-sm text-gray-500 ring-2 ring-secondary/40">Focused input</span>
<span class="rounded-full border border-tertiary/25 bg-tertiary/5 px-3 py-1 text-sm text-tertiary">Chip</span>
</div>
</div>
{{-- Presets --}}
<div class="mb-8">
<p class="mb-2 font-semibold">Presets</p>
<div class="flex flex-wrap gap-2">
@foreach ($presets as $label => $values)
<button type="button" data-preset="{{ json_encode($values) }}"
class="cursor-pointer rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-50">
<span class="mr-1.5 inline-block h-3 w-3 rounded-full align-middle" style="background: {{ $values['primary_color'] }}"></span>{{ $label }}
</button>
@endforeach
</div>
</div>
<form method="POST" action="{{ route('admin.theme.update') }}" enctype="multipart/form-data">
@csrf
@method('PUT')
{{-- Logo --}}
<div class="mb-8">
<p class="mb-2 font-semibold">Logo</p>
@if ($theme->logo_path)
<div class="mb-3 flex items-center gap-4">
<img src="{{ asset($theme->logo_path) }}" alt="Current logo" class="h-9 w-auto rounded border border-gray-200 bg-white p-1">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" name="remove_logo" value="1" data-remove-logo class="rounded border-gray-300 text-primary">
Remove current logo
</label>
</div>
@endif
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp"
class="block w-full cursor-pointer text-sm text-gray-700 file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-gray-200">
<p class="mt-1 text-sm text-gray-500">PNG, JPEG, or WebP up to 2 MB. Shown at 36px tall in place of the site name.</p>
@error('logo')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
{{-- Brand colors --}}
<div class="mb-8">
<p class="mb-2 font-semibold">Brand colors</p>
<div class="space-y-3">
@foreach ($brandColors as $color)
<div class="flex items-center gap-3">
<input type="color" value="{{ old($color['name'], $theme->{$color['name']}) }}"
data-color-for="{{ $color['name'] }}" aria-label="{{ $color['label'] }} color picker"
class="h-9 w-12 cursor-pointer rounded border border-gray-300">
<input type="text" name="{{ $color['name'] }}" value="{{ old($color['name'], $theme->{$color['name']}) }}"
required pattern="#[0-9a-fA-F]{6}" maxlength="7" data-theme-input
class="w-28 {{ $inputClasses }}">
<div>
<span class="block text-sm font-medium">{{ $color['label'] }}</span>
<span class="block text-xs text-gray-500">{{ $color['hint'] }}</span>
</div>
</div>
@error($color['name'])
<p class="text-sm text-red-700">{{ $message }}</p>
@enderror
@endforeach
</div>
</div>
{{-- Menu bar --}}
<div class="mb-8">
<p class="mb-2 font-semibold">Menu bar</p>
<div class="mb-3">
<label for="nav_gradient_direction" class="mb-1 block text-sm font-medium">Style</label>
<select id="nav_gradient_direction" name="nav_gradient_direction" data-theme-input
class="w-64 {{ $inputClasses }}">
@foreach (\App\Enums\NavGradientDirection::cases() as $direction)
<option value="{{ $direction->value }}" @selected(old('nav_gradient_direction', $theme->nav_gradient_direction->value) === $direction->value)>
{{ $direction->label() }}
</option>
@endforeach
</select>
@error('nav_gradient_direction')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="space-y-3">
@foreach ($gradientStops as $stop)
<div class="flex items-center gap-3">
<input type="color" value="{{ old($stop['name'], $theme->{$stop['name']}) ?: '#ffffff' }}"
data-color-for="{{ $stop['name'] }}" aria-label="{{ $stop['label'] }} color picker"
class="h-9 w-12 cursor-pointer rounded border border-gray-300">
<input type="text" name="{{ $stop['name'] }}" value="{{ old($stop['name'], $theme->{$stop['name']}) }}"
pattern="#[0-9a-fA-F]{6}" maxlength="7" data-theme-input
@unless ($stop['optional']) required @endunless
class="w-28 {{ $inputClasses }}">
<span class="text-sm font-medium">{{ $stop['label'] }}</span>
@if ($stop['optional'])
<button type="button" data-clear-stop="{{ $stop['name'] }}"
class="cursor-pointer text-sm text-gray-500 hover:text-gray-700 hover:underline">Clear</button>
@endif
</div>
@error($stop['name'])
<p class="text-sm text-red-700">{{ $message }}</p>
@enderror
@endforeach
</div>
<p class="mt-2 text-sm text-gray-500">With "Solid", only the From color is used. Menu text switches between light and dark automatically for readability.</p>
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save theme</button>
</div>
</form>
<form method="POST" action="{{ route('admin.theme.reset') }}" class="mt-3"
onsubmit="return confirm('Reset the theme to stock defaults and delete the uploaded logo?')">
@csrf
<button type="submit" class="cursor-pointer text-sm text-red-700 hover:underline">Reset to defaults</button>
</form>
</div>
<script>
(function () {
var panel = document.getElementById('theme-preview');
var HEX = /^#[0-9a-fA-F]{6}$/;
function field(name) {
return document.querySelector('[name="' + name + '"]');
}
function val(name) {
return field(name).value.trim();
}
// Same math as App\Models\Theme — keep in sync.
function luminance(hex) {
var parts = [1, 3, 5].map(function (i) {
var c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * parts[0] + 0.7152 * parts[1] + 0.0722 * parts[2];
}
function darken(hex, amount) {
return '#' + [1, 3, 5].map(function (i) {
var c = Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount));
return c.toString(16).padStart(2, '0');
}).join('');
}
var directionCss = { 'to-r': 'to right', 'to-b': 'to bottom', 'to-br': 'to bottom right', 'to-tr': 'to top right' };
function render() {
var from = val('nav_gradient_from');
if (!HEX.test(from)) return;
var direction = val('nav_gradient_direction');
var stops = [from];
if (direction !== 'none') {
[val('nav_gradient_mid'), val('nav_gradient_to')].forEach(function (s) {
if (HEX.test(s)) stops.push(s);
});
}
var background = (direction === 'none' || stops.length < 2) ? from
: direction === 'radial' ? 'radial-gradient(ellipse at top, ' + stops.join(', ') + ')'
: 'linear-gradient(' + directionCss[direction] + ', ' + stops.join(', ') + ')';
var l = stops.reduce(function (sum, s) { return sum + luminance(s); }, 0) / stops.length;
var dark = (1.05 / (l + 0.05)) >= ((l + 0.05) / 0.0606);
var primary = HEX.test(val('primary_color')) ? val('primary_color') : '#2563eb';
var secondary = HEX.test(val('secondary_color')) ? val('secondary_color') : '#3b82f6';
var tertiary = HEX.test(val('tertiary_color')) ? val('tertiary_color') : '#1d4ed8';
panel.style.setProperty('--theme-nav-bg', background);
panel.style.setProperty('--color-primary', primary);
panel.style.setProperty('--color-primary-hover', darken(primary, 0.15));
panel.style.setProperty('--color-secondary', secondary);
panel.style.setProperty('--color-tertiary', tertiary);
panel.style.setProperty('--color-nav-link', dark ? '#ffffff' : primary);
panel.style.setProperty('--color-nav-text', dark ? '#ffffff' : '#111827');
panel.style.setProperty('--color-nav-muted', dark ? 'rgba(255, 255, 255, 0.75)' : '#6b7280');
}
// Two-way sync between each color picker and its hex text field.
document.querySelectorAll('[data-color-for]').forEach(function (picker) {
var text = field(picker.getAttribute('data-color-for'));
picker.addEventListener('input', function () {
text.value = picker.value;
render();
});
text.addEventListener('input', function () {
if (HEX.test(text.value.trim())) picker.value = text.value.trim();
});
});
document.querySelectorAll('[data-theme-input]').forEach(function (el) {
el.addEventListener('input', render);
});
document.querySelectorAll('[data-clear-stop]').forEach(function (button) {
button.addEventListener('click', function () {
field(button.getAttribute('data-clear-stop')).value = '';
render();
});
});
document.querySelectorAll('[data-preset]').forEach(function (button) {
button.addEventListener('click', function () {
var values = JSON.parse(button.getAttribute('data-preset'));
Object.keys(values).forEach(function (name) {
var input = field(name);
if (!input) return;
input.value = values[name];
var picker = document.querySelector('[data-color-for="' + name + '"]');
if (picker && HEX.test(values[name])) picker.value = values[name];
});
render();
});
});
// Toggle the preview brand between logo and site name with the remove checkbox.
var removeLogo = document.querySelector('[data-remove-logo]');
if (removeLogo) {
removeLogo.addEventListener('change', function () {
document.querySelector('[data-preview-logo]').classList.toggle('hidden', removeLogo.checked);
document.querySelector('[data-preview-name]').classList.toggle('hidden', !removeLogo.checked);
});
}
render();
})();
</script>
@endsection

View File

@ -12,7 +12,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -21,7 +21,7 @@
<div class="mb-4">
<label for="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('email')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -30,7 +30,7 @@
<div class="mb-4">
<label for="password" class="mb-1 block font-semibold">Password</label>
<input id="password" type="password" name="password" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('password')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -39,7 +39,7 @@
<div class="mb-6">
<label for="type" class="mb-1 block font-semibold">User type</label>
<select id="type" name="type"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@foreach (\App\Enums\UserType::cases() as $type)
<option value="{{ $type->value }}" {{ old('type', 'standard') === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
@endforeach
@ -51,7 +51,7 @@
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create user</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create user</button>
<a href="{{ route('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -13,7 +13,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $user->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -22,7 +22,7 @@
<div class="mb-4">
<label for="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email', $user->email) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('email')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -31,7 +31,7 @@
<div class="mb-6">
<label for="type" class="mb-1 block font-semibold">User type</label>
<select id="type" name="type" {{ $user->id === auth()->id() ? 'disabled' : '' }}
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500">
@foreach (\App\Enums\UserType::cases() as $type)
<option value="{{ $type->value }}" {{ old('type', $user->type->value) === $type->value ? 'selected' : '' }}>{{ $type->label() }}</option>
@endforeach
@ -49,9 +49,9 @@
</div>
<div class="flex items-center gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save changes</button>
<a href="{{ route('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
<a href="{{ route('admin.users.password.edit', $user) }}" class="ml-auto text-sm text-blue-600 hover:underline">Reset password </a>
<a href="{{ route('admin.users.password.edit', $user) }}" class="ml-auto text-sm text-primary hover:underline">Reset password </a>
</div>
</form>
</div>

View File

@ -5,7 +5,7 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">User Management</h1>
<a href="{{ route('admin.users.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New user</a>
<a href="{{ route('admin.users.create') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">New user</a>
</div>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
@ -35,7 +35,7 @@
<td class="px-4 py-3">
@forelse ($user->groups as $group)
<a href="{{ route('admin.groups.members', $group) }}"
class="mr-1 inline-block rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-100">{{ $group->name }}</a>
class="mr-1 inline-block rounded-full bg-tertiary/5 px-2 py-0.5 text-xs text-tertiary hover:bg-tertiary/10">{{ $group->name }}</a>
@empty
<span class="text-gray-400"></span>
@endforelse

View File

@ -14,7 +14,7 @@
<div class="mb-4">
<label for="password" class="mb-1 block font-semibold">New password</label>
<input id="password" type="password" name="password" required autofocus autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('password')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -23,11 +23,11 @@
<div class="mb-6">
<label for="password_confirmation" class="mb-1 block font-semibold">Confirm new password</label>
<input id="password_confirmation" type="password" name="password_confirmation" required autocomplete="new-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Reset password</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Reset password</button>
<a href="{{ route('admin.users.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,7 +5,7 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">API Keys</h1>
<a href="{{ route('api.docs') }}" class="text-blue-600 hover:underline">API documentation </a>
<a href="{{ route('api.docs') }}" class="text-primary hover:underline">API documentation </a>
</div>
@if ($errors->has('view'))
@ -37,9 +37,9 @@
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">Key name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. CI pipeline, Power BI, my-script"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create key</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create key</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>

View File

@ -12,7 +12,7 @@
<div class="mb-4">
<label for="email" class="mb-1 block font-semibold">Email</label>
<input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('email')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -21,7 +21,7 @@
<div class="mb-4">
<label for="password" class="mb-1 block font-semibold">Password</label>
<input id="password" type="password" name="password" required autocomplete="current-password"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('password')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -34,7 +34,7 @@
</label>
</div>
<button type="submit" class="w-full cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Log in</button>
<button type="submit" class="w-full cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Log in</button>
</form>
</div>
@endsection

View File

@ -13,14 +13,14 @@
<div class="mb-6">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $category->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save</button>
<a href="{{ route('categories.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,7 +5,7 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Categories</h1>
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Manage teams </a>
<a href="{{ route('teams.index') }}" class="text-primary hover:underline">Manage teams </a>
</div>
<p class="mb-4 text-sm text-gray-500">Categories sub-group items within each team on bowler charts e.g. "Performance", "General", or "Security".</p>
@ -19,9 +19,9 @@
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">New category</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. Performance"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add category</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Add category</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>

View File

@ -15,7 +15,7 @@
<th class="min-w-28 px-2 py-3">Owner</th>
@endif
@foreach ($months as $month)
<th class="px-2 py-3 text-center {{ $loop->last ? 'bg-blue-50' : '' }}">{{ $month['label'] }}</th>
<th class="px-2 py-3 text-center {{ $loop->last ? 'bg-tertiary/5' : '' }}">{{ $month['label'] }}</th>
@endforeach
</tr>
</thead>
@ -47,12 +47,12 @@
@endif
@foreach ($months as $month)
@php($cell = $row['cells'][$month['key']] ?? null)
<td class="px-1 py-1 text-center {{ $loop->last ? 'bg-blue-50/50' : '' }}">
<td class="px-1 py-1 text-center {{ $loop->last ? 'bg-tertiary/3' : '' }}">
@if ($cell && $row['type'] === 'project')
<button type="button" title="{{ $cell['tooltip'] }}"
data-month="{{ $month['key'] }}"
data-details="{{ json_encode($cell['details']) }}"
class="js-project-cell inline-block w-full cursor-pointer rounded px-1 py-1.5 text-xs font-semibold hover:ring-2 hover:ring-blue-400 {{ $cell['status']->badgeClasses() }}">
class="js-project-cell inline-block w-full cursor-pointer rounded px-1 py-1.5 text-xs font-semibold hover:ring-2 hover:ring-secondary {{ $cell['status']->badgeClasses() }}">
{{ $cell['display'] }}
</button>
@elseif ($cell)
@ -68,13 +68,13 @@
</tr>
@if ($row['type'] === 'project')
<tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 p-0">
<td colspan="{{ $totalCols }}" class="bg-tertiary/3 p-0">
<div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4"></div></div>
</td>
</tr>
@else
<tr class="js-detail-row hidden">
<td colspan="{{ $totalCols }}" class="bg-blue-50/60 p-0">
<td colspan="{{ $totalCols }}" class="bg-tertiary/3 p-0">
<div class="detail-outer"><div class="detail-inner js-detail-content px-6 py-4">
@include('charts._kpi-chart', ['kpi' => $item, 'chart' => $row['chart']])
</div></div>
@ -120,7 +120,7 @@
function collapseAll() {
document.querySelectorAll('.js-detail-row.is-open').forEach(collapseRow);
document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-blue-500'); });
document.querySelectorAll('.js-project-cell.ring-2').forEach(function (c) { c.classList.remove('ring-2', 'ring-secondary'); });
}
function section(label, text) {
@ -195,7 +195,7 @@
populate(detailRow.querySelector('.js-detail-content'), JSON.parse(cell.dataset.details));
detailRow.dataset.month = cell.dataset.month;
expandRow(detailRow);
cell.classList.add('ring-2', 'ring-blue-500');
cell.classList.add('ring-2', 'ring-secondary');
}
return;
}

View File

@ -33,7 +33,7 @@
{{-- Value line (gaps split the path; pathLength normalizes the draw-in animation) --}}
@foreach ($chart['paths'] as $path)
<path d="{{ $path }}" pathLength="1" class="kpi-line"
fill="none" stroke="{{ $chart['lineColor'] }}" stroke-width="2"
fill="none" style="stroke: var(--theme-chart-line)" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
@endforeach

View File

@ -10,7 +10,7 @@
<p class="text-gray-600">
You are not an owner of any group, so you can't create group charts.
Ask a group owner (or an administrator) to make you an owner, or use
<a href="{{ route('charts.personal') }}" class="text-blue-600 hover:underline">your personal bowler</a>.
<a href="{{ route('charts.personal') }}" class="text-primary hover:underline">your personal bowler</a>.
</p>
@else
<form method="POST" action="{{ route('charts.store') }}">
@ -19,7 +19,7 @@
<div class="mb-4">
<label for="group_id" class="mb-1 block font-semibold">Group</label>
<select id="group_id" name="group_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@foreach ($groups as $group)
<option value="{{ $group->id }}" {{ (int) old('group_id') === $group->id ? 'selected' : '' }}>{{ $group->name }}</option>
@endforeach
@ -32,7 +32,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -41,14 +41,14 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create chart</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create chart</button>
<a href="{{ route('charts.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -14,7 +14,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $chart->name) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -23,14 +23,14 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $chart->description) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description', $chart->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save changes</button>
<a href="{{ route('charts.show', $chart) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,16 +5,16 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">Bowler Charts</h1>
<a href="{{ route('charts.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New chart</a>
<a href="{{ route('charts.create') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">New chart</a>
</div>
{{-- Personal chart --}}
<div class="mb-6 flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-5 py-4">
<div class="mb-6 flex items-center justify-between rounded-lg border border-tertiary/25 bg-tertiary/5 px-5 py-4">
<div>
<a href="{{ route('charts.personal') }}" class="font-semibold text-blue-800 hover:underline">My Bowler</a>
<p class="text-sm text-blue-700/80">Your personal chart with all of your own KPIs and projects.</p>
<a href="{{ route('charts.personal') }}" class="font-semibold text-tertiary hover:underline">My Bowler</a>
<p class="text-sm text-tertiary/80">Your personal chart with all of your own KPIs and projects.</p>
</div>
<a href="{{ route('charts.personal') }}" class="rounded-md border border-blue-600 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-100">Open</a>
<a href="{{ route('charts.personal') }}" class="rounded-md border border-tertiary px-3 py-1.5 text-sm text-tertiary hover:bg-tertiary/10">Open</a>
</div>
@if ($chartsByGroup->isEmpty())

View File

@ -9,7 +9,7 @@
@guest
<p class="mt-6">
<a href="{{ route('login') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Log in to get started</a>
<a href="{{ route('login') }}" class="inline-block rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Log in to get started</a>
</p>
@endguest
@ -17,7 +17,7 @@
<p class="mt-6">You are logged in as <strong>{{ auth()->user()->name }}</strong>.</p>
@if (auth()->user()->isAdmin())
<p class="mt-4">
<a href="{{ route('admin.users.index') }}" class="inline-block rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Manage users</a>
<a href="{{ route('admin.users.index') }}" class="inline-block rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Manage users</a>
</p>
@endif
@endauth

View File

@ -29,7 +29,7 @@
@endif
</label>
<input id="value" type="number" step="any" name="value" value="{{ old('value', $entry?->value) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('value')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror

View File

@ -4,7 +4,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $kpi?->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -13,7 +13,7 @@
<div class="mb-4">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $kpi?->description) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description', $kpi?->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -25,7 +25,7 @@
<div>
<label for="value_type" class="mb-1 block font-semibold">Measurement type</label>
<select id="value_type" name="value_type"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@foreach (\App\Enums\KpiValueType::cases() as $type)
<option value="{{ $type->value }}" {{ old('value_type', $kpi?->value_type?->value ?? 'number') === $type->value ? 'selected' : '' }}>
{{ $type->label() }}
@ -36,7 +36,7 @@
<div>
<label for="unit_label" class="mb-1 block font-semibold">Unit label <span class="font-normal text-gray-500">(optional)</span></label>
<input id="unit_label" type="text" name="unit_label" value="{{ old('unit_label', $kpi?->unit_label) }}" placeholder="e.g. days, units, $"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('unit_label')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -46,7 +46,7 @@
<div class="mb-4">
<label for="evaluation" class="mb-1 block font-semibold">Evaluation</label>
<select id="evaluation" name="evaluation"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@foreach (\App\Enums\KpiEvaluation::cases() as $mode)
<option value="{{ $mode->value }}" {{ old('evaluation', $kpi?->evaluation?->value ?? 'higher_is_better') === $mode->value ? 'selected' : '' }}>
{{ $mode->label() }}
@ -63,7 +63,7 @@
<div>
<label for="green_threshold" class="mb-1 block font-semibold">Green threshold</label>
<input id="green_threshold" type="number" step="any" name="green_threshold" value="{{ old('green_threshold', $kpi?->green_threshold) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<p class="mt-1 text-sm text-gray-500" data-hint-higher>Green when value this</p>
<p class="mt-1 hidden text-sm text-gray-500" data-hint-lower>Green when value this</p>
@error('green_threshold')
@ -73,7 +73,7 @@
<div>
<label for="yellow_threshold" class="mb-1 block font-semibold">Yellow threshold</label>
<input id="yellow_threshold" type="number" step="any" name="yellow_threshold" value="{{ old('yellow_threshold', $kpi?->yellow_threshold) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<p class="mt-1 text-sm text-gray-500">Beyond yellow is red</p>
@error('yellow_threshold')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@ -86,7 +86,7 @@
<div>
<label for="target" class="mb-1 block font-semibold">Target</label>
<input id="target" type="number" step="any" name="target" value="{{ old('target', $kpi?->target) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('target')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -94,7 +94,7 @@
<div>
<label for="green_tolerance" class="mb-1 block font-semibold">Green ±</label>
<input id="green_tolerance" type="number" step="any" min="0" name="green_tolerance" value="{{ old('green_tolerance', $kpi?->green_tolerance) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('green_tolerance')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -102,7 +102,7 @@
<div>
<label for="yellow_tolerance" class="mb-1 block font-semibold">Yellow ±</label>
<input id="yellow_tolerance" type="number" step="any" min="0" name="yellow_tolerance" value="{{ old('yellow_tolerance', $kpi?->yellow_tolerance) }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('yellow_tolerance')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror

View File

@ -13,7 +13,7 @@
@csrf
@include('kpis._form')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create KPI</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create KPI</button>
<a href="{{ route('kpis.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -11,7 +11,7 @@
@method('PUT')
@include('kpis._form')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save changes</button>
<a href="{{ route('kpis.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -34,7 +34,7 @@
<div>
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date" value="{{ old('entry_date', now()->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -43,13 +43,13 @@
<div>
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<input id="notes" type="text" name="notes" value="{{ old('notes') }}"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="self-end">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Add</button>
</div>
</form>
</div>

View File

@ -14,7 +14,7 @@
<div class="mb-4">
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date" value="{{ old('entry_date', $entry->entry_date->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -27,14 +27,14 @@
<div class="mb-6">
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="notes" name="notes" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('notes', $entry->notes) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('notes', $entry->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save entry</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save entry</button>
<a href="{{ route('kpis.entries.index', $kpi) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,12 +5,12 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My KPIs</h1>
<a href="{{ route('kpis.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New KPI</a>
<a href="{{ route('kpis.create') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">New KPI</a>
</div>
@if ($kpis->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No KPIs yet. <a href="{{ route('kpis.create') }}" class="text-blue-600 hover:underline">Create your first KPI.</a>
No KPIs yet. <a href="{{ route('kpis.create') }}" class="text-primary hover:underline">Create your first KPI.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">

View File

@ -5,17 +5,38 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', config('app.name', 'Bowler'))</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
@if (! $theme->isDefault())
<style>
:root {
--color-primary: {{ $theme->primary_color }};
--color-primary-hover: {{ $theme->primaryHover() }};
--color-secondary: {{ $theme->secondary_color }};
--color-tertiary: {{ $theme->tertiary_color }};
--color-nav-link: {{ $theme->navLinkColor() }};
--color-nav-text: {{ $theme->navTextColor() }};
--color-nav-muted: {{ $theme->navMutedColor() }};
--theme-nav-bg: {{ $theme->navBackgroundCss() }};
--theme-chart-line: {{ $theme->primary_color }};
}
</style>
@endif
</head>
<body class="min-h-screen bg-gray-50 text-gray-900 antialiased">
<header class="border-b border-gray-200 bg-white">
<header class="site-nav border-b border-gray-200">
<div class="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
<a href="{{ route('home') }}" class="text-lg font-bold text-gray-900">{{ config('app.name', 'Bowler') }}</a>
<a href="{{ route('home') }}" class="flex items-center text-lg font-bold text-nav-text">
@if ($theme->logo_path)
<img src="{{ asset($theme->logo_path) }}" alt="{{ config('app.name', 'Bowler') }}" class="h-9 w-auto">
@else
{{ config('app.name', 'Bowler') }}
@endif
</a>
<nav class="flex items-center gap-4">
@auth
<a href="{{ route('charts.index') }}" class="text-blue-600 hover:underline">Charts</a>
<a href="{{ route('charts.index') }}" class="text-nav-link hover:underline">Charts</a>
<div class="relative" data-dropdown>
<button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
class="flex cursor-pointer items-center gap-1 text-nav-link hover:underline">
Data
<svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@ -29,7 +50,7 @@
</div>
<div class="relative" data-dropdown>
<button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
class="flex cursor-pointer items-center gap-1 text-nav-link hover:underline">
Account
<svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@ -46,7 +67,7 @@
@if (auth()->user()->isAdmin())
<div class="relative" data-dropdown>
<button type="button" data-dropdown-toggle aria-expanded="false" aria-haspopup="true"
class="flex cursor-pointer items-center gap-1 text-blue-600 hover:underline">
class="flex cursor-pointer items-center gap-1 text-nav-link hover:underline">
Admin
<svg data-dropdown-chevron class="h-3 w-3 transition-transform duration-150" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@ -56,17 +77,18 @@
class="absolute right-0 top-full z-20 mt-2 hidden w-44 rounded-md border border-gray-200 bg-white py-1 shadow-lg">
<a href="{{ route('admin.users.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Users</a>
<a href="{{ route('admin.groups.index') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Groups</a>
<a href="{{ route('admin.theme.edit') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">Theme</a>
</div>
</div>
@endif
<span class="text-gray-500">{{ auth()->user()->name }}</span>
<span class="text-nav-muted">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="cursor-pointer text-blue-600 hover:underline">Log out</button>
<button type="submit" class="cursor-pointer text-nav-link hover:underline">Log out</button>
</form>
@else
<a href="{{ route('login') }}" class="text-blue-600 hover:underline">Log in</a>
<a href="{{ route('login') }}" class="text-nav-link hover:underline">Log in</a>
@endauth
</nav>
</div>

View File

@ -1,16 +1,16 @@
{{-- Shown instead of a create form when the user has no teams or categories yet. --}}
<div class="rounded-lg border border-blue-200 bg-blue-50 p-6 text-center">
<p class="mb-3 font-semibold text-blue-900">First, set up your teams and categories</p>
<p class="mb-4 text-sm text-blue-800/80">
<div class="rounded-lg border border-tertiary/25 bg-tertiary/5 p-6 text-center">
<p class="mb-3 font-semibold text-tertiary">First, set up your teams and categories</p>
<p class="mb-4 text-sm text-tertiary/80">
Every KPI and project belongs to a team (e.g. "Website Development") and a category
(e.g. "Performance") so bowler charts can group them. Create at least one of each to continue.
</p>
<div class="flex justify-center gap-3">
@if (auth()->user()->teams->isEmpty())
<a href="{{ route('teams.index') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create a team</a>
<a href="{{ route('teams.index') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create a team</a>
@endif
@if (auth()->user()->categories->isEmpty())
<a href="{{ route('categories.index') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create a category</a>
<a href="{{ route('categories.index') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create a category</a>
@endif
</div>
</div>

View File

@ -10,7 +10,7 @@
<div>
<label for="team_id" class="mb-1 block font-semibold">Team</label>
<select id="team_id" name="team_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<option value="">Select a team…</option>
@foreach ($ownerTeams as $team)
<option value="{{ $team->id }}" {{ (int) old('team_id', $item?->team_id) === $team->id ? 'selected' : '' }}>{{ $team->name }}</option>
@ -23,7 +23,7 @@
<div>
<label for="category_id" class="mb-1 block font-semibold">Category</label>
<select id="category_id" name="category_id" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
<option value="">Select a category…</option>
@foreach ($ownerCategories as $category)
<option value="{{ $category->id }}" {{ (int) old('category_id', $item?->category_id) === $category->id ? 'selected' : '' }}>{{ $category->name }}</option>
@ -36,6 +36,6 @@
</div>
<p class="-mt-2 mb-4 text-sm text-gray-500">
Used to group items on bowler charts.
<a href="{{ route('teams.index') }}" class="text-blue-600 hover:underline">Manage teams</a> ·
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Manage categories</a>
<a href="{{ route('teams.index') }}" class="text-primary hover:underline">Manage teams</a> ·
<a href="{{ route('categories.index') }}" class="text-primary hover:underline">Manage categories</a>
</p>

View File

@ -6,7 +6,7 @@
<label for="entry_date" class="mb-1 block font-semibold">Date</label>
<input id="entry_date" type="date" name="entry_date"
value="{{ old('entry_date', $entry?->entry_date?->format('Y-m-d') ?? now()->format('Y-m-d')) }}" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('entry_date')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -14,7 +14,7 @@
<div>
<label for="status" class="mb-1 block font-semibold">Status</label>
<select id="status" name="status" required
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@foreach (\App\Enums\RagStatus::cases() as $status)
<option value="{{ $status->value }}" {{ old('status', $entry?->status?->value) === $status->value ? 'selected' : '' }}>
{{ $status->label() }}
@ -30,7 +30,7 @@
<div class="mb-4">
<label for="notes" class="mb-1 block font-semibold">Notes <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="notes" name="notes" rows="2" placeholder="Overall status description"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('notes', $entry?->notes) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('notes', $entry?->notes) }}</textarea>
@error('notes')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -41,7 +41,7 @@
<div>
<label for="{{ $field }}" class="mb-1 block font-semibold">{{ $label }} <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="{{ $field }}" name="{{ $field }}" rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old($field, $entry?->{$field}) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old($field, $entry?->{$field}) }}</textarea>
@error($field)
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror

View File

@ -15,7 +15,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -26,7 +26,7 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description') }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -35,7 +35,7 @@
@include('partials.chart-sharing')
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Create project</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Create project</button>
<a href="{{ route('projects.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -13,7 +13,7 @@
<div class="mb-4">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $project->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -24,7 +24,7 @@
<div class="mb-6">
<label for="description" class="mb-1 block font-semibold">Description <span class="font-normal text-gray-500">(optional)</span></label>
<textarea id="description" name="description" rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">{{ old('description', $project->description) }}</textarea>
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">{{ old('description', $project->description) }}</textarea>
@error('description')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
@ -33,7 +33,7 @@
@include('partials.chart-sharing', ['item' => $project])
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save changes</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save changes</button>
<a href="{{ route('projects.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -30,7 +30,7 @@
<form method="POST" action="{{ route('projects.entries.store', $project) }}" class="border-t border-gray-200 p-4">
@csrf
@include('projects._entry-fields')
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add entry</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Add entry</button>
</form>
</details>
@endcan

View File

@ -12,7 +12,7 @@
@method('PUT')
@include('projects._entry-fields', ['entry' => $entry])
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save entry</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save entry</button>
<a href="{{ route('projects.entries.index', $project) }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,12 +5,12 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Projects</h1>
<a href="{{ route('projects.create') }}" class="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">New project</a>
<a href="{{ route('projects.create') }}" class="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">New project</a>
</div>
@if ($projects->isEmpty())
<div class="rounded-lg border border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
No projects yet. <a href="{{ route('projects.create') }}" class="text-blue-600 hover:underline">Create your first project.</a>
No projects yet. <a href="{{ route('projects.create') }}" class="text-primary hover:underline">Create your first project.</a>
</div>
@else
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">

View File

@ -13,14 +13,14 @@
<div class="mb-6">
<label for="name" class="mb-1 block font-semibold">Name</label>
<input id="name" type="text" name="name" value="{{ old('name', $team->name) }}" required autofocus
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-2">
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Save</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Save</button>
<a href="{{ route('teams.index') }}" class="rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 hover:bg-gray-50">Cancel</a>
</div>
</form>

View File

@ -5,7 +5,7 @@
@section('content')
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Teams</h1>
<a href="{{ route('categories.index') }}" class="text-blue-600 hover:underline">Manage categories </a>
<a href="{{ route('categories.index') }}" class="text-primary hover:underline">Manage categories </a>
</div>
<p class="mb-4 text-sm text-gray-500">Teams group your KPIs and projects on bowler charts e.g. "Website Development" or "Integrations".</p>
@ -19,9 +19,9 @@
<div class="flex-1">
<label for="name" class="mb-1 block font-semibold">New team</label>
<input id="name" type="text" name="name" value="{{ old('name') }}" required placeholder="e.g. Website Development"
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/40">
class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-secondary focus:outline-none focus:ring-2 focus:ring-secondary/40">
</div>
<button type="submit" class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700">Add team</button>
<button type="submit" class="cursor-pointer rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover">Add team</button>
</form>
@error('name')
<p class="mt-1 text-sm text-red-700">{{ $message }}</p>

View File

@ -10,6 +10,7 @@ use App\Http\Controllers\KpiEntryController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectEntryController;
use App\Http\Controllers\TeamController;
use App\Http\Controllers\ThemeController;
use App\Http\Controllers\UserAdminController;
use Illuminate\Support\Facades\Route;
@ -110,4 +111,8 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::post('/groups/{group}/members', [GroupAdminController::class, 'addMember'])->name('groups.members.add');
Route::put('/groups/{group}/members/{user}/role', [GroupAdminController::class, 'updateMemberRole'])->name('groups.members.role');
Route::delete('/groups/{group}/members/{user}', [GroupAdminController::class, 'removeMember'])->name('groups.members.remove');
Route::get('/theme', [ThemeController::class, 'edit'])->name('theme.edit');
Route::put('/theme', [ThemeController::class, 'update'])->name('theme.update');
Route::post('/theme/reset', [ThemeController::class, 'reset'])->name('theme.reset');
});