added theme support
This commit is contained in:
@@ -1,86 +1,473 @@
|
||||
@extends('stylist::layouts.app')
|
||||
@extends('layouts.app', ['title' => 'Stylist — SonarDesk Administration'])
|
||||
|
||||
@section('content')
|
||||
<header class="stylist-page-header">
|
||||
<span class="stylist-eyebrow">Administration</span>
|
||||
<h1>Stylist</h1>
|
||||
<p>Install, configure, validate and export self-contained themes.</p>
|
||||
</header>
|
||||
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-red-800">{{ $errors->first() }}</div>
|
||||
<div class="stylist-admin">
|
||||
|
||||
<section class="portal-header stylist-header">
|
||||
<div>
|
||||
<span class="eyebrow">Administration / Appearance</span>
|
||||
<h1>Stylist</h1>
|
||||
<p class="lead compact">
|
||||
Manage frontend and administration themes independently.
|
||||
Install, configure, preview and export complete themes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ route('admin.index') }}" class="btn btn-secondary">
|
||||
Back to administration
|
||||
</a>
|
||||
</section>
|
||||
|
||||
@if(session('status'))
|
||||
<div class="alert">{{ session('status') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('stylist.install') }}" enctype="multipart/form-data" class="mb-8 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
@csrf
|
||||
<label class="min-w-64 flex-1"><span class="mb-2 block text-sm font-medium text-slate-700">Install theme ZIP</span><input type="file" name="theme" accept=".zip" required class="block w-full rounded-lg border border-slate-300 p-2"></label>
|
||||
<button class="rounded-lg bg-slate-900 px-4 py-2.5 font-semibold text-white">Install theme</button>
|
||||
</form>
|
||||
@if($errors->any())
|
||||
<div class="error">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<section class="stylist-summary">
|
||||
<div class="stylist-summary-card">
|
||||
<span>Installed themes</span>
|
||||
<strong>{{ count($themes) }}</strong>
|
||||
<small>Available designs</small>
|
||||
</div>
|
||||
|
||||
<div class="stylist-summary-card">
|
||||
<span>Frontend theme</span>
|
||||
<strong>{{ $activeFrontendTheme }}</strong>
|
||||
<small>Public website</small>
|
||||
</div>
|
||||
|
||||
<div class="stylist-summary-card">
|
||||
<span>Backend theme</span>
|
||||
<strong>{{ $activeBackendTheme }}</strong>
|
||||
<small>Administration interface</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stylist-install-panel">
|
||||
<div class="stylist-section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Theme library</span>
|
||||
<h2>Install a theme</h2>
|
||||
<p>Upload a self-contained theme package in ZIP format.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('stylist.install') }}"
|
||||
enctype="multipart/form-data"
|
||||
class="stylist-install-form">
|
||||
|
||||
@csrf
|
||||
|
||||
<label class="stylist-upload-field">
|
||||
<span>Theme package</span>
|
||||
<input type="file"
|
||||
name="theme"
|
||||
accept=".zip"
|
||||
required>
|
||||
<small>Maximum upload size: 50 MB</small>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Install theme
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="stylist-library">
|
||||
|
||||
<div class="stylist-section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Appearance</span>
|
||||
<h2>Installed themes</h2>
|
||||
<p>Frontend and backend themes can be activated independently.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stylist-theme-grid">
|
||||
|
||||
@foreach($themes as $item)
|
||||
|
||||
@php
|
||||
$saved = $settingsService->get($item);
|
||||
$variant = $settingsService->variant($item);
|
||||
$appearance = $settingsService->appearance($item);
|
||||
$accent = $settingsService->accent($item);
|
||||
$issues = $accessibility->validate($item);
|
||||
|
||||
$isActive =
|
||||
($item->type() === 'frontend' && $item->key() === $activeFrontendTheme)
|
||||
|| ($item->type() === 'backend' && $item->key() === $activeBackendTheme);
|
||||
|
||||
$isBundled = in_array(
|
||||
$item->key(),
|
||||
['sonardesk', 'sonardesk-admin'],
|
||||
true
|
||||
);
|
||||
|
||||
$themeRoot = realpath(config('stylist.themes_path'));
|
||||
$themePath = realpath($item->path());
|
||||
|
||||
$isInstalled = $themeRoot
|
||||
&& $themePath
|
||||
&& str_starts_with(
|
||||
$themePath,
|
||||
rtrim($themeRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR
|
||||
);
|
||||
@endphp
|
||||
|
||||
<article class="stylist-theme-card {{ $isActive ? 'stylist-theme-active' : '' }}">
|
||||
|
||||
<div class="stylist-preview">
|
||||
|
||||
@if($item->preview() && $item->has($item->preview()))
|
||||
|
||||
<img
|
||||
src="{{ $urls->forTheme($item, $item->preview()) }}"
|
||||
alt="{{ $item->name() }} preview">
|
||||
|
||||
@else
|
||||
|
||||
<div class="stylist-preview-placeholder">
|
||||
<span class="stylist-preview-symbol">◈</span>
|
||||
<strong>{{ $item->name() }}</strong>
|
||||
<small>No preview image available</small>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
@foreach ($themes as $item)
|
||||
@php($saved = $settingsService->get($item))
|
||||
@php($variant = $settingsService->variant($item))
|
||||
@php($issues = $accessibility->validate($item))
|
||||
@php($appearance = $settingsService->appearance($item))
|
||||
@php($accent = $settingsService->accent($item))
|
||||
<article class="overflow-hidden rounded-2xl border {{ (($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme)) ? 'border-blue-400 ring-2 ring-blue-100' : 'border-slate-200' }} bg-white shadow-sm">
|
||||
<div class="aspect-[16/7] bg-slate-100">
|
||||
@if ($item->preview() && $item->has($item->preview()))
|
||||
<img src="{{ $urls->forTheme($item, $item->preview()) }}" alt="{{ $item->name() }} theme preview" class="h-full w-full object-cover">
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-sm text-slate-500">Add preview.webp to this theme</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-start justify-between gap-4"><div><h2 class="text-xl font-bold text-slate-950">{{ $item->name() }}</h2><p class="text-sm text-slate-500">v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif</p></div>@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))<span class="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">Active {{ ucfirst($item->type()) }}</span>@endif</div>
|
||||
@if($item->description())<p class="mt-3 text-sm text-slate-600">{{ $item->description() }}</p>@endif
|
||||
@if($item->parent())<p class="mt-2 text-xs font-medium text-violet-700">Child of {{ $item->parent() }}</p>@endif
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if(in_array($item->type(), ['frontend', 'backend'], true))
|
||||
<form method="POST" action="{{ route('stylist.activate', $item->key()) }}">@csrf<button class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white">Activate {{ ucfirst($item->type()) }}</button></form>
|
||||
<a href="{{ route('stylist.preview', $item->key()) }}" target="_blank" rel="noopener" class="rounded-lg border border-blue-300 px-3 py-2 text-sm font-medium text-blue-700">Preview</a>
|
||||
@endif
|
||||
<a href="{{ route('stylist.export', $item->key()) }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Export ZIP</a>
|
||||
<form method="POST" action="{{ route('stylist.optimize', $item->key()) }}">@csrf<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Optimise assets</button></form>
|
||||
@if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__'))
|
||||
<form method="POST" action="{{ route('stylist.uninstall', $item->key()) }}" onsubmit="return confirm('Uninstall this theme?')">@csrf @method('DELETE')<button class="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700">Uninstall</button></form>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<details class="mt-5 rounded-xl border border-slate-200 p-4">
|
||||
<summary class="cursor-pointer font-semibold">Theme settings</summary>
|
||||
<form method="POST" action="{{ route('stylist.settings', $item->key()) }}" class="mt-4 space-y-4">@csrf @method('PUT')
|
||||
@if($item->variants())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Variant</span><select name="variant" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->variants()) as $name)<option value="{{ $name }}" @selected($variant === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
|
||||
<div class="stylist-theme-body">
|
||||
|
||||
<div class="stylist-theme-heading">
|
||||
|
||||
<div>
|
||||
<span class="stylist-theme-type">
|
||||
{{ ucfirst($item->type()) }} theme
|
||||
</span>
|
||||
|
||||
<h3>{{ $item->name() }}</h3>
|
||||
|
||||
<p class="stylist-version">
|
||||
Version {{ $item->version() }}
|
||||
@if($item->author())
|
||||
· {{ $item->author() }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if($isActive)
|
||||
<span class="status-chip success">
|
||||
Active
|
||||
</span>
|
||||
@endif
|
||||
@if($item->supports('dark_mode') || $item->appearances())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Appearance</span><select name="appearance" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)<option value="{{ $name }}" @selected($appearance === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
|
||||
</div>
|
||||
|
||||
@if($item->description())
|
||||
<p class="stylist-description">
|
||||
{{ $item->description() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if($item->parent())
|
||||
<p class="stylist-parent">
|
||||
Based on {{ $item->parent() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="stylist-actions">
|
||||
|
||||
@if(in_array($item->type(), ['frontend', 'backend'], true))
|
||||
|
||||
@unless($isActive)
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('stylist.activate', $item->key()) }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Activate {{ ucfirst($item->type()) }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@endunless
|
||||
|
||||
<a class="btn btn-secondary"
|
||||
href="{{ route('stylist.preview', $item->key()) }}"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
Preview
|
||||
</a>
|
||||
|
||||
@endif
|
||||
@if($item->accents())
|
||||
<div><span class="mb-2 block text-sm font-medium">Accent colour</span><div class="flex flex-wrap gap-2">@foreach($item->accents() as $name => $definition)<label class="cursor-pointer"><input type="radio" name="accent" value="{{ $name }}" class="peer sr-only" @checked($accent === $name)><span class="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-100"><span class="h-4 w-4 rounded-full border border-black/10" style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}"></span>{{ data_get($definition, 'label', ucfirst($name)) }}</span></label>@endforeach</div></div>
|
||||
|
||||
<a class="btn btn-secondary"
|
||||
href="{{ route('stylist.export', $item->key()) }}">
|
||||
Export ZIP
|
||||
</a>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('stylist.optimize', $item->key()) }}">
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
Optimise assets
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<details class="stylist-settings">
|
||||
|
||||
<summary>
|
||||
<span>Theme configuration</span>
|
||||
<span class="stylist-chevron">⌄</span>
|
||||
</summary>
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('stylist.settings', $item->key()) }}"
|
||||
class="stylist-settings-form">
|
||||
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
@if($item->variants())
|
||||
|
||||
<label>
|
||||
Variant
|
||||
|
||||
<select name="variant">
|
||||
@foreach(array_keys($item->variants()) as $name)
|
||||
<option value="{{ $name }}"
|
||||
@selected($variant === $name)>
|
||||
{{ ucfirst($name) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@endif
|
||||
|
||||
@if($item->supports('dark_mode') || $item->appearances())
|
||||
|
||||
<label>
|
||||
Appearance
|
||||
|
||||
<select name="appearance">
|
||||
@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)
|
||||
<option value="{{ $name }}"
|
||||
@selected($appearance === $name)>
|
||||
{{ ucfirst($name) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@endif
|
||||
|
||||
@if($item->accents())
|
||||
|
||||
<fieldset class="stylist-accent-options">
|
||||
<legend>Accent colour</legend>
|
||||
|
||||
@foreach($item->accents() as $name => $definition)
|
||||
|
||||
<label class="stylist-accent-option">
|
||||
|
||||
<input type="radio"
|
||||
name="accent"
|
||||
value="{{ $name }}"
|
||||
@checked($accent === $name)>
|
||||
|
||||
<span class="stylist-colour-swatch"
|
||||
style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}">
|
||||
</span>
|
||||
|
||||
{{ data_get($definition, 'label', ucfirst($name)) }}
|
||||
|
||||
</label>
|
||||
|
||||
@endforeach
|
||||
|
||||
</fieldset>
|
||||
|
||||
@endif
|
||||
|
||||
@foreach($item->settingsSchema() as $key => $definition)
|
||||
|
||||
@php
|
||||
$definition = is_array($definition)
|
||||
? $definition
|
||||
: ['default' => $definition];
|
||||
|
||||
$fieldType = $definition['type'] ?? 'text';
|
||||
$fieldLabel = $definition['label'] ?? str($key)->headline();
|
||||
$fieldValue = $saved[$key] ?? ($definition['default'] ?? '');
|
||||
@endphp
|
||||
|
||||
<div class="stylist-setting-field">
|
||||
|
||||
<label for="stylist-{{ $item->key() }}-{{ $key }}">
|
||||
{{ $fieldLabel }}
|
||||
</label>
|
||||
|
||||
@switch($fieldType)
|
||||
|
||||
@case('color')
|
||||
|
||||
<input
|
||||
id="stylist-{{ $item->key() }}-{{ $key }}"
|
||||
type="color"
|
||||
name="settings[{{ $key }}]"
|
||||
value="{{ $fieldValue }}">
|
||||
@break
|
||||
|
||||
@case('textarea')
|
||||
|
||||
<textarea
|
||||
id="stylist-{{ $item->key() }}-{{ $key }}"
|
||||
name="settings[{{ $key }}]"
|
||||
rows="4">{{ $fieldValue }}</textarea>
|
||||
@break
|
||||
|
||||
@case('checkbox')
|
||||
|
||||
<input type="hidden"
|
||||
name="settings[{{ $key }}]"
|
||||
value="0">
|
||||
|
||||
<label class="stylist-checkbox">
|
||||
<input
|
||||
id="stylist-{{ $item->key() }}-{{ $key }}"
|
||||
type="checkbox"
|
||||
name="settings[{{ $key }}]"
|
||||
value="1"
|
||||
@checked((bool)$fieldValue)>
|
||||
Enabled
|
||||
</label>
|
||||
@break
|
||||
|
||||
@default
|
||||
|
||||
<input
|
||||
id="stylist-{{ $item->key() }}-{{ $key }}"
|
||||
type="{{ $fieldType === 'url' ? 'url' : 'text' }}"
|
||||
name="settings[{{ $key }}]"
|
||||
value="{{ $fieldValue }}">
|
||||
|
||||
@endswitch
|
||||
|
||||
</div>
|
||||
|
||||
@endforeach
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save configuration
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</details>
|
||||
|
||||
<div class="stylist-theme-footer">
|
||||
|
||||
<span class="{{ collect($issues)->contains('severity', 'error') ? 'bad' : 'ok' }}">
|
||||
Accessibility:
|
||||
{{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}
|
||||
</span>
|
||||
|
||||
@if($isInstalled && !$isBundled && !$isActive)
|
||||
|
||||
<form method="POST"
|
||||
action="{{ route('stylist.uninstall', $item->key()) }}"
|
||||
onsubmit="return confirm('Permanently uninstall this theme?')">
|
||||
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
<button type="submit" class="stylist-remove">
|
||||
Uninstall
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
@endif
|
||||
@foreach($item->settingsSchema() as $key => $definition)
|
||||
@php($definition = is_array($definition) ? $definition : ['default' => $definition])
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">{{ $definition['label'] ?? str($key)->headline() }}</span>
|
||||
@switch($definition['type'] ?? 'text')
|
||||
@case('color')<input type="color" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '#000000') }}" class="h-10 w-full rounded-lg border-slate-300">@break
|
||||
@case('textarea')<textarea name="settings[{{ $key }}]" rows="4" class="w-full rounded-lg border-slate-300">{{ $saved[$key] ?? ($definition['default'] ?? '') }}</textarea>@break
|
||||
@case('checkbox')<input type="hidden" name="settings[{{ $key }}]" value="0"><label class="inline-flex items-center gap-2"><input type="checkbox" name="settings[{{ $key }}]" value="1" @checked((bool)($saved[$key] ?? ($definition['default'] ?? false))) class="rounded border-slate-300"><span>Enabled</span></label>@break
|
||||
@default<input type="{{ ($definition['type'] ?? 'text') === 'url' ? 'url' : 'text' }}" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '') }}" class="w-full rounded-lg border-slate-300">
|
||||
@endswitch
|
||||
</label>
|
||||
@endforeach
|
||||
<button class="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white">Save settings</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="mt-4 text-sm {{ collect($issues)->contains('severity', 'error') ? 'text-red-700' : 'text-emerald-700' }}">Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</article>
|
||||
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stylist-admin{max-width:1220px;margin:auto;padding-bottom:60px}
|
||||
.stylist-header{margin-bottom:30px}
|
||||
.stylist-header .lead{max-width:740px}
|
||||
.stylist-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:28px}
|
||||
.stylist-summary-card{background:#fff;border:1px solid #e4e7ec;border-radius:15px;padding:22px;box-shadow:0 2px 6px rgba(15,23,42,.04)}
|
||||
.stylist-summary-card span,.stylist-summary-card small{display:block;color:#667085;font-size:12px}
|
||||
.stylist-summary-card strong{display:block;margin:8px 0;font-size:24px;color:#101828;overflow-wrap:anywhere}
|
||||
.stylist-install-panel{background:#fff;border:1px solid #e4e7ec;border-radius:16px;padding:26px;margin-bottom:35px;box-shadow:0 2px 8px rgba(15,23,42,.04)}
|
||||
.stylist-section-heading{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:22px}
|
||||
.stylist-section-heading h2{font-size:23px;margin:5px 0}
|
||||
.stylist-section-heading p{margin:0;color:#667085}
|
||||
.stylist-install-form{display:flex;gap:20px;align-items:end}
|
||||
.stylist-upload-field{display:block;flex:1;font-size:13px;font-weight:750}
|
||||
.stylist-upload-field span,.stylist-upload-field small{display:block}
|
||||
.stylist-upload-field small{color:#667085;font-weight:400}
|
||||
.stylist-upload-field input{margin:8px 0}
|
||||
.stylist-install-form button{margin-bottom:25px}
|
||||
.stylist-theme-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}
|
||||
.stylist-theme-card{background:#fff;border:1px solid #e4e7ec;border-radius:18px;overflow:hidden;box-shadow:0 5px 22px rgba(15,23,42,.05);min-width:0}
|
||||
.stylist-theme-active{border-color:#93c5fd;box-shadow:0 0 0 3px #dbeafe}
|
||||
.stylist-preview{height:210px;background:#f1f5f9;border-bottom:1px solid #e4e7ec;overflow:hidden}
|
||||
.stylist-preview img{width:100%;height:100%;object-fit:cover}
|
||||
.stylist-preview-placeholder{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#101828,#1d3557);color:#fff;gap:5px}
|
||||
.stylist-preview-symbol{font-size:40px;color:#93c5fd}
|
||||
.stylist-preview-placeholder strong{font-size:18px}
|
||||
.stylist-preview-placeholder small{font-size:12px;color:#94a3b8}
|
||||
.stylist-theme-body{padding:25px}
|
||||
.stylist-theme-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
||||
.stylist-theme-type{text-transform:uppercase;letter-spacing:.1em;color:#2563eb;font-size:10px;font-weight:850}
|
||||
.stylist-theme-heading h3{font-size:22px;letter-spacing:-.025em;margin:5px 0}
|
||||
.stylist-version{color:#667085;font-size:12px;margin:0}
|
||||
.stylist-description{color:#475467;font-size:14px;line-height:1.6;margin:18px 0}
|
||||
.stylist-parent{color:#7c3aed;font-size:12px}
|
||||
.stylist-actions{display:flex;gap:9px;flex-wrap:wrap;margin:22px 0}
|
||||
.stylist-actions form{margin:0}
|
||||
.stylist-actions .btn{font-size:12px;padding:9px 12px}
|
||||
.stylist-settings{border:1px solid #e4e7ec;border-radius:12px;margin-top:20px;overflow:hidden}
|
||||
.stylist-settings summary{display:flex;justify-content:space-between;align-items:center;padding:15px 17px;cursor:pointer;font-weight:750;list-style:none}
|
||||
.stylist-settings summary::-webkit-details-marker{display:none}
|
||||
.stylist-settings[open] summary{border-bottom:1px solid #e4e7ec}
|
||||
.stylist-chevron{font-size:20px;color:#667085}
|
||||
.stylist-settings-form{padding:20px;display:grid;gap:14px}
|
||||
.stylist-settings-form label{font-size:13px}
|
||||
.stylist-settings-form input:not([type=radio]):not([type=checkbox]),.stylist-settings-form select,.stylist-settings-form textarea{width:100%;margin:6px 0 0}
|
||||
.stylist-settings-form input[type=checkbox],.stylist-settings-form input[type=radio]{width:auto;margin:0}
|
||||
.stylist-accent-options{border:1px solid #e4e7ec;border-radius:10px;padding:14px;display:flex;flex-wrap:wrap;gap:12px}
|
||||
.stylist-accent-options legend{font-size:13px;font-weight:750}
|
||||
.stylist-accent-option{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
||||
.stylist-colour-swatch{width:17px;height:17px;border-radius:50%;border:1px solid rgba(0,0,0,.12)}
|
||||
.stylist-checkbox{display:flex;align-items:center;gap:9px}
|
||||
.stylist-theme-footer{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid #eef2f6;padding-top:17px;margin-top:20px;font-size:12px}
|
||||
.stylist-remove{background:transparent!important;color:#b42318!important;padding:5px 0!important;font-size:12px!important;box-shadow:none!important}
|
||||
.stylist-remove:hover{text-decoration:underline}
|
||||
@media(max-width:850px){.stylist-theme-grid{grid-template-columns:1fr}.stylist-summary{grid-template-columns:1fr}.stylist-install-form{flex-direction:column;align-items:stretch}.stylist-install-form button{margin:0}}
|
||||
</style>
|
||||
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
@extends('stylist::layouts.app')
|
||||
|
||||
@section('content')
|
||||
<header class="stylist-page-header">
|
||||
<span class="stylist-eyebrow">Administration</span>
|
||||
<h1>Stylist</h1>
|
||||
<p>Install, configure, validate and export self-contained themes.</p>
|
||||
</header>
|
||||
|
||||
|
||||
@if (session('status'))
|
||||
<div class="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-emerald-800">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-red-800">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('stylist.install') }}" enctype="multipart/form-data" class="mb-8 flex flex-wrap items-end gap-3 rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
@csrf
|
||||
<label class="min-w-64 flex-1"><span class="mb-2 block text-sm font-medium text-slate-700">Install theme ZIP</span><input type="file" name="theme" accept=".zip" required class="block w-full rounded-lg border border-slate-300 p-2"></label>
|
||||
<button class="rounded-lg bg-slate-900 px-4 py-2.5 font-semibold text-white">Install theme</button>
|
||||
</form>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-2">
|
||||
@foreach ($themes as $item)
|
||||
@php($saved = $settingsService->get($item))
|
||||
@php($variant = $settingsService->variant($item))
|
||||
@php($issues = $accessibility->validate($item))
|
||||
@php($appearance = $settingsService->appearance($item))
|
||||
@php($accent = $settingsService->accent($item))
|
||||
<article class="overflow-hidden rounded-2xl border {{ (($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme)) ? 'border-blue-400 ring-2 ring-blue-100' : 'border-slate-200' }} bg-white shadow-sm">
|
||||
<div class="aspect-[16/7] bg-slate-100">
|
||||
@if ($item->preview() && $item->has($item->preview()))
|
||||
<img src="{{ $urls->forTheme($item, $item->preview()) }}" alt="{{ $item->name() }} theme preview" class="h-full w-full object-cover">
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-sm text-slate-500">Add preview.webp to this theme</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="flex items-start justify-between gap-4"><div><h2 class="text-xl font-bold text-slate-950">{{ $item->name() }}</h2><p class="text-sm text-slate-500">v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif</p></div>@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))<span class="rounded-full bg-blue-100 px-3 py-1 text-xs font-semibold text-blue-700">Active {{ ucfirst($item->type()) }}</span>@endif</div>
|
||||
@if($item->description())<p class="mt-3 text-sm text-slate-600">{{ $item->description() }}</p>@endif
|
||||
@if($item->parent())<p class="mt-2 text-xs font-medium text-violet-700">Child of {{ $item->parent() }}</p>@endif
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if(in_array($item->type(), ['frontend', 'backend'], true))
|
||||
<form method="POST" action="{{ route('stylist.activate', $item->key()) }}">@csrf<button class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-semibold text-white">Activate {{ ucfirst($item->type()) }}</button></form>
|
||||
<a href="{{ route('stylist.preview', $item->key()) }}" target="_blank" rel="noopener" class="rounded-lg border border-blue-300 px-3 py-2 text-sm font-medium text-blue-700">Preview</a>
|
||||
@endif
|
||||
<a href="{{ route('stylist.export', $item->key()) }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Export ZIP</a>
|
||||
<form method="POST" action="{{ route('stylist.optimize', $item->key()) }}">@csrf<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium">Optimise assets</button></form>
|
||||
@if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__'))
|
||||
<form method="POST" action="{{ route('stylist.uninstall', $item->key()) }}" onsubmit="return confirm('Uninstall this theme?')">@csrf @method('DELETE')<button class="rounded-lg border border-red-300 px-3 py-2 text-sm font-medium text-red-700">Uninstall</button></form>
|
||||
@endif
|
||||
</div>
|
||||
<details class="mt-5 rounded-xl border border-slate-200 p-4">
|
||||
<summary class="cursor-pointer font-semibold">Theme settings</summary>
|
||||
<form method="POST" action="{{ route('stylist.settings', $item->key()) }}" class="mt-4 space-y-4">@csrf @method('PUT')
|
||||
@if($item->variants())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Variant</span><select name="variant" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->variants()) as $name)<option value="{{ $name }}" @selected($variant === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
@endif
|
||||
@if($item->supports('dark_mode') || $item->appearances())
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">Appearance</span><select name="appearance" class="w-full rounded-lg border-slate-300">@foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name)<option value="{{ $name }}" @selected($appearance === $name)>{{ ucfirst($name) }}</option>@endforeach</select></label>
|
||||
@endif
|
||||
@if($item->accents())
|
||||
<div><span class="mb-2 block text-sm font-medium">Accent colour</span><div class="flex flex-wrap gap-2">@foreach($item->accents() as $name => $definition)<label class="cursor-pointer"><input type="radio" name="accent" value="{{ $name }}" class="peer sr-only" @checked($accent === $name)><span class="inline-flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2 text-sm peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-100"><span class="h-4 w-4 rounded-full border border-black/10" style="background: {{ data_get($definition, 'swatch', data_get($definition, 'variables.primary', '#64748b')) }}"></span>{{ data_get($definition, 'label', ucfirst($name)) }}</span></label>@endforeach</div></div>
|
||||
@endif
|
||||
@foreach($item->settingsSchema() as $key => $definition)
|
||||
@php($definition = is_array($definition) ? $definition : ['default' => $definition])
|
||||
<label class="block"><span class="mb-1 block text-sm font-medium">{{ $definition['label'] ?? str($key)->headline() }}</span>
|
||||
@switch($definition['type'] ?? 'text')
|
||||
@case('color')<input type="color" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '#000000') }}" class="h-10 w-full rounded-lg border-slate-300">@break
|
||||
@case('textarea')<textarea name="settings[{{ $key }}]" rows="4" class="w-full rounded-lg border-slate-300">{{ $saved[$key] ?? ($definition['default'] ?? '') }}</textarea>@break
|
||||
@case('checkbox')<input type="hidden" name="settings[{{ $key }}]" value="0"><label class="inline-flex items-center gap-2"><input type="checkbox" name="settings[{{ $key }}]" value="1" @checked((bool)($saved[$key] ?? ($definition['default'] ?? false))) class="rounded border-slate-300"><span>Enabled</span></label>@break
|
||||
@default<input type="{{ ($definition['type'] ?? 'text') === 'url' ? 'url' : 'text' }}" name="settings[{{ $key }}]" value="{{ $saved[$key] ?? ($definition['default'] ?? '') }}" class="w-full rounded-lg border-slate-300">
|
||||
@endswitch
|
||||
</label>
|
||||
@endforeach
|
||||
<button class="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white">Save settings</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="mt-4 text-sm {{ collect($issues)->contains('severity', 'error') ? 'text-red-700' : 'text-emerald-700' }}">Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -2,5 +2,5 @@
|
||||
@section('content')
|
||||
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
|
||||
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('stylist.index') }}">Manage themes</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
|
||||
@endsection
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
@section('content')
|
||||
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
|
||||
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
|
||||
@endsection
|
||||
@@ -403,6 +403,7 @@
|
||||
<a href="{{ route('admin.index') }}">Admin</a>
|
||||
<a href="{{ route('admin.users.index') }}">Users</a>
|
||||
<a href="{{ route('admin.releases.index') }}">Releases</a>
|
||||
<a href="{{ route('stylist.index') }}">Themes</a>
|
||||
<a href="{{ route('admin.download-products.index') }}">Download products</a>
|
||||
<a href="{{ route('admin.support.index') }}">
|
||||
Support
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user