Ekran — Custom Worlds
Opis
Ekran Custom Worlds pozwala tworzyc i zarzadzac wlasnymi swiatami (planetami) edukacyjnymi. Kazdy swiat zawiera lokacje, do ktorych przypisywane sa zadania. Rodzic/nauczyciel moze zapraszac innych uzytkownikow do swoich swiatow.
ASCII Wireframe — Grid planet
+--------+-----------------------------------------------------------+
| | Top Bar |
| LOGO | Swiaty [Ania v] |
| +-----------------------------------------------------------+
| SIDE | |
| BAR | [ + Nowa planeta ] |
| | |
| Dash. | +------------------+ +------------------+ +------------+ |
| Content| | [ img ] | | [ img ] | | [ img ] | |
| *World*| | | | | | | |
| Todo | | Matematyka | | Geografia | | Przyroda | |
| ---- | | | | | | | |
| Settng | | 5 lokacji | | 3 lokacje | | 7 lokacji | |
| | | 12 czlonkow | | 8 czlonkow | | 4 czlonk. | |
| | | | | | | | |
| | | [Aktywny] | | [Aktywny] | | [Szkic] | |
| | +------------------+ +------------------+ +------------+ |
| | |
| | +------------------+ |
| | | [ + ] | |
| | | | |
| | | Stworz nowy | |
| | | swiat | |
| | +------------------+ |
| | |
+--------+-----------------------------------------------------------+ASCII Wireframe — Szczegoly planety
+--------+-----------------------------------------------------------+
| | Top Bar |
| LOGO | Swiaty > Matematyka [Ania v] |
| +-----------------------------------------------------------+
| SIDE | |
| BAR | +----------------------------------+ |
| | | Informacje o planecie | |
| Dash. | | | |
| Content| | Nazwa: [ Matematyka ] | |
| *World*| | | |
| Todo | | Opis: [ Swiat liczb i ] | |
| ---- | | [ geometrii. ] | |
| Settng | | | |
| | | Obraz: [Zmien obraz] (prev) | |
| | | | |
| | | Dostep: (o) Otwarty (o) Zamkniety| |
| | | | |
| | | [ Zapisz zmiany ] | |
| | +----------------------------------+ |
| | |
| | +----------------------------------+ |
| | | Lokacje [+Dodaj] | |
| | | | |
| | | :: 1. Dodawanie [E] [x] | |
| | | :: 2. Odejmowanie [E] [x] | |
| | | :: 3. Mnozenie [E] [x] | |
| | | :: 4. Dzielenie [E] [x] | |
| | | :: 5. Ulamki [E] [x] | |
| | | | |
| | | :: = drag handle | |
| | +----------------------------------+ |
| | |
| | +----------------------------------+ |
| | | Czlonkowie [Zapros]| |
| | | | |
| | | [ Szukaj czlonkow... ] | |
| | | | |
| | | Jan Kowalski rodzic [Usun] | |
| | | Anna Nowak rodzic [Usun] | |
| | | Szkola #4 szkola [Usun] | |
| | +----------------------------------+ |
| | |
+--------+-----------------------------------------------------------+Grid planet — implementacja
html
<main class="max-w-7xl mx-auto px-6 py-8">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-display font-bold text-gray-900">Swiaty</h1>
<p class="mt-1 text-sm text-gray-500">Zarzadzaj swoimi planetami edukacyjnymi</p>
</div>
<a href="/worlds/new"
class="inline-flex items-center gap-2 px-4 py-2.5
bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
transition-colors duration-150">
<i data-lucide="plus" class="w-4 h-4"></i>
Nowa planeta
</a>
</div>
<!-- Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{{ range .Worlds }}
<a href="/worlds/{{ .ID }}"
class="group bg-white rounded-xl border border-gray-100 shadow-sm
overflow-hidden hover:shadow-md transition-shadow duration-200">
<!-- Obraz planety -->
<div class="aspect-[16/9] bg-gray-100 overflow-hidden">
{{ if .ImageURL }}
<img src="{{ .ImageURL }}" alt="{{ .Name }}"
class="w-full h-full object-cover
group-hover:scale-105 transition-transform duration-300">
{{ else }}
<div class="w-full h-full flex items-center justify-center bg-gradient-to-br from-indigo-100 to-indigo-200">
<i data-lucide="globe" class="w-12 h-12 text-indigo-400"></i>
</div>
{{ end }}
</div>
<!-- Tresc -->
<div class="p-5">
<div class="flex items-start justify-between">
<h3 class="text-lg font-display font-semibold text-gray-800
group-hover:text-indigo-600 transition-colors duration-150">
{{ .Name }}
</h3>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ if eq .Status "active" }}bg-green-50 text-green-700{{ end }}
{{ if eq .Status "draft" }}bg-gray-100 text-gray-600{{ end }}">
{{ .StatusLabel }}
</span>
</div>
<div class="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span class="inline-flex items-center gap-1.5">
<i data-lucide="map-pin" class="w-3.5 h-3.5"></i>
{{ .LocationsCount }} lokacji
</span>
<span class="inline-flex items-center gap-1.5">
<i data-lucide="users" class="w-3.5 h-3.5"></i>
{{ .MembersCount }} czlonkow
</span>
</div>
</div>
</a>
{{ end }}
<!-- Karta "Stworz nowy" -->
<a href="/worlds/new"
class="group flex flex-col items-center justify-center
bg-gray-50 rounded-xl border-2 border-dashed border-gray-200
min-h-[250px]
hover:border-indigo-300 hover:bg-indigo-50
transition-all duration-200">
<div class="w-12 h-12 bg-white rounded-xl shadow-sm
flex items-center justify-center mb-3
group-hover:shadow-md transition-shadow duration-200">
<i data-lucide="plus" class="w-6 h-6 text-gray-400
group-hover:text-indigo-600
transition-colors duration-150"></i>
</div>
<span class="text-sm font-medium text-gray-500
group-hover:text-indigo-600 transition-colors duration-150">
Stworz nowy swiat
</span>
</a>
</div>
</main>Szczegoly planety — informacje
html
<main class="max-w-7xl mx-auto px-6 py-8 space-y-8">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-gray-500">
<a href="/worlds" class="hover:text-gray-700">Swiaty</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="text-gray-900 font-medium">{{ .World.Name }}</span>
</nav>
<!-- Informacje o planecie -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6">
<h2 class="text-lg font-display font-semibold text-gray-800 mb-5">
Informacje o planecie
</h2>
<form hx-post="/worlds/{{ .World.ID }}"
hx-target="this"
hx-swap="outerHTML"
class="space-y-5">
<!-- Nazwa -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Nazwa</label>
<input type="text" name="name" value="{{ .World.Name }}"
class="w-full max-w-md px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150"
required>
</div>
<!-- Opis -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Opis</label>
<textarea name="description" rows="3"
class="w-full max-w-md px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg resize-none
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">{{ .World.Description }}</textarea>
</div>
<!-- Obraz -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Obraz planety</label>
<div class="flex items-center gap-4">
{{ if .World.ImageURL }}
<img src="{{ .World.ImageURL }}" alt="{{ .World.Name }}"
class="w-20 h-20 rounded-xl object-cover border border-gray-100">
{{ end }}
<label class="inline-flex items-center gap-2 px-4 py-2.5
bg-white text-gray-700 text-sm font-medium
rounded-lg border border-gray-300 shadow-sm
hover:bg-gray-50 cursor-pointer
transition-colors duration-150">
<i data-lucide="upload" class="w-4 h-4"></i>
Zmien obraz
<input type="file" name="image" accept="image/*" class="hidden">
</label>
</div>
</div>
<!-- Dostep -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Dostep</label>
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="access" value="open"
{{ if eq .World.Access "open" }}checked{{ end }}
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500">
<span class="text-sm text-gray-700">Otwarty</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="access" value="closed"
{{ if eq .World.Access "closed" }}checked{{ end }}
class="w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500">
<span class="text-sm text-gray-700">Zamkniety</span>
</label>
</div>
<p class="mt-1 text-xs text-gray-500">
Otwarty swiat jest widoczny dla wszystkich. Zamkniety wymaga zaproszenia.
</p>
</div>
<!-- Submit -->
<div class="pt-2">
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2.5
bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
transition-colors duration-150">
Zapisz zmiany
</button>
</div>
</form>
</div>Location Manager (z drag-to-reorder)
html
<!-- Lokacje -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 class="text-lg font-display font-semibold text-gray-800">
Lokacje
</h2>
<button @click="showLocationModal = true"
class="inline-flex items-center gap-2 px-3.5 py-2
bg-indigo-600 text-white text-sm font-medium
rounded-lg shadow-sm hover:bg-indigo-700
transition-colors duration-150">
<i data-lucide="plus" class="w-4 h-4"></i>
Dodaj
</button>
</div>
<!-- Lista lokacji z drag -->
<div id="locations-list"
x-data="locationSortable()"
x-init="initSortable()"
class="divide-y divide-gray-50">
{{ range .Locations }}
<div class="flex items-center gap-4 px-6 py-4
hover:bg-gray-50 transition-colors duration-150"
data-location-id="{{ .ID }}">
<!-- Drag handle -->
<div class="cursor-grab active:cursor-grabbing text-gray-400
hover:text-gray-600 transition-colors duration-150">
<i data-lucide="grip-vertical" class="w-4 h-4"></i>
</div>
<!-- Numer porzadkowy -->
<span class="w-6 h-6 bg-gray-100 rounded-full flex items-center justify-center
text-xs font-medium text-gray-500">
{{ .Order }}
</span>
<!-- Nazwa -->
<span class="flex-1 text-sm font-medium text-gray-900">
{{ .Name }}
</span>
<!-- Licznik zadan -->
<span class="text-xs text-gray-500">
{{ .TasksCount }} zadan
</span>
<!-- Akcje -->
<div class="flex items-center gap-2">
<button hx-get="/partials/worlds/{{ $.World.ID }}/locations/{{ .ID }}/edit"
hx-target="closest div"
hx-swap="outerHTML"
class="text-gray-400 hover:text-indigo-600
transition-colors duration-150">
<i data-lucide="pencil" class="w-4 h-4"></i>
</button>
<button hx-delete="/worlds/{{ $.World.ID }}/locations/{{ .ID }}"
hx-target="closest div"
hx-swap="outerHTML swap:200ms"
hx-confirm="Usunac lokacje {{ .Name }}?"
class="text-gray-400 hover:text-red-600
transition-colors duration-150">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</div>
{{ end }}
</div>
{{ if not .Locations }}
<div class="flex flex-col items-center justify-center py-12">
<i data-lucide="map-pin" class="w-8 h-8 text-gray-300 mb-3"></i>
<p class="text-sm text-gray-500">Brak lokacji. Dodaj pierwsza lokacje.</p>
</div>
{{ end }}
</div>Alpine.js — Sortable
html
<script>
function locationSortable() {
return {
initSortable() {
// Uzyj Sortable.js do drag-and-drop
new Sortable(this.$el, {
handle: '[data-lucide="grip-vertical"]',
animation: 200,
ghostClass: 'bg-indigo-50',
onEnd: (evt) => {
const ids = Array.from(this.$el.children).map(
el => el.dataset.locationId
);
// Wyslij nowa kolejnosc do serwera
fetch(`/worlds/{{ .World.ID }}/locations/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: ids }),
});
}
});
}
}
}
</script>Lista czlonkow
html
<!-- Czlonkowie -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 class="text-lg font-display font-semibold text-gray-800">
Czlonkowie
</h2>
<button @click="showInviteModal = true"
class="inline-flex items-center gap-2 px-3.5 py-2
bg-white text-gray-700 text-sm font-medium
rounded-lg border border-gray-300 shadow-sm
hover:bg-gray-50 transition-colors duration-150">
<i data-lucide="user-plus" class="w-4 h-4"></i>
Zapros
</button>
</div>
<!-- Search -->
<div class="px-6 py-3 border-b border-gray-50">
<div class="relative">
<i data-lucide="search"
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"></i>
<input type="search"
placeholder="Szukaj czlonkow..."
hx-get="/partials/worlds/{{ .World.ID }}/members"
hx-trigger="keyup changed delay:300ms"
hx-target="#members-list"
hx-swap="innerHTML"
class="w-full pl-10 pr-4 py-2 text-sm text-gray-900
border border-gray-200 rounded-lg
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
</div>
</div>
<!-- Lista -->
<div id="members-list" class="divide-y divide-gray-50">
{{ range .Members }}
<div class="flex items-center justify-between px-6 py-3.5
hover:bg-gray-50 transition-colors duration-150">
<div class="flex items-center gap-3">
<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>
<p class="text-sm font-medium text-gray-900">{{ .Name }}</p>
<p class="text-xs text-gray-500">{{ .RoleLabel }}</p>
</div>
</div>
{{ if ne .Role "owner" }}
<button hx-delete="/worlds/{{ $.World.ID }}/members/{{ .ID }}"
hx-target="closest div"
hx-swap="outerHTML swap:200ms"
hx-confirm="Usunac {{ .Name }} z tego swiata?"
class="text-sm text-red-500 hover:text-red-700 font-medium
transition-colors duration-150">
Usun
</button>
{{ end }}
</div>
{{ end }}
</div>
</div>
</main>Endpointy
| Metoda | URL | Opis |
|---|---|---|
| GET | /worlds | Grid planet (pelna strona) |
| GET | /worlds/new | Formularz nowej planety |
| GET | /worlds/:id | Szczegoly planety |
| POST | /worlds | Utworzenie nowej planety |
| POST | /worlds/:id | Aktualizacja planety |
| DELETE | /worlds/:id | Usuniecie planety |
| POST | /worlds/:id/locations | Dodanie lokacji |
| PUT | /worlds/:id/locations/:lid | Edycja lokacji |
| DELETE | /worlds/:id/locations/:lid | Usuniecie lokacji |
| POST | /worlds/:id/locations/reorder | Zmiana kolejnosci lokacji |
| GET | /partials/worlds/:id/members | Partial: lista czlonkow |
| GET | /partials/worlds/:id/locations/:lid/edit | Partial: formularz edycji lokacji |
| POST | /worlds/:id/members/invite | Zaproszenie czlonka |
| DELETE | /worlds/:id/members/:mid | Usuniecie czlonka |