feat: add the admin layout shell

This commit is contained in:
fromme committed 2026-09-13 14:31:40 +02:00
1 parent da8af9ff08
commit ad5c93049c
21 files changed
+441 -15

No files matched your search

@@ -0,0 +1,22 @@
import { Controller } from '@hotwired/stimulus';
/*
* Off-canvas mobile navigation drawer. Ported from myep.
*
* Two instances cooperate through a Stimulus outlet: the opener button in the header and the
* drawer itself. Purely client-side -- htmx is not involved.
*/
export default class extends Controller {
static outlets = ['mobilenav'];
static classes = ['closed'];
trigger({ params: { mode } }) {
this.mobilenavOutlet.toggle(mode);
}
toggle(mode) {
// Added only now, so the drawer does not animate into place on first paint.
this.element.classList.add('transition-transform', 'duration-300');
this.element.classList.toggle(this.closedClass, 'close' === mode);
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M8 2v3m8-3v3"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M8 13h.01M12 13h.01M16 13h.01M8 17h.01M12 17h.01M16 17h.01"/></g></svg>

After

Width:  |  Height:  |  Size: 321 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></g></svg>

After

Width:  |  Height:  |  Size: 363 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5h16M4 12h16M4 19h16"/></svg>

After

Width:  |  Height:  |  Size: 199 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12"/></svg>

After

Width:  |  Height:  |  Size: 196 B

+3 -1
View File
@@ -12,6 +12,7 @@
"doctrine/orm": "^3.7", "doctrine/orm": "^3.7",
"phpdocumentor/reflection-docblock": "^6.0", "phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"symfony/apache-pack": "*",
"symfony/asset": "7.4.*", "symfony/asset": "7.4.*",
"symfony/asset-mapper": "7.4.*", "symfony/asset-mapper": "7.4.*",
"symfony/console": "7.4.*", "symfony/console": "7.4.*",
@@ -37,6 +38,7 @@
"symfony/string": "7.4.*", "symfony/string": "7.4.*",
"symfony/translation": "7.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*", "symfony/twig-bundle": "7.4.*",
"symfony/ux-icons": "^3.4",
"symfony/validator": "7.4.*", "symfony/validator": "7.4.*",
"symfony/web-link": "7.4.*", "symfony/web-link": "7.4.*",
"symfony/yaml": "7.4.*", "symfony/yaml": "7.4.*",
@@ -90,7 +92,7 @@
}, },
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": true,
"require": "7.4.*" "require": "7.4.*"
} }
}, },
Generated
+120 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9cd4746d1b88efb4f3d1ecf392a958a9", "content-hash": "ef19a7def0151d8b452378a05ec4c324",
"packages": [ "packages": [
{ {
"name": "composer/semver", "name": "composer/semver",
@@ -1903,6 +1903,32 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "time": "2024-09-11T13:17:53+00:00"
}, },
{
"name": "symfony/apache-pack",
"version": "v1.0.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/apache-pack.git",
"reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/apache-pack/zipball/3aa5818d73ad2551281fc58a75afd9ca82622e6c",
"reference": "3aa5818d73ad2551281fc58a75afd9ca82622e6c",
"shasum": ""
},
"type": "symfony-pack",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A pack for Apache support in Symfony",
"support": {
"issues": "https://github.com/symfony/apache-pack/issues",
"source": "https://github.com/symfony/apache-pack/tree/master"
},
"time": "2017-12-12T01:46:35+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v7.4.8", "version": "v7.4.8",
@@ -7260,6 +7286,99 @@
], ],
"time": "2026-08-21T15:52:48+00:00" "time": "2026-08-21T15:52:48+00:00"
}, },
{
"name": "symfony/ux-icons",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-icons.git",
"reference": "117b5d9bc9652f364495d586235b9e1f1f7c9d52"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-icons/zipball/117b5d9bc9652f364495d586235b9e1f1f7c9d52",
"reference": "117b5d9bc9652f364495d586235b9e1f1f7c9d52",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/twig-bundle": "^7.4|^8.0"
},
"conflict": {
"symfony/flex": "<1.13",
"symfony/ux-twig-component": "<2.21"
},
"require-dev": {
"phpunit/phpunit": "^11.1|^12.0",
"psr/log": "^2|^3",
"symfony/asset-mapper": "^7.4|^8.0",
"symfony/console": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/ux-twig-component": "^2.14|^3.0",
"zenstruck/console-test": "^1.5"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\Icons\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kevin Bond",
"email": "[email protected]"
},
{
"name": "Simon André",
"email": "[email protected]"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Renders local and remote SVG icons in your Twig templates.",
"homepage": "https://symfony.com",
"keywords": [
"icons",
"svg",
"symfony-ux",
"twig"
],
"support": {
"source": "https://github.com/symfony/ux-icons/tree/v3.4.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-29T16:44:14+00:00"
},
{ {
"name": "symfony/validator", "name": "symfony/validator",
"version": "v7.4.18", "version": "v7.4.18",
+1
View File
@@ -12,4 +12,5 @@ return [
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\UX\Icons\UXIconsBundle::class => ['all' => true],
]; ];
+27
View File
@@ -0,0 +1,27 @@
# Full configuration reference: https://symfony.com/bundles/ux-icons/current/index.html#configuration
ux_icons:
# Default HTML attributes to add to all icons
default_icon_attributes:
# Use current text color to fill the icon
fill: currentColor
# Default dimensions
height: '1em'
width: '1em'
# Throw an exception if an icon is not
ignore_not_found: false
when@dev:
ux_icons:
iconify:
# Persist each on-demand icon to disk on first render, so you don't forget to commit it before deploying (like `ux:icons:lock`)
auto_lock: true
when@prod:
ux_icons:
# Suppress exceptions for missing icons in production
ignore_not_found: true
+24
View File
@@ -1470,6 +1470,26 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* generate_final_classes?: bool|Param, // Default: true * generate_final_classes?: bool|Param, // Default: true
* generate_final_entities?: bool|Param, // Default: false * generate_final_entities?: bool|Param, // Default: false
* } * }
* @psalm-type UxIconsConfig = array{
* icon_dir?: scalar|Param|null, // The local directory where icons are stored. // Default: "%kernel.project_dir%/assets/icons"
* default_icon_attributes?: array<string, scalar|Param|null>,
* icon_sets?: array<string, array{ // the icon set prefix (e.g. "acme") // Default: []
* path?: scalar|Param|null, // The local icon set directory path. (cannot be used with 'alias')
* alias?: scalar|Param|null, // The remote icon set identifier. (cannot be used with 'path')
* icon_attributes?: array<string, scalar|Param|null>,
* suffixes?: array<string, array{ // The suffix name (e.g. "solid", "20-solid") // Default: []
* icon_attributes?: array<string, scalar|Param|null>,
* }>,
* }>,
* aliases?: array<string, string|Param>,
* iconify?: bool|array{ // Configuration for the remote icon service.
* enabled?: bool|Param, // Default: true
* on_demand?: bool|Param, // Whether to download icons "on demand". // Default: true
* auto_lock?: bool|Param, // Persist "on demand" icons to the local icon directory (see "icon_dir"). Recommended in dev only. Requires "on_demand" to be enabled. // Default: false
* endpoint?: scalar|Param|null, // The endpoint for the Iconify icons API. // Default: "https://api.iconify.design"
* },
* ignore_not_found?: bool|Param, // Ignore error when an icon is not found. Set to 'true' to fail silently. // Default: false
* }
* @psalm-type ConfigType = array{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1482,6 +1502,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* "when@dev"?: array{ * "when@dev"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1497,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* maker?: MakerConfig, * maker?: MakerConfig,
* ux_icons?: UxIconsConfig,
* }, * },
* "when@prod"?: array{ * "when@prod"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1510,6 +1532,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
@@ -1524,6 +1547,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* security?: SecurityConfig, * security?: SecurityConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* ux_icons?: UxIconsConfig,
* }, * },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias * ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig, * imports?: ImportsConfig,
+70
View File
@@ -0,0 +1,70 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeScript assets.
# Options +SymLinksIfOwnerMatch
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
# This Option needs to be enabled for RewriteRule, otherwise it will show an error like
# 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
Options +SymLinksIfOwnerMatch
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=308,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class BookingController extends AbstractController
{
#[Route('/buchungen', name: 'app_booking', methods: ['GET'])]
public function index(): Response
{
return $this->render('booking/index.html.twig');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class DashboardController extends AbstractController
{
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
public function index(): Response
{
return $this->render('dashboard/index.html.twig');
}
}
+25
View File
@@ -50,6 +50,18 @@
"bin/phpunit" "bin/phpunit"
] ]
}, },
"symfony/apache-pack": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5d454ec6cc4c700ed3d963f3803e1d427d9669fb"
},
"files": [
"public/.htaccess"
]
},
"symfony/asset-mapper": { "symfony/asset-mapper": {
"version": "7.4", "version": "7.4",
"recipe": { "recipe": {
@@ -270,6 +282,19 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/ux-icons": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.4",
"ref": "16a3114526e46593b494471d1654dfa46831beb5"
},
"files": [
"assets/icons/symfony.svg",
"config/packages/ux_icons.yaml"
]
},
"symfony/validator": { "symfony/validator": {
"version": "7.4", "version": "7.4",
"recipe": { "recipe": {
+13
View File
@@ -0,0 +1,13 @@
{# Flat inline alerts for now. myep renders these as Toastify toasts, but that means adopting
toastify-js as a dependency -- deferred until the toast/OOB work (plan step 7). #}
{% set levels = { error: 'bg-red-600', warning: 'bg-red-600', success: 'bg-emerald-600', info: 'bg-primary' } %}
{% for level, messages in app.flashes(levels|keys) %}
<div class="fixed bottom-4 right-4 z-50 space-y-2">
{% for message in messages %}
<div class="{{ levels[level] }} text-gray-100 rounded shadow-lg px-4 py-3 text-sm">
{{ message|trans }}
</div>
{% endfor %}
</div>
{% endfor %}
+25
View File
@@ -0,0 +1,25 @@
{# Plain nav instead of myep's knp-menu builder: a handful of links does not justify the
indirection. Active state is a prefix match on the current route name, which mirrors what
myep's menu matcher does with its 'routes' extras. #}
{% set current_route = app.request.attributes.get('_route') %}
{% set items = [
{ route: 'app_dashboard', label: 'Übersicht', icon: 'lucide:layout-grid' },
{ route: 'app_booking', label: 'Meine Buchungen', icon: 'lucide:calendar-days' },
] %}
<div class="min-h-full flex grow flex-col gap-y-5 lg:border-r border-gray-200 bg-white md:pr-6">
<nav class="flex flex-1 flex-col">
<ul role="list" class="space-y-1">
{% for item in items %}
{% set is_active = current_route starts with item.route %}
<li>
<a href="{{ path(item.route) }}"
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold {{ is_active ? 'bg-gray-100 text-gray-900' : 'text-gray-700 hover:bg-gray-50' }}">
{{ ux_icon(item.icon, { class: 'h-5 w-5 shrink-0 ' ~ (is_active ? 'text-gray-600' : 'text-gray-400') }) }}
<span class="flex-1">{{ item.label }}</span>
</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
+12 -13
View File
@@ -1,24 +1,23 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> {# Internal tool -- keep it out of search engines. #}
{% block stylesheets %} <meta name="robots" content="noindex,nofollow">
{% endblock %} <title>{% block title %}ephub{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.15em%22 font-size=%2296%22>🪑</text></svg>">
{# The Tailwind build is pulled in by assets/app.js's `import './styles/app.css'`,
so importmap() emits the <link> for it. This block is for anything outside that graph. #}
{% block stylesheets %}{% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
{% set frankenphp_hot_reload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %}
{% if frankenphp_hot_reload %}
<meta name="frankenphp-hot-reload:url" content="{{ frankenphp_hot_reload }}">
<script src="https://cdn.jsdelivr.net/npm/idiomorph"></script>
<script src="https://cdn.jsdelivr.net/npm/frankenphp-hot-reload/+esm" type="module"></script>
{% endif %}
</head> </head>
<body> {# No hx-boost: htmx handles fragments only, the browser owns navigation. #}
<body class="bg-white text-gray-700 font-sans antialiased">
{% block body %}{% endblock %} {% block body %}{% endblock %}
</body> </body>
</html> </html>
+7
View File
@@ -0,0 +1,7 @@
{% extends 'layout.html.twig' %}
{% block title %}Meine Buchungen · ephub{% endblock %}
{% block content %}
<h1>Meine Buchungen</h1>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends 'layout.html.twig' %}
{% block title %}Übersicht · ephub{% endblock %}
{% block content %}
<h1>Übersicht</h1>
<p class="subline">Schreibtische und Parkplätze buchen.</p>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8">
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
<a href="{{ path('app_dashboard') }}" class="text-2xl font-black tracking-tight text-primary-dark">
ep<span class="text-pink">hub</span>
</a>
<button type="button"
class="lg:hidden"
{{ stimulus_controller('mobilenav', [], [], { 'mobilenav': '#mobilenav' }) }}
{{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'open' }) }}>
{{ ux_icon('lucide:menu', { class: 'w-8 h-8 text-gray-700' }) }}
</button>
</div>
<div class="hidden lg:block sticky top-0 h-screen">
{% include '_partials/_nav.html.twig' %}
</div>
<div class="lg:col-span-3">
{% block content %}{% endblock %}
</div>
</div>
<div id="mobilenav"
class="fixed z-50 top-0 left-0 inset-0 pr-16 bg-black/70 -translate-x-full"
{{ stimulus_controller('mobilenav', [], { 'closed': '-translate-x-full' }, { 'mobilenav': '#mobilenav' }) }}>
<div class="h-screen w-full bg-white">
<div class="flex justify-between p-8">
<span class="text-2xl font-black tracking-tight text-primary-dark">ep<span class="text-pink">hub</span></span>
<button type="button" {{ stimulus_action('mobilenav', 'trigger', null, { 'mode': 'close' }) }}>
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
</button>
</div>
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
{% include '_partials/_nav.html.twig' %}
</div>
</div>
</div>
{% include '_partials/_flashes.html.twig' %}
{% endblock %}