Manager — Layout i Nawigacja
Opis
Layout Managera sklada sie z trzech glownych czesci:
- Sidebar (lewy) — nawigacja glowna, logo
- Top Bar (gora) — tytul strony, child selector, language switcher (flagi PL/EN), avatar usera
- Main Content — zawartosc strony, centrowana z max-width
Podejscie desktop-first — sidebar pelny na desktopie, kolapsuje na tablecie, staje sie hamburger menu na mobile.
ASCII Wireframe
+--------+-----------------------------------------------------------+
| | |
| L O G O| Tytul strony [Dziecko v] [PL|EN] [Av] [Wyloguj]|
| | |
+--------+-----------------------------------------------------------+
| | |
| [icon] | |
| Dashb. | |
| | |
| [icon] | |
| Content| M A I N C O N T E N T |
| | |
| [icon] | max-w-7xl mx-auto |
| Swiaty | px-6 py-8 |
| | |
| [icon] | |
| Todo | |
| | |
| ────── | |
| | |
| [icon] | |
| Ustawia| |
| | |
+--------+-----------------------------------------------------------+
Szerokosc sidebar: 256px (lg), 64px (md, tylko ikony), 0px (sm, hamburger)Responsive — tablet (ikony)
+----+--------------------------------------------------------------+
| LI | Tytul strony [Dziecko v] [PL|EN] [Av] [Wyloguj] |
+----+--------------------------------------------------------------+
| [] | |
| [] | |
| [] | M A I N C O N T E N T |
| [] | |
| -- | |
| [] | |
+----+--------------------------------------------------------------+Responsive — mobile (hamburger)
+------------------------------------------------------------------+
| [=] Tytul strony [Dziecko v] [PL|EN] [Av] |
+------------------------------------------------------------------+
| |
| |
| M A I N C O N T E N T |
| |
| |
+------------------------------------------------------------------+
Klikniecie [=] otwiera sidebar jako overlay:
+------------------------------------------------------------------+
| +-----------+ |
| | | (backdrop: bg-black/30 backdrop-blur-sm) |
| | L O G O | |
| | | |
| | Dashboard | |
| | Content | |
| | Swiaty | |
| | Todo | |
| | ───────── | |
| | Ustawienia| |
| | | |
| | [x] Zamknij |
| +-----------+ |
+------------------------------------------------------------------+Pelna struktura HTML
html
<!DOCTYPE html>
<html lang="pl" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .PageTitle }} — Lumos Island Manager</title>
<!-- Fonty -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@600;700&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet">
<!-- Tailwind CSS (produkcja: skompilowany) -->
<link rel="stylesheet" href="/static/css/app.css">
<!-- HTMX -->
<script src="/static/js/htmx.min.js"></script>
<!-- Alpine.js -->
<script defer src="/static/js/alpine.min.js"></script>
<!-- Lucide Icons -->
<script src="/static/js/lucide.min.js"></script>
<!-- HTMX transition styles -->
<style>
.htmx-swapping { opacity: 0; transition: opacity 200ms ease-out; }
.htmx-settling { opacity: 1; transition: opacity 200ms ease-in; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-flex; }
.htmx-request.htmx-indicator { display: inline-flex; }
</style>
</head>
<body class="h-full bg-gray-50 font-sans text-gray-900 antialiased"
x-data="{ sidebarOpen: false }">
<!-- Mobile sidebar overlay -->
<div x-show="sidebarOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="sidebarOpen = false"
class="fixed inset-0 bg-black/30 backdrop-blur-sm z-40 md:hidden"
x-cloak>
</div>
<!-- SIDEBAR -->
{{template "partials/sidebar" .}}
<!-- MAIN WRAPPER -->
<div class="md:ml-16 lg:ml-64 min-h-full flex flex-col transition-all duration-200">
<!-- TOP BAR -->
{{template "partials/topbar" .}}
<!-- MAIN CONTENT -->
<main class="flex-1">
<div class="max-w-7xl mx-auto px-6 py-8">
{{template "content" .}}
</div>
</main>
</div>
<!-- Lucide init -->
<script>lucide.createIcons();</script>
<!-- Re-init icons po HTMX swap -->
<script>
document.body.addEventListener('htmx:afterSwap', () => {
lucide.createIcons();
});
</script>
</body>
</html>Sidebar
html
{{/* partials/sidebar.html */}}
<aside class="fixed inset-y-0 left-0 z-50 flex flex-col bg-white border-r border-gray-100
w-64 lg:w-64 md:w-16
-translate-x-full md:translate-x-0
transition-all duration-200"
:class="sidebarOpen ? 'translate-x-0 w-64' : ''"
x-cloak>
<!-- Logo -->
<div class="flex items-center h-16 px-4 border-b border-gray-100">
<a href="/dashboard" class="flex items-center gap-3">
<img src="/static/img/logo-icon.svg" alt="Lumos" class="w-8 h-8">
<span class="text-lg font-display font-bold text-gray-900
hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Lumos Island
</span>
</a>
<!-- Close button (mobile only) -->
<button @click="sidebarOpen = false"
class="ml-auto md:hidden text-gray-400 hover:text-gray-600">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{{ $currentPath := .CurrentPath }}
<!-- Dashboard -->
<a href="/dashboard"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
{{ if eq $currentPath "/dashboard" }}
bg-indigo-50 text-indigo-700
{{ else }}
text-gray-600 hover:bg-gray-50 hover:text-gray-900
{{ end }}">
<i data-lucide="layout-dashboard" class="w-5 h-5 flex-shrink-0"
style="stroke-width: 1.5"></i>
<span class="hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Dashboard
</span>
</a>
<!-- Content -->
<a href="/content"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
{{ if hasPrefix $currentPath "/content" }}
bg-indigo-50 text-indigo-700
{{ else }}
text-gray-600 hover:bg-gray-50 hover:text-gray-900
{{ end }}">
<i data-lucide="book-open" class="w-5 h-5 flex-shrink-0"
style="stroke-width: 1.5"></i>
<span class="hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Content
</span>
</a>
<!-- Worlds -->
<a href="/worlds"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
{{ if hasPrefix $currentPath "/worlds" }}
bg-indigo-50 text-indigo-700
{{ else }}
text-gray-600 hover:bg-gray-50 hover:text-gray-900
{{ end }}">
<i data-lucide="globe" class="w-5 h-5 flex-shrink-0"
style="stroke-width: 1.5"></i>
<span class="hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Swiaty
</span>
</a>
<!-- Todo -->
<a href="/todo"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
{{ if hasPrefix $currentPath "/todo" }}
bg-indigo-50 text-indigo-700
{{ else }}
text-gray-600 hover:bg-gray-50 hover:text-gray-900
{{ end }}">
<i data-lucide="check-square" class="w-5 h-5 flex-shrink-0"
style="stroke-width: 1.5"></i>
<span class="hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Todo
</span>
</a>
<!-- Separator -->
<div class="my-4 border-t border-gray-100"></div>
<!-- Settings -->
<a href="/settings"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
transition-colors duration-150
{{ if hasPrefix $currentPath "/settings" }}
bg-indigo-50 text-indigo-700
{{ else }}
text-gray-600 hover:bg-gray-50 hover:text-gray-900
{{ end }}">
<i data-lucide="settings" class="w-5 h-5 flex-shrink-0"
style="stroke-width: 1.5"></i>
<span class="hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
Ustawienia
</span>
</a>
</nav>
<!-- Sidebar footer (user info, widoczny na pelnym sidebarze) -->
<div class="px-3 py-4 border-t border-gray-100 hidden lg:block"
:class="sidebarOpen ? '!block' : ''">
<div class="flex items-center gap-3 px-3">
<div class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
<i data-lucide="user" class="w-4 h-4 text-gray-500"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 truncate">{{ .User.Email }}</p>
</div>
</div>
</div>
</aside>Top Bar
html
{{/* partials/topbar.html */}}
<header class="sticky top-0 z-30 bg-white/80 backdrop-blur-sm border-b border-gray-100">
<div class="flex items-center justify-between px-6 h-16">
<!-- Left: hamburger (mobile) + page title -->
<div class="flex items-center gap-4">
<!-- Hamburger button (mobile only) -->
<button @click="sidebarOpen = true"
class="md:hidden text-gray-500 hover:text-gray-700
transition-colors duration-150">
<i data-lucide="menu" class="w-5 h-5"></i>
</button>
<!-- Page title -->
<h1 class="text-xl font-display font-bold text-gray-900">
{{ .PageTitle }}
</h1>
</div>
<!-- Right: child selector + user -->
<div class="flex items-center gap-4">
<!-- Child selector -->
{{ if gt (len .Children) 1 }}
<div x-data="{ open: false }" class="relative">
<button @click="open = !open"
class="inline-flex items-center gap-2 px-3 py-2
bg-gray-50 rounded-lg text-sm font-medium text-gray-700
hover:bg-gray-100 transition-colors duration-150">
<div class="w-6 h-6 rounded-full bg-indigo-100
flex items-center justify-center">
<span class="text-xs font-semibold text-indigo-600">
{{ .ActiveChild.Initials }}
</span>
</div>
<span class="hidden sm:inline">{{ .ActiveChild.Name }}</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-gray-400"></i>
</button>
<div x-show="open" @click.away="open = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-56 bg-white rounded-xl
border border-gray-100 shadow-lg py-1 z-50">
{{ range .Children }}
<a href="?child={{ .ID }}"
class="flex items-center gap-3 px-4 py-2.5 text-sm
transition-colors duration-150
{{ if .IsActive }}bg-indigo-50 text-indigo-700
{{ else }}text-gray-700 hover:bg-gray-50{{ end }}">
<div class="w-8 h-8 rounded-full bg-indigo-100
flex items-center justify-center">
<span class="text-xs font-semibold text-indigo-600">
{{ .Initials }}
</span>
</div>
<div>
<div class="font-medium">{{ .Name }}</div>
<div class="text-xs text-gray-500">Poziom {{ .Level }}</div>
</div>
{{ if .IsActive }}
<i data-lucide="check" class="w-4 h-4 ml-auto text-indigo-600"></i>
{{ end }}
</a>
{{ end }}
</div>
</div>
{{ else if eq (len .Children) 1 }}
<div class="inline-flex items-center gap-2 px-3 py-2 text-sm text-gray-500">
<div class="w-6 h-6 rounded-full bg-indigo-100
flex items-center justify-center">
<span class="text-xs font-semibold text-indigo-600">
{{ .ActiveChild.Initials }}
</span>
</div>
<span class="hidden sm:inline">{{ .ActiveChild.Name }}</span>
</div>
{{ end }}
<!-- Language switcher (PL / EN) -->
<div class="flex items-center gap-1 pl-4 border-l border-gray-100">
<a href="?lang=pl"
class="px-1.5 py-1 rounded text-xs font-semibold transition-colors duration-150
{{ if eq .Lang "pl" }}bg-indigo-100 text-indigo-700{{ else }}text-gray-400 hover:text-gray-600{{ end }}">
🇵🇱
</a>
<a href="?lang=en"
class="px-1.5 py-1 rounded text-xs font-semibold transition-colors duration-150
{{ if eq .Lang "en" }}bg-indigo-100 text-indigo-700{{ else }}text-gray-400 hover:text-gray-600{{ end }}">
🇬🇧
</a>
</div>
<!-- User avatar + logout -->
<div class="flex items-center gap-3 pl-4 border-l border-gray-100">
<div class="w-8 h-8 rounded-full bg-gray-200
flex items-center justify-center">
<i data-lucide="user" class="w-4 h-4 text-gray-500"></i>
</div>
<a href="/logout"
class="hidden sm:block text-sm text-gray-500 hover:text-gray-700
transition-colors duration-150">
Wyloguj
</a>
</div>
</div>
</div>
</header>Active state — nawigacja
Aktywna pozycja w menu wyroznia sie kolorystycznie:
| Stan | Klasy Tailwind |
|---|---|
| Aktywny | bg-indigo-50 text-indigo-700 |
| Domyslny | text-gray-600 hover:bg-gray-50 hover:text-gray-900 |
Aktywnosc okreslana jest przez CurrentPath przekazywany z Go handlera. Jezyk (Lang) przekazywany z session cookie (lang), domyslnie "pl":
go
func layoutData(r *http.Request) LayoutData {
return LayoutData{
CurrentPath: r.URL.Path,
PageTitle: getPageTitle(r.URL.Path),
User: middleware.GetUser(r.Context()),
Children: childService.GetLinkedChildren(r.Context(), user.ID),
ActiveChild: getActiveChild(r),
}
}
func getPageTitle(path string) string {
switch {
case path == "/dashboard":
return "Dashboard"
case strings.HasPrefix(path, "/content"):
return "Content"
case strings.HasPrefix(path, "/worlds"):
return "Swiaty"
case strings.HasPrefix(path, "/todo"):
return "Todo"
case strings.HasPrefix(path, "/settings"):
return "Ustawienia"
default:
return "Lumos Island"
}
}Tooltip na skróconym sidebarze (tablet)
Gdy sidebar jest skrocony (md:w-16, tylko ikony), pozycje menu maja tooltipy:
html
<a href="/dashboard" class="relative group ...">
<i data-lucide="layout-dashboard" class="w-5 h-5 flex-shrink-0"></i>
<span class="hidden lg:block">Dashboard</span>
<!-- Tooltip (widoczny tylko w trybie ikon) -->
<span class="absolute left-full ml-3 px-2.5 py-1.5
bg-gray-900 text-white text-xs font-medium rounded-lg
opacity-0 group-hover:opacity-100
pointer-events-none
transition-opacity duration-150
hidden md:block lg:hidden
whitespace-nowrap z-50">
Dashboard
</span>
</a>HTMX — globalny loading indicator
html
<!-- Cienki pasek ladowania na gorze strony -->
<div id="global-loader"
class="htmx-indicator fixed top-0 left-0 right-0 z-[100] h-0.5">
<div class="h-full bg-indigo-600 animate-pulse rounded-r-full"
style="width: 100%"></div>
</div>Konfiguracja HTMX, aby uzywal tego wskaznika:
html
<body hx-indicator="#global-loader">Go Template — rendering
go
package web
import (
"html/template"
"net/http"
"strings"
)
var templates *template.Template
func init() {
funcMap := template.FuncMap{
"hasPrefix": strings.HasPrefix,
"eq": func(a, b string) bool { return a == b },
}
templates = template.Must(
template.New("").Funcs(funcMap).ParseGlob("templates/**/*.html"),
)
}
func renderTemplate(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func renderPartial(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := templates.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "Template error", http.StatusInternalServerError)
}
}Podsumowanie struktury
templates/
layout.html <- bazowy layout (head, body, sidebar, topbar, content slot)
partials/
sidebar.html <- sidebar nawigacja
topbar.html <- gorny pasek
dashboard.html <- content slot: dashboard
content/
list.html <- content slot: lista zadan
detail.html <- content slot: szczegoly zadania
worlds/
list.html <- content slot: grid planet
detail.html <- content slot: szczegoly planety
todo.html <- content slot: todo lista
settings.html <- content slot: ustawienia
login.html <- osobny layout (bez sidebar/topbar)
partials/
auth/
code-tab.html
email-tab.html
content/
task-list.html <- partial: wiersze tabeli + paginacja
locations.html <- partial: opcje lokacji
todo/
list.html <- partial: wiersze todo + paginacja
row.html <- partial: pojedynczy wiersz (po approve)
worlds/
members.html <- partial: lista czlonkow