Ekran — Content Manager
Opis
Content Manager pozwala rodzicowi/nauczycielowi tworzyc i zarzadzac zadaniami edukacyjnymi. Sklada sie z dwoch widokow:
- Lista zadan — tabela z wyszukiwaniem, filtrami i paginacja HTMX
- Szczegoly zadania — formularz edycji z quiz builderem (Alpine.js)
ASCII Wireframe — Lista zadan
+--------+-----------------------------------------------------------+
| | Top Bar |
| LOGO | Content [Ania v] |
| +-----------------------------------------------------------+
| SIDE | |
| BAR | [ Szukaj zadania... ] [Typ v] [Wiek v] [Swiat v] |
| | |
| Dash. | +-----------------------------------------------------+ |
| *Cont* | | Tytul | Typ | Wiek | Swiat | Status |A| |
| Worlds | |----------------|--------|-------|--------|---------|--| |
| Todo | | Tabliczka mn. | Quiz | 7-9 | Matema | Aktywne |E| |
| ---- | | Stolice Europy | Quiz | 10-12 | Geogra | Szkic |E| |
| Settng | | Czytanie ze... | Task | 7-9 | Polski | Aktywne |E| |
| | | Figury geomet. | Quiz | 10-12 | Matema | Review |E| |
| | | Slowka ang. | Flash | 7-9 | Jezyki | Aktywne |E| |
| | |----------------|--------|-------|--------|---------|--| |
| | | Wyniki 1-20 z 47 | [<] [1] [2] [3] [>] | |
| | +-----------------------------------------------------+ |
| | |
| | [ + Nowe zadanie ] |
| | |
+--------+-----------------------------------------------------------+ASCII Wireframe — Szczegoly zadania
+--------+-----------------------------------------------------------+
| | Top Bar |
| LOGO | Content > Tabliczka mnozenia [Ania v] |
| +-----------------------------------------------------------+
| SIDE | |
| BAR | +----------------------------------+ +------------------+|
| | | Informacje podstawowe | | Podglad ||
| Dash. | | | | ||
| *Cont* | | Tytul: | | +-------------+ ||
| Worlds | | [ Tabliczka mnozenia ] | | | QUIZ | ||
| Todo | | | | | | ||
| ---- | | Opis: | | | Pytanie 1: | ||
| Settng | | [ Cwicz tabliczke mnozenia ] | | | 2 x 3 = ? | ||
| | | [ do 100. Kazde pytanie ma ] | | | | ||
| | | [ 4 odpowiedzi. ] | | | o 4 o 5 | ||
| | | | | | o 6 o 7 | ||
| | | Typ: [Quiz v] | | +-------------+ ||
| | | | | ||
| | | Grupy wiekowe: | +------------------+|
| | | [x] 6-7 [x] 8-9 [ ] 10-12 | |
| | | | |
| | | Swiat: [Matematyka v] | |
| | | Lokacja: [Mnozenie v ] | |
| | +----------------------------------+ |
| | |
| | +----------------------------------+ |
| | | Quiz Builder | |
| | | | |
| | | Pytanie 1: | |
| | | [ Ile to 2 x 3? ] | |
| | | [x] 6 [ ] 4 [ ] 8 [-] | |
| | | [ ] 9 [-] | |
| | | [+ Dodaj odpowiedz] | |
| | | | |
| | | Pytanie 2: | |
| | | [ Ile to 4 x 5? ] | |
| | | [x] 20 [ ] 15 [ ] 25 [-] | |
| | | [+ Dodaj odpowiedz] | |
| | | | |
| | | [+ Dodaj pytanie] | |
| | +----------------------------------+ |
| | |
| | +----------------------------------+ |
| | | Media | |
| | | [Przeciagnij pliki lub kliknij] | |
| | | img1.png img2.png | |
| | +----------------------------------+ |
| | |
| | [ Anuluj ] [ Zapisz szkic ] [ Publikuj ] |
| | |
+--------+-----------------------------------------------------------+Lista zadan — implementacja
Toolbar z wyszukiwaniem i filtrami
html
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
<!-- Search -->
<div class="relative flex-1 w-full">
<i data-lucide="search"
class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"></i>
<input type="search"
name="search"
placeholder="Szukaj zadania..."
hx-get="/partials/content/task-list"
hx-trigger="keyup changed delay:300ms"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='type_filter'],[name='age_filter'],[name='world_filter']"
class="w-full pl-10 pr-4 py-2.5 text-sm text-gray-900
bg-white border border-gray-300 rounded-lg
placeholder:text-gray-400
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500
transition-colors duration-150">
</div>
<!-- Filtry -->
<div class="flex items-center gap-3">
<select name="type_filter"
hx-get="/partials/content/task-list"
hx-trigger="change"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='search'],[name='age_filter'],[name='world_filter']"
class="px-3.5 py-2.5 text-sm text-gray-700 bg-white border border-gray-300
rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<option value="">Wszystkie typy</option>
<option value="quiz">Quiz</option>
<option value="task">Zadanie</option>
<option value="flashcard">Fiszki</option>
<option value="story">Opowiadanie</option>
</select>
<select name="age_filter"
hx-get="/partials/content/task-list"
hx-trigger="change"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='search'],[name='type_filter'],[name='world_filter']"
class="px-3.5 py-2.5 text-sm text-gray-700 bg-white border border-gray-300
rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<option value="">Wszystkie grupy</option>
<option value="6-7">6-7 lat</option>
<option value="8-9">8-9 lat</option>
<option value="10-12">10-12 lat</option>
</select>
<select name="world_filter"
hx-get="/partials/content/task-list"
hx-trigger="change"
hx-target="#table-body"
hx-swap="innerHTML transition:true"
hx-include="[name='search'],[name='type_filter'],[name='age_filter']"
class="px-3.5 py-2.5 text-sm text-gray-700 bg-white border border-gray-300
rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<option value="">Wszystkie swiaty</option>
{{ range .Worlds }}
<option value="{{ .ID }}">{{ .Name }}</option>
{{ end }}
</select>
</div>
<!-- New task button -->
<a href="/content/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>
Nowe zadanie
</a>
</div>Tabela
html
<div class="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-gray-100">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tytul
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Typ
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Grupa wiekowa
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Swiat
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Akcje
</th>
</tr>
</thead>
<tbody id="table-body"
hx-get="/partials/content/task-list?page=1&per_page=20"
hx-trigger="load"
hx-swap="innerHTML transition:true"
class="divide-y divide-gray-50">
<!-- Ladowane przez HTMX -->
</tbody>
</table>
<div id="pagination-controls"></div>
</div>Szczegoly zadania — formularz
Informacje podstawowe
html
<form hx-post="/content/{{ .Task.ID }}"
hx-target="body"
class="space-y-8">
<!-- Informacje podstawowe -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6 space-y-5">
<h2 class="text-lg font-display font-semibold text-gray-800">
Informacje podstawowe
</h2>
<!-- Tytul -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Tytul</label>
<input type="text" name="title" value="{{ .Task.Title }}"
class="w-full 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 focus:border-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="4"
class="w-full 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 focus:border-indigo-500
transition-colors duration-150">{{ .Task.Description }}</textarea>
</div>
<!-- Typ -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Typ zadania</label>
<select name="type"
class="w-full 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 focus:border-indigo-500
transition-colors duration-150">
<option value="quiz" {{ if eq .Task.Type "quiz" }}selected{{ end }}>Quiz</option>
<option value="task" {{ if eq .Task.Type "task" }}selected{{ end }}>Zadanie</option>
<option value="flashcard" {{ if eq .Task.Type "flashcard" }}selected{{ end }}>Fiszki</option>
<option value="story" {{ if eq .Task.Type "story" }}selected{{ end }}>Opowiadanie</option>
</select>
</div>
<!-- Grupy wiekowe (checkboxes) -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Grupy wiekowe</label>
<div class="flex flex-wrap gap-4">
{{ range .AgeGroups }}
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="age_groups" value="{{ .ID }}"
{{ if .IsSelected }}checked{{ end }}
class="w-4 h-4 text-indigo-600 border-gray-300 rounded
focus:ring-indigo-500 focus:ring-2">
<span class="text-sm text-gray-700">{{ .Label }}</span>
</label>
{{ end }}
</div>
</div>
<!-- Swiat i lokacja -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Swiat</label>
<select name="world_id"
hx-get="/partials/content/locations"
hx-trigger="change"
hx-target="#location-select"
hx-swap="innerHTML"
class="w-full 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">
<option value="">Wybierz swiat...</option>
{{ range .Worlds }}
<option value="{{ .ID }}" {{ if eq .ID $.Task.WorldID }}selected{{ end }}>
{{ .Name }}
</option>
{{ end }}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Lokacja</label>
<select name="location_id" id="location-select"
class="w-full 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">
<option value="">Wybierz lokacje...</option>
{{ range .Locations }}
<option value="{{ .ID }}" {{ if eq .ID $.Task.LocationID }}selected{{ end }}>
{{ .Name }}
</option>
{{ end }}
</select>
</div>
</div>
</div>Quiz Builder (Alpine.js)
html
<!-- Quiz Builder -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6"
x-data="quizBuilder({{ .Task.QuestionsJSON }})">
<h2 class="text-lg font-display font-semibold text-gray-800 mb-5">
Quiz Builder
</h2>
<div class="space-y-6">
<template x-for="(question, qi) in questions" :key="qi">
<div class="bg-gray-50 rounded-lg p-5 space-y-4">
<!-- Header pytania -->
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">
Pytanie <span x-text="qi + 1"></span>
</h4>
<button type="button" @click="removeQuestion(qi)"
class="text-red-500 hover:text-red-700 text-sm font-medium
transition-colors duration-150"
x-show="questions.length > 1">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
<!-- Tresc pytania -->
<input type="text"
x-model="question.text"
:name="'questions[' + qi + '].text'"
placeholder="Wpisz tresc pytania..."
class="w-full px-3.5 py-2.5 text-sm text-gray-900
border border-gray-300 rounded-lg bg-white
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<!-- Odpowiedzi -->
<div class="space-y-2.5 pl-4">
<template x-for="(answer, ai) in question.answers" :key="ai">
<div class="flex items-center gap-3">
<!-- Zaznacz poprawna -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio"
:name="'questions[' + qi + '].correct'"
:value="ai"
:checked="answer.isCorrect"
@change="setCorrect(qi, ai)"
class="w-4 h-4 text-indigo-600 border-gray-300
focus:ring-indigo-500">
</label>
<!-- Tresc odpowiedzi -->
<input type="text"
x-model="answer.text"
:name="'questions[' + qi + '].answers[' + ai + '].text'"
placeholder="Odpowiedz..."
class="flex-1 px-3 py-2 text-sm text-gray-900
border border-gray-200 rounded-lg bg-white
focus:outline-none focus:ring-2 focus:ring-indigo-500
transition-colors duration-150">
<!-- Usun odpowiedz -->
<button type="button" @click="removeAnswer(qi, ai)"
class="text-gray-400 hover:text-red-500
transition-colors duration-150"
x-show="question.answers.length > 2">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</template>
<!-- Dodaj odpowiedz -->
<button type="button" @click="addAnswer(qi)"
class="inline-flex items-center gap-1.5 text-sm text-indigo-600
hover:text-indigo-800 font-medium transition-colors duration-150">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
Dodaj odpowiedz
</button>
</div>
</div>
</template>
</div>
<!-- Dodaj pytanie -->
<button type="button" @click="addQuestion()"
class="mt-5 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 transition-colors duration-150">
<i data-lucide="plus" class="w-4 h-4"></i>
Dodaj pytanie
</button>
<!-- Hidden field z JSON dla serwera -->
<input type="hidden" name="questions_json" :value="JSON.stringify(questions)">
</div>Alpine.js — logika Quiz Buildera
html
<script>
function quizBuilder(initialQuestions) {
return {
questions: initialQuestions || [{
text: '',
answers: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
]
}],
addQuestion() {
this.questions.push({
text: '',
answers: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
]
});
},
removeQuestion(qi) {
this.questions.splice(qi, 1);
},
addAnswer(qi) {
this.questions[qi].answers.push({ text: '', isCorrect: false });
},
removeAnswer(qi, ai) {
// Jesli usuwamy poprawna odpowiedz, zaznacz pierwsza jako poprawna
if (this.questions[qi].answers[ai].isCorrect) {
const remaining = this.questions[qi].answers.filter((_, i) => i !== ai);
if (remaining.length > 0) remaining[0].isCorrect = true;
}
this.questions[qi].answers.splice(ai, 1);
},
setCorrect(qi, ai) {
this.questions[qi].answers.forEach((a, i) => {
a.isCorrect = (i === ai);
});
}
}
}
</script>Media upload
html
<!-- Media -->
<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">
Media
</h2>
<!-- Upload area -->
<div class="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center
hover:border-indigo-300 transition-colors duration-150"
x-data="fileUpload()"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="handleDrop($event)"
:class="dragging ? 'border-indigo-400 bg-indigo-50' : ''">
<i data-lucide="upload-cloud" class="w-10 h-10 text-gray-400 mx-auto"></i>
<p class="mt-2 text-sm text-gray-600">
Przeciagnij pliki lub
<label class="text-indigo-600 hover:text-indigo-800 font-medium cursor-pointer">
kliknij aby wybrac
<input type="file" multiple accept="image/*,audio/*" class="hidden"
@change="handleFiles($event)">
</label>
</p>
<p class="mt-1 text-xs text-gray-500">PNG, JPG, MP3 do 10MB</p>
</div>
<!-- Uploaded files -->
{{ if .Task.Media }}
<div class="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
{{ range .Task.Media }}
<div class="relative group rounded-lg overflow-hidden border border-gray-100">
<img src="{{ .ThumbnailURL }}" alt="{{ .Name }}"
class="w-full h-24 object-cover">
<button type="button"
hx-delete="/api/media/{{ .ID }}"
hx-target="closest div"
hx-swap="outerHTML"
class="absolute top-1 right-1 w-6 h-6 bg-black/50 rounded-full
flex items-center justify-center
opacity-0 group-hover:opacity-100
transition-opacity duration-150">
<i data-lucide="x" class="w-3 h-3 text-white"></i>
</button>
<div class="px-2 py-1.5">
<p class="text-xs text-gray-600 truncate">{{ .Name }}</p>
</div>
</div>
{{ end }}
</div>
{{ end }}
</div>Preview panel
html
<!-- Panel podgladu (prawa kolumna) -->
<div class="bg-white rounded-xl border border-gray-100 shadow-sm p-6 sticky top-24">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">
Podglad
</h3>
<!-- Symulacja wygladu w aplikacji -->
<div class="bg-gray-900 rounded-2xl p-4 aspect-[9/16] max-h-[480px] overflow-hidden">
<div class="bg-gray-800 rounded-xl p-4 h-full flex flex-col">
<div class="text-center mb-4">
<span class="text-xs text-gray-400 uppercase tracking-wider"
x-text="taskType"></span>
<h4 class="text-white text-sm font-semibold mt-1"
x-text="title || 'Tytul zadania'"></h4>
</div>
<!-- Tresc podgladu w zaleznosci od typu -->
<div class="flex-1 text-gray-300 text-xs">
<template x-if="taskType === 'quiz'">
<!-- Podglad quizu -->
</template>
</div>
</div>
</div>
</div>Footer formularza (akcje)
html
<!-- Akcje -->
<div class="flex items-center justify-between pt-4">
<a href="/content"
class="px-4 py-2.5 text-sm font-medium text-gray-700
rounded-lg hover:bg-gray-100
transition-colors duration-150">
Anuluj
</a>
<div class="flex items-center gap-3">
<button type="submit" name="action" value="draft"
class="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 transition-colors duration-150">
Zapisz szkic
</button>
<button type="submit" name="action" value="publish"
class="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">
Publikuj
</button>
</div>
</div>
</form>Layout strony szczegulow
html
<main class="max-w-7xl mx-auto px-6 py-8">
<!-- Breadcrumb -->
<nav class="flex items-center gap-2 text-sm text-gray-500 mb-6">
<a href="/content" class="hover:text-gray-700">Content</a>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
<span class="text-gray-900 font-medium">{{ .Task.Title }}</span>
</nav>
<!-- Dwie kolumny: formularz + podglad -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<!-- Formularz (informacje + quiz builder + media) -->
</div>
<div class="hidden lg:block">
<!-- Preview panel -->
</div>
</div>
</main>Endpointy
| Metoda | URL | Opis |
|---|---|---|
| GET | /content | Lista zadan (pelna strona) |
| GET | /content/new | Formularz nowego zadania |
| GET | /content/:id | Formularz edycji zadania |
| POST | /content | Utworzenie nowego zadania |
| POST | /content/:id | Aktualizacja zadania |
| DELETE | /content/:id | Usuniecie zadania |
| GET | /partials/content/task-list | Partial: wiersze tabeli + paginacja |
| GET | /partials/content/locations | Partial: opcje lokacji dla swiata |
| DELETE | /api/media/:id | Usuniecie pliku media |
Go Handler — lista
go
func ContentListHandler(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
worlds := worldService.GetUserWorlds(r.Context(), user.ID)
data := ContentListData{
Worlds: worlds,
}
renderTemplate(w, "content/list.html", data)
}Go Handler — szczegoly
go
func ContentDetailHandler(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r.Context())
taskID := r.PathValue("id")
task, err := taskService.GetByID(r.Context(), taskID)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
worlds := worldService.GetUserWorlds(r.Context(), user.ID)
locations := locationService.GetByWorldID(r.Context(), task.WorldID)
ageGroups := ageGroupService.GetAll(r.Context())
// Oznacz wybrane grupy wiekowe
for i := range ageGroups {
ageGroups[i].IsSelected = task.HasAgeGroup(ageGroups[i].ID)
}
data := ContentDetailData{
Task: task,
Worlds: worlds,
Locations: locations,
AgeGroups: ageGroups,
}
renderTemplate(w, "content/detail.html", data)
}