Skip to content

Manager — Layout i Nawigacja

Opis

Layout Managera sklada sie z trzech glownych czesci:

  1. Sidebar (lewy) — nawigacja glowna, logo
  2. Top Bar (gora) — tytul strony, child selector, language switcher (flagi PL/EN), avatar usera
  3. 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>

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:

StanKlasy Tailwind
Aktywnybg-indigo-50 text-indigo-700
Domyslnytext-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

Lumos Islands v2 - Dokumentacja Projektowa