Files
Olivier Meunier fc7496f0a6 New layout
This moves the left app menu bar to the top, leaving a fully empty left
space so we can center the layout.

This new layout simplifies pages without a side menu (ie. a bookmark);
it only needs a null menu component and the layout adapts to the absence
of such menu.
2026-06-06 08:16:30 +02:00

269 lines
7.6 KiB
Templ

// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package components
import "net/http"
// BasePage renders the layout's base page with head elements.
templ (c *Layout) BasePage(title string) {
<!DOCTYPE html>
<html
lang={ L(ctx).Tag.String() }
class="overscroll-y-none scroll-pt-20 max-sm:scroll-pt-40"
>
<head>
<meta charset="UTF-8"/>
<title>{ title } - Readeck</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="csp-nonce" content={ CSPNonce(ctx) }/>
<meta name="robots" content="noindex nofollow noarchive"/>
<link rel="stylesheet" href={ Asset(ctx, "bundle.css") } nonce={ CSPNonce(ctx) }/>
<script type="module" src={ Asset(ctx, "bundle.js") } nonce={ CSPNonce(ctx) }></script>
<script nonce={ CSPNonce(ctx) }>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
</script>
<link rel="icon" href={ Asset(ctx, "img/fi/favicon.ico") } sizes="48x48"/>
<link rel="icon" href={ Asset(ctx, `img/fi/favicon.svg`) } sizes="any" type="image/svg+xml"/>
<link rel="apple-touch-icon" href={ Asset(ctx, `img/fi/apple-touch-icon.png`) }/>
<link rel="manifest" href={ URL(ctx, "/manifest.webmanifest") }/>
<meta name="theme-color" content="#064c5c"/>
@c.Head
@CustomTemplate("layout/head.html.tmpl", nil)
</head>
<body
class="
no-js bg-app-bg text-app-fg font-sans leading-tight tracking-normal
relative
print:text-black print:bg-white
overscroll-y-none
"
data-turbo="false"
data-turbo-prefetch="false"
data-controller="scroll-tracker"
data-scroll-tracker-down-class="scrolled-down"
>
<script nonce={ CSPNonce(ctx) }>
if ('noModule' in HTMLScriptElement.prototype) {
document.body.classList.remove("no-js")
document.body.classList.add("js")
}
</script>
{ children... }
</body>
</html>
}
templ (c *Layout) Menu(menuBtn templ.Component) {
<div class="topnav--grid" id="menu">
@menuBtn {
@Icon("o-menu")
}
<div class="logo">
if HasCustomTemplate("layout/menu-icon.html.tmpl") {
@CustomTemplate("layout/menu-icon.html.tmpl", nil)
} else {
<a href={ URL(ctx, "/") } title={ L(ctx).Gettext("Home Page") }>
@Icon("o-logo-square", WithIconClass(), WithIconSvgClass("h-11 w-11"))
</a>
}
</div>
<div class="mainmenu">
<menu class="grow">
if HasPermission(ctx, "bookmarks", "read") {
@c.mainMenuItem(L(ctx).Gettext("Bookmarks"), "/bookmarks/unread", "o-library", PathIs(ctx, "/bookmarks", "/bookmarks/*")) {
<span class="max-sm:sr-only">{ L(ctx).Gettext("Bookmarks") }</span>
}
}
</menu>
if !IsAnonymous(ctx) {
<menu class="flex-shrink-0">
<li class="no-js:hidden">
<button
type="button"
data-controller="theme"
data-action="theme#toggleTheme"
title={ L(ctx).Gettext("Change color theme") }
>
<span data-theme-target="icon">
@Icon("o-theme-system")
</span>
<span class="sr-only">{ L(ctx).Gettext("Change color theme") }</span>
<template data-theme-target="iconLight">
@Icon("o-theme-light")
</template>
<template data-theme-target="iconDark">
@Icon("o-theme-dark")
</template>
<template data-theme-target="iconSystem">
@Icon("o-theme-system")
</template>
</button>
</li>
if HasPermission(ctx, "profile", "read") {
@c.mainMenuItem(L(ctx).Gettext("Settings"), "/profile", "o-settings", PathIs(ctx, "/admin", "/admin/*", "/profile", "/profile/*")) {
<span class="sr-only">{ L(ctx).Gettext("Settings") }</span>
}
}
@c.mainMenuItem(L(ctx).Gettext("Documentation"), "/docs/", "o-help", PathIs(ctx, "/docs/*")) {
<span class="sr-only">{ L(ctx).Gettext("Documentation") }</span>
}
</menu>
}
</div>
</div>
}
templ (c *Layout) menuButton(name string) {
<button
type="button"
title={ name }
class="sidemenu--button"
data-action="click->panel#toggle"
data-panel-target="button"
aria-expanded="false"
aria-controls="sidemenu"
>
{ children... }
<span class="sr-only">{ name }</span>
</button>
}
templ (c *Layout) mainMenuItem(name, path, icon string, current bool) {
<li>
<a href={ URL(ctx, path) } title={ name } data-current={ current }>
@Icon(icon)
{ " " }
{ children... }
</a>
</li>
}
// QuickAccessMenu renders an a11y menu.
templ QuickAccessMenu(items [][2]string) {
if len(items) > 0 {
<ul label={ L(ctx).Gettext("Quick access menu") } class="a11y-nav">
for _, item := range items {
<li><a href={ "#" + item[0] } class="link">{ item[1] }</a></li>
}
</ul>
}
}
// SideMenuTitle renders a title used in side menus.
templ SideMenuTitle() {
<h2
id="sidemenu-title"
class="h-topnav border-b border-gray-200 uppercase flex items-center justify-center sm:hidden"
>
{ children... }
</h2>
}
// SideMenuLayout renders a layout with a side menu.
templ (c *Layout) SideMenuLayout(title string, sidemenu templ.Component, contentWrapper templ.Component) {
{{
qam := c.AccessMenu
if len(qam) == 0 {
qam = [][2]string{
{"menu", L(ctx).Gettext("Menu")},
{"sidemenu", L(ctx).Gettext("Secondary Menu")},
{"content", L(ctx).Gettext("Main content")},
}
}
}}
@c.BasePage(title) {
@QuickAccessMenu(qam)
<div
class="layout"
data-controller="panel"
data-panel-hidden-class="sidemenu--hidden"
data-panel-body-class="max-sm:overflow-hidden"
>
<div class="topnav">
{{
var btn templ.Component = templ.NopComponent
if sidemenu != nil {
btn = c.menuButton(L(ctx).Gettext("Open Menu"))
}
}}
@c.Menu(btn)
</div>
<div class="layout--grid">
if sidemenu != nil {
<nav
id="sidemenu"
tabindex="-1"
data-panel-target="panel"
aria-labelledby="sidemenu-title"
data-action="keydown.esc->panel#toggle"
class="sidemenu sidemenu--hidden"
>
@c.menuButton(L(ctx).Gettext("Close menu")) {
@Icon("o-close", WithIconClass("inline-block"), WithIconSvgClass("w-8 h-8"))
}
@sidemenu
</nav>
}
@contentWrapper {
{ children... }
}
</div>
</div>
}
}
templ (c *Layout) SideMenuWrapper() {
<div class="content" id="content">
@Flashes()
{ children... }
</div>
}
templ (c *Layout) SideMenuStdWrapper() {
<main class="content max-w-std" id="content">
@Flashes()
@Breadcrumbs()
{ children... }
</main>
}
templ (c *Layout) authHeader() {
<h1 class="max-w-sm mt-12 mx-auto px-8">
<div>
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 567 128" class="w-full fill-gray-900">
<use href={ Asset(ctx, "img/logo-text.svg") + "#main" }></use>
</svg>
</div>
</h1>
}
templ (c *Layout) AuthBase(title string) {
@BasePage(title) {
@c.authHeader()
<main class="w-full max-w-sm p-8 mt-6 mx-auto bg-gray-100 text-gray-dark rounded-md shadow-md" id="content">
{ children... }
</main>
@CustomTemplate("auth/footer.html.tmpl", nil)
}
}
templ (c *Layout) AuthError(status int) {
@BasePage(L(ctx).Gettext("Error")) {
@c.authHeader()
<main class="w-full max-w-sm p-8 mt-6 mx-auto text-center border border-red-800 bg-red-100 rounded-md" id="content">
<h2 class="title text-h3">{ L(ctx).Gettext("An error occurred.") }</h2>
<p>{ http.StatusText(status) }</p>
<p class="mt-8">
<a class="link font-semibold" href={ URL(ctx, "/") }>{ L(ctx).Gettext("Go back to Readeck") }</a>
</p>
</main>
}
}