diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7eb4dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development (starts PHP server + queue + logs + Vite concurrently) +composer dev + +# Build assets for production +npm run build + +# Vite dev server only +npm run dev + +# Run all tests +composer test +php artisan test + +# Run a single test file +php artisan test tests/Feature/ExampleTest.php + +# Run a single test by name +php artisan test --filter=TestName + +# Migrations +php artisan migrate +``` + +## Architecture + +### Design +- Tailwind CSS v4 +- Modals sind immer vom Livewire Modals +- Kein Inline-Style nur wenn wirklich notwendig. +- Immer prüfen das das Style nicht kaputt ist und wenn Icons in Buttons mit Text sind, sind diese immer nebeneinadner Nie übereinander. + +### What this is +Mailwolt is a self-hosted mail server administration panel (German UI). It manages domains, mailboxes, aliases, DNS records (DKIM/DMARC/SPF/TLSA), TLS, Fail2ban, IMAP, and email quarantine/queues. It also has a mail sandbox for testing Postfix transports. + +### Layout & Routing +- **`resources/views/layouts/dvx.blade.php`** is the actual app layout (not `app.blade.php`). Livewire full-page components use `#[Layout('layouts.dvx')]`. +- Routes are in `routes/web.php` — each page maps directly to a Livewire full-page component via `->name()`. +- Navigation structure is driven by `config/ui-menu.php`. +- Für den Style wird Tailwind CSS v4 + +### Livewire component structure +``` +app/Livewire/Ui/ +├── Nx/ — Current production UI (Dashboard, DomainList, MailboxList, AliasList, etc.) +├── Domain/ — Domain modals and DKIM/DNS views +├── Mail/ — Mailbox/alias modals, queue, quarantine +├── Security/ — Fail2ban, SSL, RSpamd, TLS, audit logs +├── System/ — Settings, users, API keys, webhooks, sandbox, backups +├── Search/ — Global search palette modal +└── Webmail/ — Webmail-specific components +``` + +Full-page components live in `Ui/Nx/` and `Ui/System/`, `Ui/Security/`, etc. Modals are always in a `Modal/` subfolder and extend `LivewireUI\Modal\ModalComponent`. + +### Modals (wire-elements/modal v2) +- Open from **outside** a Livewire component: `onclick="Livewire.dispatch('openModal', {component:'ui.system.modal.my-modal', arguments:{key:value}})"` +- Open from **inside** a Livewire component: `$this->dispatch('openModal', component: '...', arguments: [...])` +- **Never** use `wire:click="$dispatch('openModal',...)"` outside a Livewire component context — it won't work. +- Modal argument keys must match the `mount(int $keyName)` parameter names exactly. +- To prevent closing on backdrop/Escape, override in the modal class: + ```php + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + ``` +- To force-close the entire modal stack: `$this->forceClose()->closeModal()` + +### Livewire dependency injection +- **Never** use constructor injection in Livewire components — Livewire calls `new Component()` with no args. +- Use `boot(MyService $service)` instead: this is called on every request and supports DI. + +### CSS design system +The app uses a custom `mw-*` variable and class system defined in `resources/css/app.css` (Tailwind CSS v4, no `tailwind.config.js`): + +**CSS variables:** +- `--mw-bg`, `--mw-bg3`, `--mw-bg4` — background layers +- `--mw-b1`, `--mw-b2`, `--mw-b3` — border shades +- `--mw-t1` through `--mw-t5` — text shades (t1 = primary, t4/t5 = muted) +- `--mw-v`, `--mw-v2`, `--mw-vbg` — purple accent (primary brand color) +- `--mw-gr` — green (success) + +**Reusable component classes:** `.mw-btn-primary`, `.mw-btn-secondary`, `.mw-btn-cancel`, `.mw-btn-save`, `.mw-btn-del`, `.mbx-act-btn`, `.mbx-act-danger`, `.mw-modal-frame`, `.mw-modal-head`, `.mw-modal-body`, `.mw-modal-foot`, `.mw-modal-label`, `.mw-modal-input`, `.mw-modal-error`, `.mbx-badge-mute`, `.mbx-badge-ok`, `.mbx-badge-warn`. + +### Services +`app/Services/` contains: `DkimService`, `DnsRecordService`, `ImapService`, `MailStorage`, `SandboxMailParser`, `SandboxService`, `TlsaService`, `TotpService`, `WebhookService`. + +### API +REST API under `/api/v1/` uses Laravel Sanctum. Token abilities map to scopes like `mailboxes:read`, `domains:write`, etc. Defined in `routes/api.php`. + +### Sandbox mail system +The mail sandbox intercepts Postfix mail via a pipe transport (`php artisan sandbox:receive`). `SandboxRoute` model controls which domains/addresses are intercepted. `SandboxService::syncTransportFile()` writes `/etc/postfix/transport.sandbox` and runs `postmap`. + +### Queue & real-time +- Queue driver: database (configurable). Jobs in `app/Jobs/`. +- Real-time updates use Laravel Reverb + Pusher.js. Livewire polls (`wire:poll.5s`) are used as fallback on some pages. diff --git a/app/Livewire/Ui/Nx/Dashboard.php b/app/Livewire/Ui/Nx/Dashboard.php index 19701c1..e3ca906 100644 --- a/app/Livewire/Ui/Nx/Dashboard.php +++ b/app/Livewire/Ui/Nx/Dashboard.php @@ -6,6 +6,7 @@ use App\Models\BackupJob; use App\Models\BackupPolicy; use App\Models\Domain; use App\Models\MailUser; +use App\Models\SandboxRoute; use App\Models\Setting as SettingModel; use App\Support\CacheVer; use Illuminate\Support\Facades\Cache; @@ -43,7 +44,8 @@ class Dashboard extends Component 'mailboxCount' => MailUser::where('is_system', false)->where('is_active', true)->count(), 'servicesActive' => $servicesActive, 'servicesTotal' => count($services), - 'alertCount' => 0, + 'alertCount' => SandboxRoute::where('is_active', true)->count(), + 'sandboxAlerts' => SandboxRoute::activeRoutes(), 'backup' => $this->backupData(), 'mailHostname' => gethostname() ?: 'mailserver', 'services' => $services, diff --git a/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php index 9714d26..7e17212 100644 --- a/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php +++ b/app/Livewire/Ui/Search/Modal/SearchPaletteModal.php @@ -126,21 +126,20 @@ class SearchPaletteModal extends ModalComponent public function go(string $type, int $id): void { - // Schließe die Palette … - $this->dispatch('closeModal'); - - // … und navigiere / öffne Kontext: - // - Domain → scrolle/markiere Domainkarte - // - Mailbox → öffne Bearbeiten-Modal - // Passe an, was du bevorzugst: if ($type === 'domain') { - $this->dispatch('focus:domain', id: $id); + $component = 'ui.domain.modal.domain-edit-modal'; + $arguments = ['domainId' => $id]; } elseif ($type === 'mailbox') { - // direkt Edit-Modal auf - $this->dispatch('openModal', component:'ui.mail.modal.mailbox-edit-modal', arguments: [$id]); - } elseif ($type === 'user') { - $this->dispatch('focus:user', id: $id); + $component = 'ui.mail.modal.mailbox-edit-modal'; + $arguments = ['mailboxId' => $id]; + } else { + return; } + + // Search palette schließen, dann nach dem State-Reset (300 ms) das Ziel-Modal öffnen + $this->forceClose()->closeModal(); + $payload = json_encode(['component' => $component, 'arguments' => $arguments]); + $this->js("setTimeout(()=>Livewire.dispatch('openModal',{$payload}),350)"); } public static function modalMaxWidth(): string diff --git a/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php index a8ffe0c..2246e38 100644 --- a/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php +++ b/app/Livewire/Ui/System/Modal/ApiKeyCreateModal.php @@ -20,21 +20,22 @@ class ApiKeyCreateModal extends ModalComponent 'domains:write' => 'Domains schreiben', ]; + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + public function create(): void { $this->validate([ - 'name' => 'required|string|max:80', - 'selected' => 'required|array|min:1', + 'name' => 'required|string|max:80', + 'selected' => 'required|array|min:1', 'selected.*' => 'in:' . implode(',', array_keys(self::$availableScopes)), ], [ 'selected.required' => 'Bitte mindestens einen Scope auswählen.', 'selected.min' => 'Bitte mindestens einen Scope auswählen.', ]); - $token = Auth::user()->createToken( - $this->name, - $this->selected, - ); + $token = Auth::user()->createToken($this->name, $this->selected); $pat = $token->accessToken; if ($this->sandbox) { @@ -43,7 +44,10 @@ class ApiKeyCreateModal extends ModalComponent } $this->dispatch('token-created', plainText: $token->plainTextToken); - $this->closeModal(); + $this->dispatch('openModal', + component: 'ui.system.modal.api-key-show-modal', + arguments: ['plainText' => $token->plainTextToken], + ); } public function toggleAll(): void diff --git a/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php b/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php new file mode 100644 index 0000000..c834737 --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyDeleteModal.php @@ -0,0 +1,42 @@ +where('tokenable_type', Auth::user()::class) + ->findOrFail($tokenId); + + $this->tokenId = $tokenId; + $this->tokenName = $token->name; + } + + public function delete(): void + { + PersonalAccessToken::where('tokenable_id', Auth::id()) + ->where('tokenable_type', Auth::user()::class) + ->findOrFail($this->tokenId) + ->delete(); + + $this->dispatch('toast', type: 'done', badge: 'API Key', + title: 'Gelöscht', text: "Key {$this->tokenName} wurde entfernt.", duration: 4000); + + $this->dispatch('token-deleted'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-delete-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php b/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php new file mode 100644 index 0000000..07bfa5e --- /dev/null +++ b/app/Livewire/Ui/System/Modal/ApiKeyScopesModal.php @@ -0,0 +1,28 @@ +where('tokenable_type', Auth::user()::class) + ->findOrFail($tokenId); + + $this->tokenName = $token->name; + $this->scopes = $token->abilities; + } + + public function render() + { + return view('livewire.ui.system.modal.api-key-scopes-modal'); + } +} diff --git a/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php index af5e208..76b155b 100644 --- a/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php +++ b/app/Livewire/Ui/System/Modal/ApiKeyShowModal.php @@ -13,9 +13,18 @@ class ApiKeyShowModal extends ModalComponent $this->plainText = $plainText; } + public static function closeModalOnClickAway(): bool { return false; } + public static function closeModalOnEscape(): bool { return false; } + public static function closeModalOnEscapeIsForceful(): bool { return false; } + + public function dismiss(): void + { + $this->forceClose()->closeModal(); + } + public static function modalMaxWidth(): string { - return 'md'; + return '2xl'; } public function render() diff --git a/app/Livewire/Ui/System/SandboxRoutes.php b/app/Livewire/Ui/System/SandboxRoutes.php new file mode 100644 index 0000000..c71d6fd --- /dev/null +++ b/app/Livewire/Ui/System/SandboxRoutes.php @@ -0,0 +1,68 @@ +sandboxService = $sandboxService; + } + + public function addRoute(): void + { + if ($this->newType !== 'global') { + $this->validate(['newTarget' => 'required|string|max:255']); + } + + $target = $this->newType === 'global' ? null : trim($this->newTarget); + $this->sandboxService->enable($this->newType, $target); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + $this->newTarget = ''; + } + + public function removeRoute(int $id): void + { + $this->sandboxService->delete($id); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + } + + public function toggleRoute(int $id): void + { + $route = SandboxRoute::findOrFail($id); + $route->is_active = !$route->is_active; + $route->save(); + $this->sandboxService->syncTransportFile(); + $this->dispatch('sandbox-route-changed'); + } + + public function syncPostfix(): void + { + $result = $this->sandboxService->syncTransportFile(); + $this->syncOk = $result['ok']; + $this->syncMessage = $result['message']; + } + + #[Computed] + public function routes() + { + return SandboxRoute::orderBy('type')->orderBy('target')->get(); + } + + public function render() + { + return view('livewire.ui.system.sandbox-routes'); + } +} diff --git a/app/Models/SandboxRoute.php b/app/Models/SandboxRoute.php new file mode 100644 index 0000000..5b92664 --- /dev/null +++ b/app/Models/SandboxRoute.php @@ -0,0 +1,63 @@ + 'boolean', + ]; + + /** + * All active routes ordered by type and target. + */ + public static function activeRoutes(): Collection + { + return static::where('is_active', true) + ->orderBy('type') + ->orderBy('target') + ->get(); + } + + /** + * Check whether sandbox is active for a given domain. + * True if global sandbox is active OR domain-specific rule is active. + */ + public static function isActiveForDomain(string $domain): bool + { + return static::where('is_active', true) + ->where(function ($q) use ($domain) { + $q->where('type', 'global') + ->orWhere(function ($q2) use ($domain) { + $q2->where('type', 'domain')->where('target', $domain); + }); + }) + ->exists(); + } + + /** + * Check whether sandbox is active for a given address. + * True if global is active OR domain matches OR address-specific rule exists. + */ + public static function isActiveForAddress(string $address): bool + { + $domain = substr(strrchr($address, '@'), 1) ?: ''; + + return static::where('is_active', true) + ->where(function ($q) use ($address, $domain) { + $q->where('type', 'global') + ->orWhere(function ($q2) use ($domain) { + $q2->where('type', 'domain')->where('target', $domain); + }) + ->orWhere(function ($q3) use ($address) { + $q3->where('type', 'address')->where('target', $address); + }); + }) + ->exists(); + } +} diff --git a/app/Services/SandboxService.php b/app/Services/SandboxService.php new file mode 100644 index 0000000..f136e53 --- /dev/null +++ b/app/Services/SandboxService.php @@ -0,0 +1,88 @@ + $type, 'target' => $target], + ['is_active' => true] + ); + } + + /** + * Disable a sandbox route by id. + */ + public function disable(int $id): void + { + SandboxRoute::findOrFail($id)->update(['is_active' => false]); + } + + /** + * Delete a sandbox route by id. + */ + public function delete(int $id): void + { + SandboxRoute::findOrFail($id)->delete(); + } + + /** + * Write /etc/postfix/transport.sandbox and run postmap. + * Returns ['ok' => bool, 'message' => string]. + */ + public function syncTransportFile(): array + { + $file = config('sandbox.transport_file', '/etc/postfix/transport.sandbox'); + + $routes = SandboxRoute::where('is_active', true) + ->orderBy('type') + ->orderBy('target') + ->get(); + + $lines = []; + $hasGlobal = false; + + foreach ($routes as $route) { + if ($route->type === 'global') { + $hasGlobal = true; + } elseif ($route->type === 'domain') { + $lines[] = $route->target . ' sandbox:'; + } elseif ($route->type === 'address') { + $lines[] = $route->target . ' sandbox:'; + } + } + + // Global catch-all goes at the end + if ($hasGlobal) { + $lines[] = '* sandbox:'; + } + + $content = implode("\n", $lines); + if ($lines) { + $content .= "\n"; + } + + try { + file_put_contents($file, $content); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => 'Fehler beim Schreiben: ' . $e->getMessage()]; + } + + $out = []; + $code = 0; + exec('postmap ' . escapeshellarg($file) . ' 2>&1', $out, $code); + + if ($code !== 0) { + return ['ok' => false, 'message' => 'postmap Fehler: ' . implode(' ', $out)]; + } + + return ['ok' => true, 'message' => 'Transport-Datei synchronisiert (' . count($lines) . ' Einträge).']; + } +} diff --git a/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php b/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php new file mode 100644 index 0000000..8a33eff --- /dev/null +++ b/database/migrations/2026_04_23_000001_create_sandbox_routes_table.php @@ -0,0 +1,25 @@ +id(); + $table->enum('type', ['global', 'domain', 'address'])->default('domain'); + $table->string('target')->nullable(); // domain or email, null = global + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['type', 'target']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sandbox_routes'); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 20b1721..a99b10f 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -5,6 +5,7 @@ @import "../fonts/Space/font.css"; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../vendor/wire-elements/modal/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @source '../**/*.blade.php'; @source '../**/*.js'; @@ -99,6 +100,19 @@ .mw-divider { @apply border-t border-white/10 my-4; } + + /* ── Header utility classes ── */ + .btn-ghost { + @apply inline-flex items-center justify-center rounded-lg + text-white/60 hover:text-white hover:bg-white/5 + transition-colors duration-150 cursor-pointer; + padding: 0.4rem; + } + + .popup { + @apply rounded-xl border border-white/10 shadow-xl z-50 + bg-[#0f172a]/95 backdrop-blur-xl text-white/80; + } } /* ============ BOOT-STATE: keine Sprünge ============ */ @@ -1093,40 +1107,125 @@ select { /* ── Main ── */ .mw-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; } -/* ── Topbar ── */ +/* ════════════════════════════════════════ + Topbar — Redesign +════════════════════════════════════════ */ .mw-topbar { - height: 50px; padding: 0 22px; border-bottom: 1px solid var(--mw-b1); - background: var(--mw-bg); display: flex; align-items: center; - justify-content: space-between; flex-shrink: 0; + height: 54px; + padding: 0 20px; + border-bottom: 1px solid var(--mw-b1); + background: var(--mw-bg); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 8px; } -.mw-breadcrumb { display: flex; align-items: center; gap: 5px; font-size: 13px; color: var(--mw-t4); } -.mw-breadcrumb b { color: var(--mw-t2); font-weight: 500; } -.mw-topbar-right { display: flex; align-items: center; gap: 8px; } -.mw-live { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--mw-t3); } -.mw-live-dot { - width: 6px; height: 6px; border-radius: 50%; background: var(--mw-gr); - box-shadow: 0 0 5px rgba(34,197,94,.5); animation: mw-pulse 2s infinite; +/* ── Links ── */ +.mw-tb-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1; } -@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.3} } -.mw-search { - display: flex; align-items: center; gap: 6px; background: var(--mw-bg3); - border: 1px solid var(--mw-b1); border-radius: 6px; padding: 5px 10px; - font-size: 12px; color: var(--mw-t4); cursor: pointer; +.mw-hamburger { + display: none; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 32px; height: 32px; + border-radius: 8px; + background: none; + border: none; + color: var(--mw-t3); + cursor: pointer; + transition: background .15s, color .15s; } -.mw-search kbd { font-size: 9.5px; opacity: .5; margin-left: 2px; } -.mw-ip { +.mw-hamburger:hover { background: var(--mw-bg3); color: var(--mw-t1); } + +.mw-tb-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + min-width: 0; + overflow: hidden; +} +.mw-tb-parent { color: var(--mw-t4); white-space: nowrap; flex-shrink: 0; } +.mw-tb-sep { color: var(--mw-b3); flex-shrink: 0; } +.mw-tb-current { color: var(--mw-t2); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ── Rechts ── */ +.mw-tb-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* Live-Dot */ +.mw-tb-live { + display: flex; align-items: center; gap: 5px; + padding: 4px 9px; + border-radius: 99px; + background: rgba(34,197,94,.08); + border: 1px solid rgba(34,197,94,.18); + font-size: 11px; font-weight: 500; color: #4ade80; +} +.mw-tb-live-dot { + width: 6px; height: 6px; border-radius: 50%; + background: #22c55e; + box-shadow: 0 0 6px rgba(34,197,94,.7); + animation: mw-pulse 2s infinite; +} +@keyframes mw-pulse { 0%,100%{opacity:1} 50%{opacity:.35} } + +/* Suchbutton */ +.mw-tb-search { + display: flex; align-items: center; gap: 7px; + height: 32px; padding: 0 12px; + background: var(--mw-bg3); + border: 1px solid var(--mw-b2); + border-radius: 8px; + font-size: 12px; color: var(--mw-t4); + cursor: pointer; + transition: border-color .15s, color .15s; + white-space: nowrap; +} +.mw-tb-search:hover { border-color: var(--mw-b3); color: var(--mw-t2); } +.mw-tb-kbd { + display: inline-flex; align-items: center; justify-content: center; + padding: 2px 5px; + background: var(--mw-b1); border: 1px solid var(--mw-b2); + border-radius: 4px; font-size: 10px; color: var(--mw-t5); + font-family: ui-monospace, monospace; +} + +/* Hostname-Badge */ +.mw-tb-host { + display: flex; align-items: center; gap: 5px; + height: 32px; padding: 0 10px; + background: var(--mw-bg3); border: 1px solid var(--mw-b2); + border-radius: 8px; font-family: ui-monospace, monospace; font-size: 11px; color: var(--mw-t4); - background: var(--mw-bg3); border: 1px solid var(--mw-b1); border-radius: 5px; padding: 4px 9px; + white-space: nowrap; } -.mw-btn-primary { - display: inline-flex; align-items: center; gap: 5px; - background: var(--mw-v); border: none; border-radius: 6px; padding: 6px 14px; - font-size: 12.5px; font-weight: 600; color: #fff; cursor: pointer; - box-shadow: 0 0 10px rgba(124,58,237,.35); + +/* + Domain Button */ +.mw-tb-add { + display: flex; align-items: center; gap: 6px; + height: 32px; padding: 0 14px; + background: var(--mw-v); + border: none; border-radius: 8px; + font-size: 12.5px; font-weight: 600; color: #fff; + cursor: pointer; flex-shrink: 0; + box-shadow: 0 2px 12px rgba(124,58,237,.35); + transition: background .15s, box-shadow .15s; } -.mw-btn-primary:hover { background: #6d28d9; } +.mw-tb-add:hover { background: #6d28d9; box-shadow: 0 2px 16px rgba(124,58,237,.5); } /* ── Content ── */ .mw-content { flex: 1; overflow-y: auto; padding: 20px 22px 30px; } @@ -1346,13 +1445,16 @@ select { /* ── Responsive ── */ @media (max-width: 1024px) { .mw-content { padding: 16px 18px 24px; } - .mw-topbar { padding: 0 18px; } + .mw-topbar { padding: 0 16px; } } @media (max-width: 900px) { .mw-bottom-grid { grid-template-columns: 1fr; } .mw-right-col { flex-direction: row; } + /* Hostname auf Tablet kompakter */ + .mw-tb-host { font-size: 10.5px; padding: 0 8px; } } @media (max-width: 768px) { + /* Sidebar off-canvas */ .mw-sidebar { position: fixed; left: 0; top: 0; z-index: 50; transform: translateX(-100%); transition: transform .25s; @@ -1363,10 +1465,10 @@ select { background: rgba(0,0,0,.55); z-index: 40; } .mw-sidebar-overlay.open { display: block; } - .mw-hamburger { - display: flex !important; background: none; border: none; - color: var(--mw-t3); cursor: pointer; padding: 4px; margin-right: 4px; - } + /* Hamburger einblenden */ + .mw-hamburger { display: flex; } + /* Parent bleibt sichtbar (3 Buchstaben) */ + /* Layout */ .mw-metric-grid { grid-template-columns: repeat(2, 1fr); } .mw-status-grid { grid-template-columns: repeat(2, 1fr); } .mw-shell { display: block; height: 100vh; } @@ -1376,7 +1478,176 @@ select { @media (max-width: 480px) { .mw-metric-grid { grid-template-columns: 1fr; } .mw-status-grid { grid-template-columns: 1fr; } - .mw-ip { display: none; } + .mw-topbar { padding: 0 12px; gap: 5px; } + .mw-tb-parent { display: none; } + .mw-tb-sep { display: none; } + .mw-tb-search-text { display: none; } + .mw-tb-kbd { display: none; } + .mw-tb-search { padding: 0 9px; } + .mw-tb-add-text { display: none; } + .mw-tb-add { padding: 0 9px; } +} + +/* ── API Key Layout ── */ +.mw-apikey-layout { + display: grid; + grid-template-columns: 1fr 340px; + gap: 14px; + align-items: start; +} +.mw-apikey-layout > * { min-width: 0; } +@media (max-width: 900px) { + .mw-apikey-layout { grid-template-columns: 1fr; } +} +/* ── Responsive list helpers ── */ +@media (max-width: 640px) { + .mw-col-hide-sm { display: none !important; } + .mw-only-desktop { display: none !important; } +} +@media (min-width: 641px) { + .mw-only-mobile { display: none !important; } +} + +/* ── API Key div-list ── */ +.mw-kl-head, +.mw-kl-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 104px 80px 90px 40px; + gap: 8px; + padding: 8px 14px; + align-items: center; + min-width: 0; +} +.mw-kl-head { + font-size: 9.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--mw-t4); + border-bottom: 1px solid var(--mw-b1); +} +.mw-kl-row { + border-bottom: 1px solid var(--mw-b1); + transition: background .1s; +} +.mw-kl-row:last-child { border-bottom: none; } +.mw-kl-row:hover { background: rgba(255,255,255,.015); } +.mw-kl-row > * { min-width: 0; overflow: hidden; } +.mw-kl-date-inline { display: none; } +.mw-kl-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +@media (max-width: 900px) and (min-width: 641px) { + .mw-kl-head, + .mw-kl-row { grid-template-columns: minmax(0, 1fr) 120px 80px 40px; } + .mw-kl-date { display: none; } + .mw-kl-date-inline { display: inline; } +} + +@media (max-width: 640px) { + .mw-kl-head { display: none; } + .mw-kl-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 5px 8px; + padding: 10px 12px; + } + .mw-kl-name { grid-column: 1; grid-row: 1; } + .mw-kl-scopes { grid-column: 1; grid-row: 2; flex-wrap: wrap; overflow: visible; } + .mw-kl-modus { grid-column: 2; grid-row: 1; } + .mw-kl-date { display: none; } + .mw-kl-date-inline { display: inline; } + .mw-kl-actions { grid-column: 2; grid-row: 2; justify-self: end; } +} + +/* ── Webhook div-list ── */ +.mw-whl-head, +.mw-whl-row { + display: grid; + grid-template-columns: 1fr 124px 72px 54px 82px; + gap: 8px; + padding: 8px 14px; + align-items: center; + min-width: 0; +} +.mw-whl-head { + font-size: 9.5px; font-weight: 600; text-transform: uppercase; + letter-spacing: .5px; color: var(--mw-t4); + border-bottom: 1px solid var(--mw-b1); +} +.mw-whl-row { + border-bottom: 1px solid var(--mw-b1); + transition: background .1s; +} +.mw-whl-row:last-child { border-bottom: none; } +.mw-whl-row:hover { background: rgba(255,255,255,.015); } +.mw-whl-row > * { min-width: 0; } +.mw-whl-status-sm, .mw-whl-http-sm { display: none; } + +@media (max-width: 640px) { + .mw-whl-head { display: none; } + .mw-whl-row { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: 5px 8px; + padding: 10px 12px; + } + .mw-whl-webhook { grid-column: 1; grid-row: 1; } + .mw-whl-events { grid-column: 1; grid-row: 2; } + .mw-whl-status { display: none; } + .mw-whl-http { display: none; } + .mw-whl-date { display: none; } + .mw-whl-actions { + grid-column: 2; grid-row: 1 / 3; + display: flex; flex-direction: column; gap: 4px; align-self: start; + } + .mw-whl-status-sm, .mw-whl-http-sm { display: inline; } +} + +/* ── Webhook-Tabelle Layout ── */ +.mw-wh-layout { + display: grid; + grid-template-columns: 1fr 300px; + gap: 14px; + align-items: start; +} +.mw-wh-layout > * { min-width: 0; } +@media (max-width: 900px) { + .mw-wh-layout { grid-template-columns: 1fr; } +} + +/* ── Sandbox Layout ── */ +.mw-sandbox-client { + display: grid; + grid-template-columns: 300px 1fr; + gap: 0; + border: 1px solid var(--mw-b1); + border-radius: 10px; + overflow: hidden; + background: var(--mw-bg2); + min-height: 520px; +} +.mw-sandbox-list { + border-right: 1px solid var(--mw-b1); + overflow-y: auto; + max-height: 680px; +} +.mw-sandbox-detail { + overflow-y: auto; + max-height: 680px; + min-height: 400px; +} +.mw-postfix-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; + padding: 16px 18px; +} +@media (max-width: 900px) { + .mw-sandbox-client { grid-template-columns: 1fr; } + .mw-sandbox-list { border-right: none; border-bottom: 1px solid var(--mw-b1); max-height: 260px; } + .mw-sandbox-detail { max-height: none; } + .mw-postfix-grid { grid-template-columns: 1fr; } +} +@media (max-width: 640px) { + .mw-sandbox-client { min-height: 0; } + .mw-postfix-grid { grid-template-columns: 1fr; } } .mw-svc-status-off { color: var(--mw-rd) !important; opacity: 1; } @@ -1743,6 +2014,20 @@ textarea.mw-modal-input { height: auto; line-height: 1.55; padding: 8px 10px; } } /* Buttons */ +.mw-btn-primary { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 16px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; + background: var(--mw-v); border: 1px solid var(--mw-v); color: #fff; + box-shadow: 0 2px 8px rgba(124,58,237,.3); +} +.mw-btn-primary:hover { background: #6d28d9; border-color: #6d28d9; } +.mw-btn-secondary { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 14px; border-radius: 7px; font-size: 13px; font-weight: 500; cursor: pointer; + background: var(--mw-bg3); border: 1px solid var(--mw-b2); color: var(--mw-t2); + transition: background .15s, border-color .15s; +} +.mw-btn-secondary:hover { background: var(--mw-bg4); border-color: var(--mw-b3); color: var(--mw-t1); } .mw-btn-cancel { display: inline-flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: 7px; font-size: 13px; cursor: pointer; diff --git a/resources/views/components/partials/header.blade.php b/resources/views/components/partials/header.blade.php index 91db085..8603290 100644 --- a/resources/views/components/partials/header.blade.php +++ b/resources/views/components/partials/header.blade.php @@ -1,107 +1,119 @@ {{--resources/views/components/partials/header.blade.php--}} -