web using penguin-ui
This commit is contained in:
@@ -202,8 +202,10 @@ mod tests {
|
||||
for path in ["/", "/login", "/register"] {
|
||||
let (status, body) = get(path).await;
|
||||
assert!(status.is_success(), "{path} returned {status}");
|
||||
// Penguin UI's navbar, so what identifies it is its Alpine
|
||||
// state rather than a class this app wrote.
|
||||
assert!(
|
||||
body.contains("<header class=\"topbar\">"),
|
||||
body.contains(r#"x-data="{ mobileMenuIsOpen: false }""#),
|
||||
"{path} is missing the shared navbar"
|
||||
);
|
||||
assert!(
|
||||
@@ -312,6 +314,8 @@ mod tests {
|
||||
async fn stylesheet_is_served_once_for_every_page() {
|
||||
let (status, body) = get("/static/app.css").await;
|
||||
assert!(status.is_success());
|
||||
assert!(body.contains(".topbar"));
|
||||
// The navbar and the alerts are Penguin UI's own Tailwind classes now,
|
||||
// so what is left in this file is the site's layout.
|
||||
assert!(body.contains(".form-card"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ impl TableDraft {
|
||||
.map(|link| ProtoTableLink {
|
||||
linked_table_name: link.linked_table_name.clone(),
|
||||
required: link.mode.is_required(),
|
||||
name: link.linked_table_name.clone(),
|
||||
})
|
||||
.collect(),
|
||||
accounting_currency: if self.creating_new_profile {
|
||||
|
||||
@@ -183,7 +183,7 @@ mod tests {
|
||||
state.error = Some("That column already exists.".to_string());
|
||||
let html = render_builder(&state);
|
||||
assert!(html.contains(r#"role="dialog""#));
|
||||
assert!(html.contains("x-data=\"{ modalIsOpen: true }\""));
|
||||
assert!(html.contains("x-data=\"{ dangerModalIsOpen: true }\""));
|
||||
assert_eq!(html.matches("That column already exists.").count(), 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,10 @@
|
||||
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
|
||||
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
|
||||
|
||||
/* ---------- Navbar (components/navbar.html) ---------- */
|
||||
|
||||
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; }
|
||||
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; }
|
||||
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; width: auto; }
|
||||
.topbar a:hover, .link-button:hover, .topbar a.active { color: white; }
|
||||
.topbar a.active { border-bottom: 2px solid #68a4ff; }
|
||||
.topbar form { margin: 0; }
|
||||
.role { color: #9fb0c8; font-size: 12px; }
|
||||
/* ---------- Navbar ----------
|
||||
Nothing here: the navbar is Penguin UI's, styled by its own Tailwind
|
||||
classes in ui/navbar.html. The dark `.topbar` bar this replaced was the
|
||||
last hand-written component on the site. */
|
||||
|
||||
/* ---------- Page heading (components/heading.html) ---------- */
|
||||
|
||||
@@ -189,8 +184,6 @@
|
||||
.heading { align-items: start; flex-direction: column; }
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; }
|
||||
.topbar { padding: 10px 16px; align-items: start; gap: 10px; }
|
||||
.topbar, .topbar nav { flex-wrap: wrap; }
|
||||
.analytics { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static; max-height: none; }
|
||||
.query-row { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
{#
|
||||
Inline status blocks — Penguin UI's "alert" component
|
||||
(https://www.penguinui.com/components/alert, modern style, with icon), minus
|
||||
the dark variants because the app has no dark theme. The colours come from
|
||||
the @theme block in ui/base.html.
|
||||
Inline failure alert — Penguin UI's danger alert, taken from
|
||||
penguinui-components/alert/alert-dismiss-functionality.html.
|
||||
|
||||
Failures only: a success is ui/toast.html, which says its piece and then
|
||||
removes itself rather than staying in the layout. An error has to stay until
|
||||
Unlike the toast, this component carries its copy in the markup, so it cannot
|
||||
be `{% include %}`d from the library the way ui/toast.html's stack is: the
|
||||
library file says "Invalid Email Address". This is that file's danger variant
|
||||
with its two text nodes replaced by `{{ title }}` and `{{ message }}` and
|
||||
nothing else touched — every class, the x-data, the leave transition and the
|
||||
dismiss button are the library's. Two additions, both about this app's
|
||||
content rather than the component's look: `mt-4` for the gap from the form
|
||||
above, and `whitespace-pre-wrap` because the server's messages are multi-line.
|
||||
|
||||
Failures only. A success is ui/toast.html, which says its piece and then
|
||||
removes itself rather than staying in the layout; an error has to stay until
|
||||
it is read, so it is this.
|
||||
|
||||
Imported wherever a form reports a failure:
|
||||
|
||||
{% import "ui/alert.html" as alert %}
|
||||
{% call alert::error("Could not create the table", message) %}{% endcall %}
|
||||
|
||||
@@ -17,18 +22,29 @@
|
||||
the message in front of someone who has scrolled away from the top of a form.
|
||||
#}
|
||||
{% macro error(title, message) %}
|
||||
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-danger bg-surface text-onSurface" role="alert">
|
||||
<div class="flex items-center gap-2 bg-danger/10 p-4">
|
||||
<div class="shrink-0 rounded-full bg-danger/15 p-1 text-danger">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" />
|
||||
</svg>
|
||||
<div x-data="{ alertIsVisible: true }" x-show="alertIsVisible"
|
||||
class="relative mt-4 w-full overflow-hidden rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
|
||||
role="alert" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-90">
|
||||
<div class="flex w-full items-center gap-2 bg-danger/10 p-4">
|
||||
<div class="bg-danger/15 text-danger rounded-full p-1" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-danger">{{ title }}</h3>
|
||||
<p class="text-xs font-medium whitespace-pre-wrap sm:text-sm">{{ message }}</p>
|
||||
</div>
|
||||
<button type="button" @click="alertIsVisible = false" class="ml-auto" aria-label="dismiss alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="2.5" class="w-4 h-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-danger">{{ title }}</h3>
|
||||
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -25,43 +25,62 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
|
||||
|
||||
{#
|
||||
Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and its
|
||||
components are what this app uses for anything the user has to be told:
|
||||
ui/alert.html and ui/dialog.html. The focus plugin is what x-trap in the
|
||||
dialog needs, and it has to load before Alpine itself.
|
||||
Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and every
|
||||
component on this site is one of its components: the navbar, the failure
|
||||
alert and modal, and the toast, which is included straight out of
|
||||
penguinui-components. The focus plugin is what x-trap in the modal needs,
|
||||
and it has to load before Alpine itself.
|
||||
#}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<style type="text/tailwindcss">
|
||||
/* Penguin UI's "modern" theme setup: its semantic names, this app's palette. */
|
||||
@theme {
|
||||
--color-surface: #ffffff;
|
||||
--color-surfaceAlt: #f3f5f7;
|
||||
--color-surfaceDark: #152238;
|
||||
--color-onSurface: #465267;
|
||||
--color-onSurfaceStrong: #17202a;
|
||||
--color-outline: #d9dfe7;
|
||||
--color-primary: #2563eb;
|
||||
--color-onPrimary: #ffffff;
|
||||
--color-danger: #9d342d;
|
||||
--color-success: #21643a;
|
||||
/*
|
||||
The theme penguinui-components/style.css declares, with this app's palette
|
||||
in the light slots. The names are the library's and are not renamed: the
|
||||
component markup here is the library's markup, so it asks for
|
||||
`text-on-surface` and `rounded-radius` and has to find them.
|
||||
*/
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/*
|
||||
The same palette again under the names penguinui-components/style.css
|
||||
declares, for the component files this app includes from the library
|
||||
unmodified rather than copying — currently the toast. The copies above
|
||||
renamed these; a file used as the library ships it cannot be renamed,
|
||||
so the theme answers to both spellings.
|
||||
*/
|
||||
@theme {
|
||||
/* Light theme — this app's palette. */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f3f5f7;
|
||||
--color-on-surface: #465267;
|
||||
--color-on-surface-strong: #17202a;
|
||||
--color-primary: #2563eb;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-secondary: #152238;
|
||||
--color-on-secondary: #ffffff;
|
||||
--color-outline: #d9dfe7;
|
||||
--color-outline-strong: #8f9bad;
|
||||
|
||||
/*
|
||||
Dark theme. Nothing sets the `.dark` class the custom variant above
|
||||
scopes these to, so they are inert — they exist because the library's
|
||||
markup carries `dark:` classes and is used as it ships. Scoping the
|
||||
variant to a class rather than leaving Tailwind's prefers-color-scheme
|
||||
default is what keeps them from firing on an OS set to dark.
|
||||
*/
|
||||
--color-surface-dark: #0f1722;
|
||||
--color-surface-dark-alt: #152238;
|
||||
--color-on-surface-dark: #cbd7e8;
|
||||
--color-on-surface-dark-strong: #ffffff;
|
||||
--color-primary-dark: #68a4ff;
|
||||
--color-on-primary-dark: #0f1722;
|
||||
--color-outline-dark: #33415a;
|
||||
--color-outline-dark-strong: #cbd7e8;
|
||||
|
||||
/* Shared colours. */
|
||||
--color-info: #1d4e89;
|
||||
--color-on-info: #ffffff;
|
||||
--color-success: #21643a;
|
||||
--color-on-success: #ffffff;
|
||||
--color-warning: #8a5a08;
|
||||
--color-on-warning: #ffffff;
|
||||
--color-danger: #9d342d;
|
||||
--color-on-danger: #ffffff;
|
||||
|
||||
/* Border radius. */
|
||||
--radius-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
{#
|
||||
Blocking modal dialog — Penguin UI's "modal" component
|
||||
(https://www.penguinui.com/components/modal, modern style), with the dark
|
||||
variants dropped because the app has no dark theme.
|
||||
Blocking failure modal — Penguin UI's danger modal, taken from
|
||||
penguinui-components/modal/modal-alerts.html.
|
||||
|
||||
It is Tailwind + Alpine markup, so a page that calls this macro must load
|
||||
both in its `head` block; `pages/add_table/add_table.html` shows the set.
|
||||
The `@theme` block there maps Penguin UI's semantic colour names onto the
|
||||
palette in `static/app.css`.
|
||||
Same deal as ui/alert.html: the component's copy lives in its markup, so this
|
||||
is the library file's danger variant with its text nodes replaced and nothing
|
||||
else changed. Two structural edits, both because the server, not a click,
|
||||
decides when this appears:
|
||||
|
||||
Rendered already open (`modalIsOpen: true`): the server only sends the
|
||||
dialog when there is something the user has to be told, so there is no
|
||||
trigger button — the response itself is the trigger.
|
||||
- the library's trigger button is dropped, and `dangerModalIsOpen` starts
|
||||
`true`. The server only sends the dialog when there is something the user
|
||||
has to be told, so the response itself is the trigger.
|
||||
- the footer button dismisses instead of acting ("Back to the form"): a
|
||||
failure has nothing to confirm.
|
||||
|
||||
It is Tailwind + Alpine markup; x-trap needs the focus plugin, which
|
||||
ui/base.html loads before Alpine itself.
|
||||
|
||||
{% import "ui/dialog.html" as dialog %}
|
||||
{% call dialog::error("Could not add the column", message) %}{% endcall %}
|
||||
#}
|
||||
{% macro error(title, message) %}
|
||||
<div x-data="{ modalIsOpen: true }">
|
||||
<div x-cloak x-show="modalIsOpen" x-transition.opacity.duration.200ms
|
||||
x-trap.inert.noscroll="modalIsOpen"
|
||||
x-on:keydown.esc.window="modalIsOpen = false" x-on:click.self="modalIsOpen = false"
|
||||
class="fixed inset-0 z-30 flex items-end justify-center bg-surfaceDark/20 p-4 pb-8 backdrop-blur-xs sm:items-center lg:p-8"
|
||||
role="dialog" aria-modal="true" aria-labelledby="dialogTitle">
|
||||
<!-- Modal Dialog -->
|
||||
<div x-show="modalIsOpen"
|
||||
x-transition:enter="transition delay-100 duration-200 ease-out motion-reduce:transition-opacity"
|
||||
x-transition:enter-start="scale-50 opacity-0" x-transition:enter-end="scale-100 opacity-100"
|
||||
class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-lg border border-outline bg-surface text-onSurface">
|
||||
<!-- Dialog Header -->
|
||||
<div class="flex items-center justify-between border-b border-outline bg-surfaceAlt/60 p-4">
|
||||
<h3 id="dialogTitle" class="font-semibold tracking-wide text-danger">{{ title }}</h3>
|
||||
<button type="button" x-on:click="modalIsOpen = false" class="cursor-pointer text-onSurface" aria-label="close dialog">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="1.4" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dialog Body -->
|
||||
<div class="px-4 py-4">
|
||||
<p class="text-pretty whitespace-pre-wrap">{{ message }}</p>
|
||||
</div>
|
||||
<!-- Dialog Footer -->
|
||||
<div class="flex flex-col-reverse justify-between gap-2 border-t border-outline bg-surfaceAlt/60 p-4 sm:flex-row sm:items-center md:justify-end">
|
||||
<button type="button" x-on:click="modalIsOpen = false"
|
||||
class="cursor-pointer rounded-md whitespace-nowrap bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-onPrimary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0">
|
||||
Back to the form
|
||||
</button>
|
||||
</div>
|
||||
<div x-data="{ dangerModalIsOpen: true }">
|
||||
<div x-cloak x-show="dangerModalIsOpen" x-transition.opacity.duration.200ms x-trap.inert.noscroll="dangerModalIsOpen" x-on:keydown.esc.window="dangerModalIsOpen = false" x-on:click.self="dangerModalIsOpen = false" class="fixed inset-0 z-30 flex items-end justify-center bg-black/20 p-4 pb-8 backdrop-blur-md sm:items-center lg:p-8" role="dialog" aria-modal="true" aria-labelledby="dangerModalTitle">
|
||||
<!-- Modal Dialog -->
|
||||
<div x-show="dangerModalIsOpen" x-transition:enter="transition ease-out duration-200 delay-100 motion-reduce:transition-opacity" x-transition:enter-start="opacity-0 scale-50" x-transition:enter-end="opacity-100 scale-100" class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
|
||||
<!-- Dialog Header -->
|
||||
<div class="flex items-center justify-between border-b border-outline bg-surface-alt/60 px-4 py-2 dark:border-outline-dark dark:bg-surface-dark/20">
|
||||
<div class="flex items-center justify-center rounded-full bg-danger/20 text-danger p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<button x-on:click="dangerModalIsOpen = false" aria-label="close modal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="1.4" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dialog Body -->
|
||||
<div class="px-4 text-center">
|
||||
<h3 id="dangerModalTitle" class="mb-2 font-semibold tracking-wide text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h3>
|
||||
<p class="whitespace-pre-wrap">{{ message }}</p>
|
||||
</div>
|
||||
<!-- Dialog Footer -->
|
||||
<div class="flex items-center justify-center border-outline p-4 dark:border-outline-dark">
|
||||
<button x-on:click="dangerModalIsOpen = false" type="button" class="w-full whitespace-nowrap rounded-radius border border-danger bg-danger px-4 py-2 text-center text-sm font-semibold tracking-wide text-on-danger transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0">Back to the form</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
{#
|
||||
Shared navbar, rendered on every page by base.html.
|
||||
Reads the `nav` field of the surrounding page struct (crate::ui::Nav).
|
||||
Shared navbar, rendered on every page by base.html. Reads the `nav` field of
|
||||
the surrounding page struct (crate::ui::Nav).
|
||||
|
||||
Penguin UI's default navbar, from
|
||||
penguinui-components/navbar/default-navbar.html: the library's markup with
|
||||
this app's brand and links in place of the demo's, and the same link list
|
||||
repeated in the mobile menu exactly as the library repeats it. The active
|
||||
link is the library's own "current page" styling (`font-bold text-primary`
|
||||
plus aria-current) rather than the underline the old hand-written `.topbar`
|
||||
drew, and logging out is a form posting to /logout, styled as a link so it
|
||||
sits in the list with the others.
|
||||
|
||||
This replaced the hand-written dark `.topbar` bar, so the site chrome is now
|
||||
the library's: white, bordered underneath, with a hamburger menu below `md`.
|
||||
#}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<strong>Komp Accounting</strong>
|
||||
{% if !nav.role.is_empty() %}<span class="role">{{ nav.role }}</span>{% endif %}
|
||||
</div>
|
||||
<nav>
|
||||
<a href="/admin" {% if nav.active == "admin" %}class="active"{% endif %}>Admin</a>
|
||||
<a href="/" {% if nav.active == "analytics" %}class="active"{% endif %}>Analytics</a>
|
||||
{% if nav.authenticated %}
|
||||
<form hx-post="/logout" hx-swap="none">
|
||||
<button class="link-button" type="submit">Log out</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
|
||||
<a href="/register" {% if nav.active == "register" %}class="active"{% endif %}>Register</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<nav x-data="{ mobileMenuIsOpen: false }" x-on:click.away="mobileMenuIsOpen = false" class="flex items-center justify-between border-b border-outline px-6 py-4 dark:border-outline-dark" aria-label="main menu">
|
||||
<!-- Brand -->
|
||||
<a href="/" class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
<span>Komp <span class="text-primary dark:text-primary-dark">Accounting</span></span>
|
||||
{% if !nav.role.is_empty() %}<span class="ml-2 align-middle text-xs font-medium text-on-surface dark:text-on-surface-dark">{{ nav.role }}</span>{% endif %}
|
||||
</a>
|
||||
<!-- Desktop Menu -->
|
||||
<ul class="hidden items-center gap-4 md:flex">
|
||||
<li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>
|
||||
<li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.authenticated %}
|
||||
<li><form hx-post="/logout" hx-swap="none"><button type="submit" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Log out</button></form></li>
|
||||
{% else %}
|
||||
<li><a href="/login" class="{% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>
|
||||
<li><a href="/register" class="{% if nav.active == "register" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "register" %}aria-current="page"{% endif %}>Register</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- Mobile Menu Button -->
|
||||
<button x-on:click="mobileMenuIsOpen = !mobileMenuIsOpen" x-bind:aria-expanded="mobileMenuIsOpen" x-bind:class="mobileMenuIsOpen ? 'fixed top-6 right-6 z-20' : null" type="button" class="flex text-on-surface dark:text-on-surface-dark md:hidden" aria-label="mobile menu" aria-controls="mobileMenu">
|
||||
<svg x-cloak x-show="!mobileMenuIsOpen" xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
<svg x-cloak x-show="mobileMenuIsOpen" xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Mobile Menu -->
|
||||
<ul x-cloak x-show="mobileMenuIsOpen" x-transition:enter="transition motion-reduce:transition-none ease-out duration-300" x-transition:enter-start="-translate-y-full" x-transition:enter-end="translate-y-0" x-transition:leave="transition motion-reduce:transition-none ease-out duration-300" x-transition:leave-start="translate-y-0" x-transition:leave-end="-translate-y-full" id="mobileMenu" class="fixed max-h-svh overflow-y-auto inset-x-0 top-0 z-10 flex flex-col divide-y divide-outline rounded-b-radius border-b border-outline bg-surface-alt px-6 pb-6 pt-20 dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt md:hidden">
|
||||
<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>
|
||||
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
|
||||
{% if nav.authenticated %}
|
||||
<li class="py-4"><form hx-post="/logout" hx-swap="none"><button type="submit" class="w-full text-left text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Log out</button></form></li>
|
||||
{% else %}
|
||||
<li class="py-4"><a href="/login" class="w-full text-lg {% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>
|
||||
<li class="py-4"><a href="/register" class="w-full text-lg {% if nav.active == "register" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "register" %}aria-current="page"{% endif %}>Register</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user