diff --git a/LICENSE b/LICENSE
index 5ef3af9..3e22644 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 gopl.dev
+Copyright (c) 2026 gopl.dev Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/SETUP.md b/SETUP.md
new file mode 100644
index 0000000..0b00fe3
--- /dev/null
+++ b/SETUP.md
@@ -0,0 +1,100 @@
+# Development Setup
+
+Guide on how to set up your local instance of [gopl-dev/server](https://github.com/gopl-dev/server).
+
+## Prerequisites
+
+- [Git](https://git-scm.com/)
+- [Go 1.26+](https://golang.org/dl/)
+- [PostgreSQL 18](https://www.postgresql.org/download/)
+
+If you are working on the frontend, you will also need:
+- [templ](https://templ.guide/)
+- [TailwindCSS](https://tailwindcss.com/)
+
+## Setup
+
+1. **Clone the repo:**
+ ```bash
+ git clone https://github.com/gopl-dev/server.git
+ cd server
+ ```
+
+2. **Run the setup wizard:**
+ ```bash
+ go run ./cmd/setup_wizard/main.go
+ ```
+ This tool checks your DB connection and creates the necessary configuration files.
+
+
+ Manual setup (alternative):
+
+ 1. Copy `config.sample.yaml` to `.config.yaml` and edit the values. At least a DB connection is required for startup.
+ > **Tip:** It's recommended to use a `_local_dev` suffix for the DB name (e.g., `myapp_local_dev`). The reset tool uses this convention to prevent accidental data loss.
+ 2. Create test configurations in:
+ - `test/api_test/.config.yaml`
+ - `test/service_test/.config.yaml`
+ - `test/worker_test/.config.yaml`
+ 3. For each test config, update the following:
+ - Set a test DB connection (e.g., `myapp_local_dev_test`).
+ - `email.driver: "test"`
+ - `tracing.enabled: false`
+ - `files.storage_driver: "in-memory-fs"`
+
+
+
+3. **Run tests:**
+ ```bash
+ go test ./...
+ ```
+
+4. **Start the server:**
+ ```bash
+ go run ./cmd/server/main.go
+ ```
+
+## Seeding
+To populate the database with test data, use the CLI tool:
+```bash
+go run ./cmd/cli/main.go sd
+```
+By default, it seeds all available entities. You can specify a specific entity and count:
+* **Example:** `go run ./cmd/cli/main.go sd users 1000` (creates 1000 users).
+* **Help:** `go run ./cmd/cli/main.go ? sd` for detailed options.
+
+## Environment Reset
+If you need a clean slate, run:
+```bash
+go run ./cmd/cli/main.go rde
+```
+This command recreates the database, applies migrations, and creates a default user.
+
+---
+
+## Toolchain
+
+### templ — Live Watch & Reload
+```bash
+go tool templ generate --watch --proxy="http://localhost:8080" --cmd="go run ./cmd/server/main.go"
+```
+
+### TailwindCSS — Watch
+```bash
+tailwindcss -i ./frontend/assets/input.css -o ./frontend/assets/output.css --watch
+```
+
+### Linting
+Requires [golangci-lint](https://golangci-lint.run/welcome/install/#local-installation)
+```bash
+golangci-lint run
+```
+
+### OpenAPI & Swagger
+Requires [swag](https://github.com/swaggo/swag)
+```bash
+# Format Swagger directives
+swag fmt --dir server/handler
+
+# Generate specifications
+swag init --parseDependency --parseDepth 1 --dir server/handler -g handler.go -o server/docs
+```
\ No newline at end of file
diff --git a/WIP.md b/WIP.md
index aabe8b8..2b0526d 100644
--- a/WIP.md
+++ b/WIP.md
@@ -4,24 +4,22 @@ for simplicity, I keep todo list and progress here for now
**Before release**:
- [ ] Review "TODO!"s
-- [ ] Homepage
+- [x] Homepage
- [ ] Licence
-- [ ] RELEASE
+- [x] RELEASE
---
-AFTER RELEASE CHECKLIST:
-- [x] Setup & test email
-- [x] Setup & test auth & registration with google
-- [x] Setup & test auth & registration with github
-- [x] Setup & test tracing
-- [ ] Enable linting and testing on CI/CD
-- [ ] Disable push to main without MR
+Tweaks & fixes:
+- [ ] Add description to upload book cover input
+- [ ] When book cover upload ends with error, another cannot be added
-NEXT:
+
+TODO:
- [ ] Resend verification link (Sometimes email is lost in somewhere between the woods (Not mailman to blame))
- [ ] Create new topic when creating/editing entity
- [ ] Pages
- [ ] Add meta to page (Who created, last edit by who and list of activities on page (api call))
+ - [ ] Content chips
- [ ] Books
- [ ] Add subtitle
- [ ] Sort
@@ -53,3 +51,4 @@ NEXT:
- [ ] Let user continue work on reject entity and proposed changes
- [ ] Review "delete account" test. Right now, it passes even if models belonging to the user still exist.
- [ ] Order of props when reviewing changes and public diffs should be constant and predefined
+- [ ] Sitemap
diff --git a/app/config.go b/app/config.go
index d0b5427..65de321 100644
--- a/app/config.go
+++ b/app/config.go
@@ -67,12 +67,14 @@ type ConfigT struct {
Email struct {
// Driver can be: smtp or test
- Driver string `yaml:"driver"`
- From string `yaml:"from"`
- Host string `yaml:"host"`
- Port int `yaml:"port"`
- Username string `yaml:"username"`
- Password string `yaml:"password"` //nolint:gosec
+ Driver string `yaml:"driver"`
+ From string `yaml:"from"`
+ SMTP struct {
+ Host string `yaml:"host"`
+ Port int `yaml:"port"`
+ Username string `yaml:"username"`
+ Password string `yaml:"password"` //nolint:gosec
+ } `yaml:"smtp"`
} `yaml:"email"`
Session struct {
diff --git a/app/repo/page_repo.go b/app/repo/page_repo.go
index 81e49ff..fa936c8 100644
--- a/app/repo/page_repo.go
+++ b/app/repo/page_repo.go
@@ -30,6 +30,22 @@ func (r *Repo) GetPageByPublicID(ctx context.Context, publicID string) (*ds.Page
return page, err
}
+// GetPagesByPublicID retrieves pages by given public ID.
+func (r *Repo) GetPagesByPublicID(ctx context.Context, publicIDs ...string) (pages []ds.Page, err error) {
+ _, span := r.tracer.Start(ctx, "GetPageByPublicID")
+ defer span.End()
+
+ if len(publicIDs) == 0 {
+ return
+ }
+
+ pages = make([]ds.Page, 0, len(publicIDs))
+ const query = `SELECT * FROM entities e JOIN pages p USING (id) WHERE e.public_id = ANY($1) AND e.type = $2 AND e.deleted_at IS NULL`
+
+ err = pgxscan.Select(ctx, r.getDB(ctx), &pages, query, publicIDs, ds.EntityTypePage)
+ return
+}
+
// GetPageByID retrieves a page by its ID.
func (r *Repo) GetPageByID(ctx context.Context, id ds.ID) (*ds.Page, error) {
_, span := r.tracer.Start(ctx, "GetPageByID")
diff --git a/app/service/page_service.go b/app/service/page_service.go
index 5705952..31440c1 100644
--- a/app/service/page_service.go
+++ b/app/service/page_service.go
@@ -11,7 +11,7 @@ import (
"github.com/gopl-dev/server/email"
)
-// GetPageByPublicID retrieves a page by its public identifier.
+// GetPageByPublicID retrieves a page by its public ID.
func (s *Service) GetPageByPublicID(ctx context.Context, id string) (*ds.Page, error) {
ctx, span := s.tracer.Start(ctx, "GetPageByPublicID")
defer span.End()
@@ -19,6 +19,14 @@ func (s *Service) GetPageByPublicID(ctx context.Context, id string) (*ds.Page, e
return s.db.GetPageByPublicID(ctx, id)
}
+// GetPagesByPublicID retrieves pages by given public IDs.
+func (s *Service) GetPagesByPublicID(ctx context.Context, id ...string) ([]ds.Page, error) {
+ ctx, span := s.tracer.Start(ctx, "GetPagesByPublicID")
+ defer span.End()
+
+ return s.db.GetPagesByPublicID(ctx, id...)
+}
+
// GetPageByID retrieves a page by its ID from the database.
func (s *Service) GetPageByID(ctx context.Context, id ds.ID) (*ds.Page, error) {
ctx, span := s.tracer.Start(ctx, "GetBookByID")
diff --git a/cmd/setup_wizard/main.go b/cmd/setup_wizard/main.go
new file mode 100644
index 0000000..32b6ec8
--- /dev/null
+++ b/cmd/setup_wizard/main.go
@@ -0,0 +1,868 @@
+// Package main provides a CLI setup wizard for the gopl-server.
+// It guides the user through database configuration, validates connections,
+// and generates the necessary .config.yaml files for local development.
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/gopl-dev/server/app"
+ "github.com/jackc/pgx/v5"
+ "gopkg.in/yaml.v3"
+)
+
+var (
+ errYAMLNodeUnsupportedValueType = errors.New("unsupported value type")
+ errEmptyYAML = errors.New("empty yaml document")
+ errSectionNotFound = errors.New("section not found")
+ errSubsectionNotFound = errors.New("subsection not found")
+ errKeyNotFound = errors.New("key not found")
+ errDatabaseNotEmpty = errors.New("database already has tables")
+)
+
+// ── Styles ────────────────────────────────────────────────────────────────────
+
+var (
+ swTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
+ swSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
+ swFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+ swBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+ swHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
+ swSuccessStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42"))
+ swErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
+ swDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
+)
+
+// ── Async messages ────────────────────────────────────────────────────────────
+
+type msgConnectResult struct{ err error }
+type msgDBCheckResult struct {
+ created bool
+ err error
+}
+
+// ── Step state ────────────────────────────────────────────────────────────────
+
+// stepState holds the runtime state for a single step.
+// extra is used for step-specific non-input state (e.g. selects, flags).
+type stepState struct {
+ inputs []textinput.Model
+ focused int
+ extra any
+ err string
+}
+
+// val returns the input value at index i, falling back to the placeholder if empty.
+func (s stepState) val(i int) string {
+ v := s.inputs[i].Value()
+ if v == "" {
+ return s.inputs[i].Placeholder
+ }
+ return v
+}
+
+// focusInput blurs the currently focused input and focuses the one at index to.
+func (s stepState) focusInput(to int) stepState {
+ if s.focused < len(s.inputs) {
+ s.inputs[s.focused].Blur()
+ s.inputs[s.focused].PromptStyle = swBlurredStyle
+ s.inputs[s.focused].TextStyle = swBlurredStyle
+ }
+ s.inputs[to].Focus()
+ s.inputs[to].PromptStyle = swFocusedStyle
+ s.inputs[to].TextStyle = swFocusedStyle
+ s.focused = to
+ return s
+}
+
+// makeInput creates a styled textinput with optional password echo mode.
+func makeInput(secret bool) textinput.Model {
+ ti := textinput.New()
+ ti.CharLimit = 256
+ ti.PromptStyle = swBlurredStyle
+ ti.TextStyle = swBlurredStyle
+ if secret {
+ ti.EchoMode = textinput.EchoPassword
+ ti.EchoCharacter = '•'
+ }
+ return ti
+}
+
+// renderInput renders a labeled textinput row, highlighted when focused.
+func renderInput(s stepState, idx int, label string) string {
+ var b strings.Builder
+ if idx == s.focused {
+ b.WriteString(swFocusedStyle.Render("▶ "+label) + "\n")
+ } else {
+ b.WriteString(swBlurredStyle.Render(" "+label) + "\n")
+ }
+ b.WriteString(s.inputs[idx].View() + "\n\n")
+ return b.String()
+}
+
+// renderError renders a red error line prefixed with ✗. Returns empty string if err is empty.
+func renderError(err string) string {
+ if err == "" {
+ return ""
+ }
+ return "\n" + swErrorStyle.Render("✗ "+err) + "\n"
+}
+
+// ── Step definition ───────────────────────────────────────────────────────────
+
+// Step describes a single wizard step.
+type Step struct {
+ // Init returns the initial state for this step.
+ Init func() stepState
+
+ // View renders the step UI given its current state.
+ View func(s stepState) string
+
+ // HandleKey processes a key event and returns the updated state.
+ // Returns (state, cmd, advance) where advance=true means move to next step.
+ HandleKey func(s stepState, msg tea.KeyMsg) (stepState, tea.Cmd, bool)
+
+ // HandleMsg processes an async tea.Msg (e.g. DB connect result).
+ // Returns (state, cmd, advance, ok) where ok=false means stay on this step.
+ HandleMsg func(s stepState, msg tea.Msg) (stepState, tea.Cmd, bool, bool)
+
+ // Values returns section→key→value triples used when writing the config.
+ Values func(s stepState) []yamlValue
+}
+
+// ── DB credentials step ───────────────────────────────────────────────────────
+
+const (
+ dbIdxHost = 0
+ dbIdxUser = 1
+ dbIdxPass = 2
+)
+
+var stepDBCredentials = Step{
+ Init: func() stepState {
+ inputs := []textinput.Model{
+ makeInput(false),
+ makeInput(false),
+ makeInput(true),
+ }
+ s := stepState{inputs: inputs}
+ return s.focusInput(dbIdxHost)
+ },
+
+ View: func(s stepState) string {
+ var b strings.Builder
+ b.WriteString(swSectionStyle.Render("── Database connection ──") + "\n\n")
+ b.WriteString(renderInput(s, dbIdxHost, "Host:Port"))
+ b.WriteString(renderInput(s, dbIdxUser, "User"))
+ b.WriteString(renderInput(s, dbIdxPass, "Password"))
+ b.WriteString(renderError(s.err))
+ if s.focused == dbIdxPass {
+ b.WriteString("\n" + swDimStyle.Render("Press enter to test connection") + "\n")
+ }
+ return b.String()
+ },
+
+ HandleKey: func(s stepState, msg tea.KeyMsg) (stepState, tea.Cmd, bool) {
+ switch msg.Type {
+ case tea.KeyEnter, tea.KeyTab, tea.KeyDown:
+ if s.focused < dbIdxPass {
+ return s.focusInput(s.focused + 1), textinput.Blink, false
+ }
+ host := s.val(dbIdxHost)
+ user := s.val(dbIdxUser)
+ pass := s.val(dbIdxPass)
+ return s, func() tea.Msg {
+ conn, err := pgx.Connect(context.Background(), buildConnStr(host, user, pass, "postgres"))
+ if err != nil {
+ return msgConnectResult{err: err}
+ }
+ _ = conn.Close(context.Background())
+ return msgConnectResult{}
+ }, false
+ case tea.KeyShiftTab, tea.KeyUp:
+ if s.focused > dbIdxHost {
+ return s.focusInput(s.focused - 1), textinput.Blink, false
+ }
+ }
+ return s, nil, false
+ },
+
+ HandleMsg: func(s stepState, msg tea.Msg) (stepState, tea.Cmd, bool, bool) {
+ r, ok := msg.(msgConnectResult)
+ if !ok {
+ return s, nil, false, false
+ }
+ if r.err != nil {
+ s.err = r.err.Error()
+ return s, nil, false, true
+ }
+ s.err = ""
+ return s, nil, true, true
+ },
+
+ Values: func(s stepState) []yamlValue {
+ host, port, _ := strings.Cut(s.val(dbIdxHost), ":")
+ if port == "" {
+ port = "5432"
+ }
+ return []yamlValue{
+ yv("db", "host", host),
+ yv("db", "port", port),
+ yv("db", "user", s.val(dbIdxUser)),
+ yv("db", "password", s.val(dbIdxPass)),
+ }
+ },
+}
+
+// ── DB name step ──────────────────────────────────────────────────────────────
+
+const dbNameIdx = 0
+
+var stepDBName = Step{
+ Init: func() stepState {
+ inputs := []textinput.Model{makeInput(false)}
+ s := stepState{inputs: inputs}
+ return s.focusInput(dbNameIdx)
+ },
+
+ View: func(s stepState) string {
+ var b strings.Builder
+ b.WriteString(swSuccessStyle.Render("✓ Connected successfully") + "\n\n")
+ b.WriteString(swSectionStyle.Render("── Database name ──") + "\n\n")
+ b.WriteString(renderInput(s, dbNameIdx, "DB Name"))
+ b.WriteString(swHelpStyle.Render(" If the database does not exist, we'll try to create it") + "\n")
+ b.WriteString(swHelpStyle.Render(" Tip: use a \"_local_dev\" suffix (e.g. gopl_local_dev) —") + "\n")
+ b.WriteString(swHelpStyle.Render(" the reset tool uses this convention to prevent accidents") + "\n")
+ b.WriteString(renderError(s.err))
+ return b.String()
+ },
+
+ HandleKey: func(s stepState, msg tea.KeyMsg) (stepState, tea.Cmd, bool) {
+ switch msg.Type {
+ case tea.KeyEnter, tea.KeyTab, tea.KeyDown:
+ dbName := s.val(dbNameIdx)
+
+ creds, ok := s.extra.(dbCreds)
+ if !ok {
+ s.err = "internal error: database credentials not found"
+ return s, nil, false
+ }
+
+ if dbName != "" && dbName == creds.originalName {
+ return s, func() tea.Msg {
+ return msgDBCheckResult{created: false, err: nil}
+ }, false
+ }
+
+ return s, func() tea.Msg {
+ return checkDB(creds.host, creds.user, creds.pass, dbName)
+ }, false
+ }
+ return s, nil, false
+ },
+
+ HandleMsg: func(s stepState, msg tea.Msg) (stepState, tea.Cmd, bool, bool) {
+ r, ok := msg.(msgDBCheckResult)
+ if !ok {
+ return s, nil, false, false
+ }
+ if r.err != nil {
+ s.err = r.err.Error()
+ return s, nil, false, true
+ }
+ s.err = ""
+ return s, nil, true, true
+ },
+
+ Values: func(_ stepState) []yamlValue { return nil },
+}
+
+// dbCreds is passed as extra to stepDBName so it can fire the async check.
+type dbCreds struct {
+ host, user, pass string
+ originalName string // Добавляем это поле
+}
+
+// checkDB connects to dbName, creating it if it doesn't exist, and verifies it has no tables.
+func checkDB(hostPort, user, pass, dbName string) tea.Msg {
+ ctx := context.Background()
+ connStr := buildConnStr(hostPort, user, pass, dbName)
+ conn, err := pgx.Connect(ctx, connStr)
+ created := false
+
+ if err != nil {
+ pgConn, pgErr := pgx.Connect(ctx, buildConnStr(hostPort, user, pass, "postgres"))
+ if pgErr != nil {
+ return msgDBCheckResult{err: pgErr}
+ }
+ _, pgErr = pgConn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %q", dbName))
+ _ = pgConn.Close(ctx)
+ if pgErr != nil {
+ return msgDBCheckResult{err: fmt.Errorf("create database: %w", pgErr)}
+ }
+ created = true
+
+ conn, err = pgx.Connect(ctx, connStr)
+ if err != nil {
+ return msgDBCheckResult{err: err}
+ }
+ }
+ defer func() {
+ _ = conn.Close(ctx)
+ }()
+
+ var count int
+ err = conn.QueryRow(ctx,
+ "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'",
+ ).Scan(&count)
+ if err != nil {
+ return msgDBCheckResult{err: fmt.Errorf("query tables: %w", err)}
+ }
+ if count > 0 {
+ return msgDBCheckResult{err: fmt.Errorf("%w: %s (%d tables)", errDatabaseNotEmpty, dbName, count)}
+ }
+ return msgDBCheckResult{created: created}
+}
+
+// ── Step registry ─────────────────────────────────────────────────────────────
+
+// allSteps defines the wizard flow in order.
+// To add a new step: append a Step{} value here.
+// To remove a step: delete its entry.
+var allSteps = []Step{
+ stepDBCredentials,
+ stepDBName,
+}
+
+const (
+ stepIdxDBCreds = 0
+ stepIdxDBName = 1
+)
+
+// ── Model ─────────────────────────────────────────────────────────────────────
+
+type swModel struct {
+ stepIdx int
+ states []stepState
+ done bool
+ dbInfo dbCreds // passed to stepDBName as extra
+ dbName string // resolved after stepDBName
+ created bool // whether the DB was just created
+ writeErr string // error from writing .config.yaml
+}
+
+// newSWModel initializes the wizard model. If ex is non-nil, inputs are pre-populated
+// from the existing config so the user can edit rather than retype unchanged values.
+// sample is used for placeholder defaults (loaded from config.sample.yaml).
+func newSWModel(ex *app.ConfigT, sample *app.ConfigT) swModel {
+ states := make([]stepState, len(allSteps))
+ for i, step := range allSteps {
+ states[i] = step.Init()
+ }
+
+ // apply sample defaults as placeholders
+ if sample != nil {
+ sampleHostPort := sample.DB.Host
+ if sample.DB.Port != "" && !strings.Contains(sampleHostPort, ":") {
+ sampleHostPort = sampleHostPort + ":" + sample.DB.Port
+ }
+ states[stepIdxDBCreds].inputs[dbIdxHost].Placeholder = sampleHostPort
+ states[stepIdxDBCreds].inputs[dbIdxUser].Placeholder = sample.DB.User
+ states[stepIdxDBName].inputs[dbNameIdx].Placeholder = sample.DB.Name
+ }
+
+ m := swModel{states: states}
+
+ if ex == nil {
+ return m
+ }
+
+ // pre-populate inputs from existing .config.yaml
+ hostPort := ex.DB.Host
+ if ex.DB.Port != "" && !strings.Contains(hostPort, ":") {
+ hostPort = hostPort + ":" + ex.DB.Port
+ }
+
+ setInputDefault(states[stepIdxDBCreds].inputs, dbIdxHost, hostPort)
+ setInputDefault(states[stepIdxDBCreds].inputs, dbIdxUser, ex.DB.User)
+ setInputDefault(states[stepIdxDBCreds].inputs, dbIdxPass, ex.DB.Password)
+ setInputDefault(states[stepIdxDBName].inputs, dbNameIdx, ex.DB.Name)
+
+ return m
+}
+
+// setInputDefault pre-fills an input with value so it appears as editable text.
+// The slice element must be passed directly (not via pointer) because textinput.Model is a struct.
+func setInputDefault(inputs []textinput.Model, idx int, value string) {
+ if value == "" {
+ return
+ }
+ inputs[idx].SetValue(value)
+}
+
+// ── Init / Update ─────────────────────────────────────────────────────────────
+
+func (m swModel) Init() tea.Cmd { return textinput.Blink }
+
+func (m swModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ // handle summary screen
+ if m.done {
+ if key, ok := msg.(tea.KeyMsg); ok {
+ switch key.Type {
+ case tea.KeyCtrlC, tea.KeyEsc:
+ return m, tea.Quit
+ case tea.KeyEnter:
+ err := swWriteConfig(m)
+ if err != nil {
+ m.writeErr = err.Error()
+ return m, nil
+ }
+ return m, tea.Quit
+ }
+ }
+ return m, nil
+ }
+
+ step := m.currentStep()
+ state := m.currentState()
+
+ // handle async messages first
+ if step.HandleMsg != nil {
+ if newState, cmd, advance, handled := step.HandleMsg(state, msg); handled {
+ m.states[m.stepIdx] = newState
+ if advance {
+ if m.stepIdx == stepIdxDBCreds {
+ m.dbInfo = dbCreds{
+ host: newState.val(dbIdxHost),
+ user: newState.val(dbIdxUser),
+ pass: newState.val(dbIdxPass),
+ originalName: "",
+ }
+ }
+ if m.stepIdx == stepIdxDBName {
+ if r, ok := msg.(msgDBCheckResult); ok {
+ m.dbName = newState.val(dbNameIdx)
+ m.created = r.created
+ }
+ }
+ m = m.advance()
+ }
+ return m, cmd
+ }
+ }
+
+ keyMsg, isKey := msg.(tea.KeyMsg)
+ if !isKey {
+ if state.focused >= 0 && state.focused < len(state.inputs) {
+ var cmd tea.Cmd
+ state.inputs[state.focused], cmd = state.inputs[state.focused].Update(msg)
+ m.states[m.stepIdx] = state
+ return m, cmd
+ }
+ return m, nil
+ }
+
+ switch keyMsg.Type {
+ case tea.KeyCtrlC, tea.KeyEsc:
+ return m, tea.Quit
+ }
+
+ newState, cmd, advance := step.HandleKey(state, keyMsg)
+ m.states[m.stepIdx] = newState
+ if advance {
+ m = m.advance()
+ }
+
+ if cmd == nil && !advance && newState.focused >= 0 && newState.focused < len(newState.inputs) {
+ var inputCmd tea.Cmd
+ m.states[m.stepIdx].inputs[newState.focused], inputCmd = newState.inputs[newState.focused].Update(keyMsg)
+ return m, inputCmd
+ }
+
+ return m, cmd
+}
+
+// ── View ──────────────────────────────────────────────────────────────────────
+
+func (m swModel) View() string {
+ if m.done {
+ return m.summaryView()
+ }
+
+ var b strings.Builder
+ b.WriteString(swTitleStyle.Render("gopl-server · Setup Wizard") + "\n")
+ b.WriteString(swHelpStyle.Render("tab/enter: next · shift+tab: prev · esc: cancel") + "\n\n")
+ b.WriteString(m.currentStep().View(m.currentState()))
+ return b.String()
+}
+
+// currentStep returns the Step definition for the active step index.
+func (m swModel) currentStep() Step { return allSteps[m.stepIdx] }
+
+// currentState returns the runtime state for the active step.
+func (m swModel) currentState() stepState { return m.states[m.stepIdx] }
+
+// advance moves to the next step, injecting dbCreds into stepDBName when needed.
+// Sets done=true when there are no more steps.
+func (m swModel) advance() swModel {
+ next := m.stepIdx + 1
+ if next >= len(allSteps) {
+ m.done = true
+ return m
+ }
+ m.stepIdx = next
+ // inject dbCreds into stepDBName so it can fire the async check
+ if next == stepIdxDBName {
+ info := m.dbInfo
+ info.originalName = m.states[next].val(dbNameIdx)
+ m.states[next].extra = info
+ }
+ return m
+}
+
+// summaryView renders the final confirmation screen showing collected values.
+func (m swModel) summaryView() string {
+ var b strings.Builder
+ b.WriteString(swTitleStyle.Render("gopl-server · Setup Wizard") + "\n\n")
+ b.WriteString(swSuccessStyle.Render("✓ Database is ready") + "\n")
+ if m.created {
+ b.WriteString(swDimStyle.Render(" (database was created)") + "\n")
+ }
+ b.WriteString("\n" + swSectionStyle.Render("── Summary ──") + "\n\n")
+
+ dbState := m.states[stepIdxDBCreds]
+ b.WriteString(swDimStyle.Render(" host:port ") + dbState.val(dbIdxHost) + "\n")
+ b.WriteString(swDimStyle.Render(" db user ") + dbState.val(dbIdxUser) + "\n")
+ b.WriteString(swDimStyle.Render(" db password ") + strings.Repeat("•", len(dbState.val(dbIdxPass))) + "\n")
+ b.WriteString(swDimStyle.Render(" db name ") + m.dbName + "\n")
+
+ b.WriteString("\n" + swSuccessStyle.Render(" Press enter to write .config.yaml") + "\n")
+ if m.writeErr != "" {
+ b.WriteString("\n" + swErrorStyle.Render("✗ "+m.writeErr) + "\n")
+ }
+ return b.String()
+}
+
+// ── Config writer ─────────────────────────────────────────────────────────────
+
+// buildConnStr builds a PostgreSQL DSN from hostPort (host:port), user, pass, and dbName.
+func buildConnStr(hostPort, user, pass, dbName string) string {
+ host, port, _ := strings.Cut(hostPort, ":")
+ if port == "" {
+ port = "5432"
+ }
+ return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
+ host, port, user, pass, dbName)
+}
+
+// ── YAML writer ───────────────────────────────────────────────────────────────
+
+// yamlValue represents a value to set at a path in a YAML document.
+// section is the top-level key, subsection is an optional nested key, key is the field.
+type yamlValue struct {
+ section string
+ subsection string
+ key string
+ value any
+}
+
+// yv is a shorthand constructor for yamlValue without a subsection.
+func yv(section, key string, value any) yamlValue {
+ return yamlValue{section: section, key: key, value: value}
+}
+
+// yvs is a shorthand constructor for yamlValue with a subsection.
+func yvs(section, subsection, key, value string) yamlValue {
+ return yamlValue{section: section, subsection: subsection, key: key, value: value}
+}
+
+// setYAMLValue finds section[.subsection].key in a yaml.Node document and sets its value.
+// Preserves comments and structure of the original document.
+func setYAMLValue(doc *yaml.Node, v yamlValue) error {
+ root := doc
+ if root.Kind == yaml.DocumentNode {
+ if len(root.Content) == 0 {
+ return errEmptyYAML
+ }
+ root = root.Content[0]
+ }
+
+ sectionNode := mappingValue(root, v.section)
+ if sectionNode == nil {
+ return fmt.Errorf("%w: %s", errSectionNotFound, v.section)
+ }
+
+ target := sectionNode
+ if v.subsection != "" {
+ target = mappingValue(sectionNode, v.subsection)
+ if target == nil {
+ return fmt.Errorf("%w: %s in %s", errSubsectionNotFound, v.subsection, v.section)
+ }
+ }
+
+ valueNode := mappingValue(target, v.key)
+ if valueNode == nil {
+ return fmt.Errorf("%w: %s in %s", errKeyNotFound, v.key, v.section)
+ }
+
+ switch val := v.value.(type) {
+ case bool:
+ valueNode.Kind = yaml.ScalarNode
+ valueNode.Value = strconv.FormatBool(val)
+ valueNode.Tag = "!!bool"
+
+ case int:
+ valueNode.Kind = yaml.ScalarNode
+ valueNode.Value = strconv.Itoa(val)
+ valueNode.Tag = "!!int"
+
+ case string:
+ valueNode.Kind = yaml.ScalarNode
+ valueNode.Value = val
+ valueNode.Tag = "!!str"
+
+ default:
+ return fmt.Errorf("%w: %T", errYAMLNodeUnsupportedValueType, v.value)
+ }
+
+ return nil
+}
+
+// mappingValue returns the value node for key in a YAML mapping node, or nil if not found.
+func mappingValue(mapping *yaml.Node, key string) *yaml.Node {
+ if mapping.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i+1 < len(mapping.Content); i += 2 {
+ if mapping.Content[i].Value == key {
+ return mapping.Content[i+1]
+ }
+ }
+ return nil
+}
+
+// applyValues applies a list of yamlValue changes to a parsed YAML document.
+func applyValues(doc *yaml.Node, vals []yamlValue) error {
+ for _, v := range vals {
+ err := setYAMLValue(doc, v)
+ if err != nil {
+ return fmt.Errorf("set %s.%s: %w", v.section, v.key, err)
+ }
+ }
+ return nil
+}
+
+// encodeYAML encodes a yaml.Node back to string, preserving structure and comments.
+func encodeYAML(doc *yaml.Node) (string, error) {
+ var buf strings.Builder
+ enc := yaml.NewEncoder(&buf)
+ enc.SetIndent(2) //nolint:mnd
+ err := enc.Encode(doc)
+ if err != nil {
+ return "", err
+ }
+
+ return buf.String(), nil
+}
+
+// swWriteConfig writes .config.yaml from config.sample.yaml with wizard values applied,
+// then calls writeTestConfigs to write the test config files.
+func swWriteConfig(m swModel) error {
+ src, err := os.ReadFile("config.sample.yaml")
+ if err != nil {
+ return fmt.Errorf("read config.sample.yaml: %w", err)
+ }
+
+ var doc yaml.Node
+ err = yaml.Unmarshal(src, &doc)
+ if err != nil {
+ return fmt.Errorf("parse config.sample.yaml: %w", err)
+ }
+
+ var vals []yamlValue
+ for i, step := range allSteps {
+ if step.Values == nil {
+ continue
+ }
+ vals = append(vals, step.Values(m.states[i])...)
+ }
+ vals = append(vals, yv("db", "name", m.dbName))
+
+ err = applyValues(&doc, vals)
+ if err != nil {
+ return err
+ }
+
+ content, err := encodeYAML(&doc)
+ if err != nil {
+ return fmt.Errorf("encode config: %w", err)
+ }
+
+ err = os.WriteFile(".config.yaml", []byte(content), 0o600) //nolint:mnd
+ if err != nil {
+ return fmt.Errorf("write .config.yaml: %w", err)
+ }
+
+ return writeTestConfigs(m, src)
+}
+
+var testConfigPaths = []string{
+ "test/api_test/.config.yaml",
+ "test/service_test/.config.yaml",
+ "test/worker_test/.config.yaml",
+}
+
+// writeTestConfigs writes test configs based on config.sample.yaml with
+// test-specific overrides and a "_test" suffix on the DB name.
+func writeTestConfigs(m swModel, src []byte) error {
+ testDBName := m.dbName + "_test"
+
+ creds := m.states[stepIdxDBCreds]
+ err := ensureTestDB(creds.val(dbIdxHost), creds.val(dbIdxUser), creds.val(dbIdxPass), testDBName)
+ if err != nil {
+ return fmt.Errorf("test database '%s': %w", testDBName, err)
+ }
+
+ var doc yaml.Node
+ err = yaml.Unmarshal(src, &doc)
+ if err != nil {
+ return fmt.Errorf("parse config.sample.yaml: %w", err)
+ }
+
+ dbVals := stepDBCredentials.Values(m.states[stepIdxDBCreds])
+ vals := make([]yamlValue, 0, len(dbVals)+4) //nolint:mnd
+ vals = append(vals, dbVals...)
+ vals = append(vals,
+ yv("db", "name", testDBName),
+ yv("email", "driver", "test"),
+ yv("tracing", "enabled", "false"), // Обратите внимание: в вашем коде была строка "false"
+ yv("files", "storage_driver", "in-memory-fs"),
+ )
+
+ err = applyValues(&doc, vals)
+ if err != nil {
+ return err
+ }
+
+ content, err := encodeYAML(&doc)
+ if err != nil {
+ return fmt.Errorf("encode test config: %w", err)
+ }
+
+ for _, path := range testConfigPaths {
+ _, statErr := os.Stat(path)
+ if statErr == nil {
+ continue // already exists — skip
+ }
+ err = os.WriteFile(path, []byte(content), 0o600) //nolint:mnd
+ if err != nil {
+ return fmt.Errorf("write %s: %w", path, err)
+ }
+ }
+
+ return nil
+}
+
+// ensureTestDB connects to dbName and returns nil if it exists.
+// If the connection fails, it attempts to create the database.
+// Unlike checkDB, it does not require the database to be empty.
+func ensureTestDB(hostPort, user, pass, dbName string) error {
+ ctx := context.Background()
+ conn, err := pgx.Connect(ctx, buildConnStr(hostPort, user, pass, dbName))
+ if err == nil {
+ _ = conn.Close(ctx)
+ return nil
+ }
+
+ // DB doesn't exist — create it
+ pgConn, err := pgx.Connect(ctx, buildConnStr(hostPort, user, pass, "postgres"))
+ if err != nil {
+ return err
+ }
+ _, err = pgConn.Exec(ctx, fmt.Sprintf("CREATE DATABASE %q", dbName))
+ _ = pgConn.Close(ctx)
+ return err
+}
+
+// ── Main ──────────────────────────────────────────────────────────────────────
+
+func main() {
+ fmt.Println(swErrorStyle.Render("⚠ WARNING"))
+ fmt.Println(swErrorStyle.Render(" This wizard is intended for local development setup only."))
+ fmt.Println(swErrorStyle.Render(" It will create or overwrite .config.yaml and may create databases."))
+ fmt.Println(swErrorStyle.Render(" Do NOT run this in staging or production environments."))
+ fmt.Println()
+ fmt.Print(" Proceed? [Y/n]: ")
+ var ans string
+ _, err := fmt.Scanln(&ans)
+ if err != nil && err.Error() != "unexpected newline" && err.Error() != "EOF" {
+ fmt.Fprintln(os.Stderr, swErrorStyle.Render("\n✗ input error: "+err.Error()))
+ os.Exit(1)
+ }
+ ans = strings.ToLower(strings.TrimSpace(ans))
+ if ans != "" && ans != "y" {
+ fmt.Println("Aborted.")
+ return
+ }
+ fmt.Println()
+
+ sample, err := app.ConfigFromFile("config.sample.yaml")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, swErrorStyle.Render("⚠ failed to load config.sample.yaml: "+err.Error()))
+ os.Exit(1)
+ }
+
+ ex, err := app.ConfigFromFile(".config.yaml")
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ fmt.Fprintln(os.Stderr, swErrorStyle.Render("⚠ failed to load .config.yaml: "+err.Error()))
+ fmt.Fprintln(os.Stderr, swErrorStyle.Render(" fix the file or delete it to start fresh"))
+ os.Exit(1)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ ex = nil
+ }
+
+ p := tea.NewProgram(newSWModel(ex, sample), tea.WithAltScreen())
+ raw, err := p.Run()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error: "+err.Error())
+ os.Exit(1)
+ }
+
+ result, ok := raw.(swModel)
+ if !ok {
+ fmt.Fprintln(os.Stderr, "error: terminal model is not of type swModel")
+ os.Exit(1)
+ }
+ if result.writeErr != "" {
+ fmt.Fprintln(os.Stderr, swErrorStyle.Render("✗ "+result.writeErr))
+ os.Exit(1)
+ }
+
+ _, statErr := os.Stat(".config.yaml")
+ if statErr == nil {
+ fmt.Println(swSuccessStyle.Render("✓ .config.yaml updated successfully"))
+ } else {
+ fmt.Println(swSuccessStyle.Render("✓ .config.yaml created successfully"))
+ }
+ for _, p := range testConfigPaths {
+ _, statErr := os.Stat(p)
+ if statErr == nil {
+ fmt.Println(swDimStyle.Render(" " + p + " already exists, skipped"))
+ } else {
+ fmt.Println(swSuccessStyle.Render("✓ " + p + " created successfully"))
+ }
+ }
+
+ fmt.Println("")
+ fmt.Println(swDimStyle.Render(" Your server is ready to run."))
+ fmt.Println(swDimStyle.Render(" You can manually edit .config.yaml to check and configure remaining options."))
+}
diff --git a/config.sample.yaml b/config.sample.yaml
new file mode 100644
index 0000000..5360387
--- /dev/null
+++ b/config.sample.yaml
@@ -0,0 +1,198 @@
+# This file contains sample and default values for the application configuration.
+# Copy this file to ".config.yaml" and update the values according to your environment.
+# This file is intended for local development and as a reference template.
+
+# Application settings
+app:
+ # Unique identifier of the running application instance.
+ # Useful when multiple instances are running (e.g., in containers or clusters).
+ # Appears in logs and monitoring systems.
+ id: "local-dev"
+
+ # Human-readable application name.
+ # Used in logs, metrics, tracing, and possibly UI/system integrations.
+ name: "MY-APP"
+
+ # Application version string.
+ # Can follow semantic versioning (e.g., 1.2.3), a commit hash, build timestamp,
+ # or any custom format.
+ # Used in logs and exposed via status endpoints (e.g., /api/status/).
+ version: "local-dev"
+
+ # Defines the current runtime environment.
+ # Allowed values:
+ # dev - Active development.
+ # test - Automated testing environment.
+ # staging - Pre-production validation.
+ # release - Production environment handling real traffic.
+ env: dev
+
+# HTTP server settings
+server:
+ # Network interface or hostname to bind the HTTP server to.
+ # Use "localhost" for local-only access.
+ # Use "0.0.0.0" to allow access from other machines.
+ host: localhost
+
+ # TCP port the HTTP server listens on.
+ # Must be available and not blocked by firewall rules.
+ port: 8080
+
+ # Base path prefix for API routes.
+ # For example, if set to "api", endpoints become:
+ # /api/users
+ # /api/status
+ api_base_path: api
+
+ # Public base address of the server (used for absolute URLs, redirects, email links, OAuth callbacks).
+ # Should include scheme (http/https).
+ addr: "https://yourhost.dev"
+
+ # Comma-separated list of domains for which automatic TLS certificates
+ # should be issued (e.g., via Let's Encrypt).
+ # Leave empty to disable automatic certificate provisioning.
+ autocert_hosts: ""
+
+# PostgreSQL database configuration
+db:
+ # Database host (hostname or IP address).
+ host: "localhost"
+
+ # PostgreSQL port number.
+ port: 5432
+
+ # Database user with appropriate privileges.
+ user: "postgres"
+
+ # Password for the database user.
+ password: "postgres"
+
+ # Database name used by the application.
+ name: "myapp_local_dev"
+
+ # Enables SQL query logging.
+ log_queries: true
+
+# Email configuration
+email:
+ # Email delivery driver.
+ #
+ # Allowed values:
+ # test - Emails are not sent (but stored in-memory and available for tests)
+ # smtp - Emails are sent via SMTP
+ driver: "test"
+
+ # Default "From" email address used in outgoing emails.
+ from: "mail@yourhost.dev"
+
+ smtp:
+ # SMTP server hostname.
+ host: "smtp.mailtrap.io"
+
+ # SMTP server port.
+ port: 2525
+
+ # SMTP authentication username.
+ username: "demo"
+
+ # SMTP authentication password.
+ password: "password"
+
+# Session management
+session:
+ # Session lifetime in hours.
+ # After this duration, the user must re-authenticate.
+ duration_hours: 24
+
+ # Secret key used to sign and validate session cookies or tokens.
+ # Changing this value invalidates all existing sessions.
+ key: "secret-key"
+
+# Distributed tracing / observability
+tracing:
+ # Enables or disables tracing instrumentation.
+ enabled: true
+
+ # Tracing backend driver.
+ #
+ # Allowed values:
+ # log - Writes trace spans to application logs.
+ # uptrace - Sends trace data to Uptrace.
+ driver: log
+
+ # Uptrace DSN
+ # Required if driver is set to "uptrace".
+ # See: https://uptrace.dev/get#dsn
+ uptrace_dsn: "https://**********@api.uptrace.dev?grpc=4317"
+
+# File storage and uploads
+files:
+ # Storage backend driver.
+ #
+ # Allowed values:
+ # local-fs - Stores files on local filesystem.
+ # in-memory-fs - Stores files in memory (for testing).
+ storage_driver: "local-fs"
+
+ # Maximum allowed upload size in megabytes.
+ # Requests exceeding this limit will be rejected.
+ max_upload_size_mb: 20
+
+ # Maximum width (in pixels) for uploaded images.
+ # Images larger than this may be resized.
+ image_max_width: 1500
+
+ # Maximum height (in pixels) for uploaded images.
+ image_max_height: 1500
+
+ # Width (in pixels) for generated preview images.
+ preview_width: 400
+
+ # Height (in pixels) for generated preview images.
+ preview_height: 400
+
+ local_fs:
+ # Directory path where files will be stored when using local-fs driver.
+ # "~" may need to be expanded by the application if supported.
+ storage_path: "~/uploaded-files/gopl-files"
+
+ # Logical storage structure for domain entities.
+ # Each entity can define subdirectories for different file types.
+ entities:
+ books:
+ covers:
+ # Subdirectory name for storing book cover images.
+ path: "book-covers"
+
+
+# OpenAPI documentation
+openapi:
+ # Enables serving OpenAPI (Swagger) specification.
+ enabled: true
+
+ # URL path where OpenAPI JSON/YAML is available.
+ # Example: /openapi
+ serve_path: openapi
+
+
+# OAuth providers
+google_oauth:
+ # OAuth 2.0 Client ID issued by Google.
+ client_id: ""
+
+ # OAuth 2.0 Client Secret issued by Google.
+ client_secret: ""
+
+github_oauth:
+ # OAuth App Client ID issued by GitHub.
+ client_id: ""
+
+ # OAuth App Client Secret issued by GitHub.
+ client_secret: ""
+
+# Administrators
+admins:
+ # List of user UUIDs that have administrative privileges.
+ # These users bypass normal role checks and have full system access.
+ - "uuid-of-admin"
+ - "uuid-of-another-admin"
\ No newline at end of file
diff --git a/email/smtp_sender.go b/email/smtp_sender.go
index 1ff2c75..1579e82 100644
--- a/email/smtp_sender.go
+++ b/email/smtp_sender.go
@@ -16,7 +16,7 @@ type SMTPSender struct {
// NewSMTPSender creates and initializes a new SMTPSender instance.
func NewSMTPSender() (*SMTPSender, error) {
- conf := app.Config().Email
+ conf := app.Config().Email.SMTP
client, err := mail.NewClient(conf.Host,
mail.WithPort(conf.Port),
diff --git a/frontend/assets/output.css b/frontend/assets/output.css
index e311cd3..0eb9c68 100644
--- a/frontend/assets/output.css
+++ b/frontend/assets/output.css
@@ -3590,9 +3590,6 @@
.left-0 {
left: calc(var(--spacing) * 0);
}
- .left-1 {
- left: calc(var(--spacing) * 1);
- }
.left-1\/2 {
left: calc(1/2 * 100%);
}
@@ -5151,6 +5148,9 @@
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
+ .mb-5 {
+ margin-bottom: calc(var(--spacing) * 5);
+ }
.mb-10 {
margin-bottom: calc(var(--spacing) * 10);
}
@@ -5920,15 +5920,9 @@
.w-6 {
width: calc(var(--spacing) * 6);
}
- .w-11 {
- width: calc(var(--spacing) * 11);
- }
.w-11\/12 {
width: calc(11/12 * 100%);
}
- .w-32 {
- width: calc(var(--spacing) * 32);
- }
.w-40 {
width: calc(var(--spacing) * 40);
}
@@ -5983,10 +5977,6 @@
.border-collapse {
border-collapse: collapse;
}
- .-translate-x-1 {
- --tw-translate-x: calc(var(--spacing) * -1);
- translate: var(--tw-translate-x) var(--tw-translate-y);
- }
.-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -6195,9 +6185,6 @@
.gap-8 {
gap: calc(var(--spacing) * 8);
}
- .self-start {
- align-self: flex-start;
- }
.overflow-x-auto {
overflow-x: auto;
}
@@ -6628,9 +6615,6 @@
.border-info {
border-color: var(--color-info);
}
- .border-neutral-content {
- border-color: var(--color-neutral-content);
- }
.border-neutral-content\/30 {
border-color: var(--color-neutral-content);
@supports (color: color-mix(in lab, red, red)) {
@@ -7043,6 +7027,9 @@
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
+ .py-4 {
+ padding-block: calc(var(--spacing) * 4);
+ }
.py-8 {
padding-block: calc(var(--spacing) * 8);
}
@@ -7055,9 +7042,6 @@
font-size: 1.375rem;
}
}
- .pt-1 {
- padding-top: calc(var(--spacing) * 1);
- }
.pt-6 {
padding-top: calc(var(--spacing) * 6);
}
@@ -7669,6 +7653,10 @@
}
}
}
+ .blur {
+ --tw-blur: blur(8px);
+ filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
+ }
.filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
}
@@ -8381,6 +8369,11 @@
width: auto;
}
}
+ .lg\:grid-cols-2 {
+ @media (width >= 64rem) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ }
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
diff --git a/frontend/component/form.templ b/frontend/component/form.templ
index 31aa157..6bd1feb 100644
--- a/frontend/component/form.templ
+++ b/frontend/component/form.templ
@@ -7,7 +7,7 @@ const (
const (
LabelClass = "text-gray-900"
InputClassDefault = "input input-xl w-full"
- TextareaClassDefault = "textarea textarea-xl w-full"
+ TextareaClassDefault = "textarea w-full"
InputClassError = "input-error"
)
diff --git a/frontend/component/form_templ.go b/frontend/component/form_templ.go
index 9c6d301..0ff1b20 100644
--- a/frontend/component/form_templ.go
+++ b/frontend/component/form_templ.go
@@ -15,7 +15,7 @@ const (
const (
LabelClass = "text-gray-900"
InputClassDefault = "input input-xl w-full"
- TextareaClassDefault = "textarea textarea-xl w-full"
+ TextareaClassDefault = "textarea w-full"
InputClassError = "input-error"
)
diff --git a/frontend/page/edit_page.templ b/frontend/page/edit_page.templ
index 55aeb98..c643a23 100644
--- a/frontend/page/edit_page.templ
+++ b/frontend/page/edit_page.templ
@@ -189,7 +189,7 @@ templ EditPageForm(pageID string) {
Model: "form.content",
ErrorModel: "errors.content",
Type: "textarea",
- Rows: 15,
+ Rows: 25,
Description: "You can use Markdown.",
})
diff --git a/frontend/page/edit_page_templ.go b/frontend/page/edit_page_templ.go
index 145d478..bb3a571 100644
--- a/frontend/page/edit_page_templ.go
+++ b/frontend/page/edit_page_templ.go
@@ -87,7 +87,7 @@ func EditPageForm(pageID string) templ.Component {
Model: "form.content",
ErrorModel: "errors.content",
Type: "textarea",
- Rows: 15,
+ Rows: 25,
Description: "You can use Markdown.",
}).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
diff --git a/frontend/page/home.templ b/frontend/page/home.templ
index 1e0bbec..5ef4bb2 100644
--- a/frontend/page/home.templ
+++ b/frontend/page/home.templ
@@ -1,13 +1,74 @@
package page
-templ Home() {
-
-
hello world
-
-
-
-
+import "github.com/gopl-dev/server/frontend/component/icon"
+
+type HomeData struct {
+ Title string
+ Data map[string]string
+}
+
+templ Home(d HomeData) {
+
+
+
+ @templ.Raw(d.Data["intro"])
+
+
+
+
+
+ @templ.Raw(d.Data["getting-started"])
+
+
+
+
+
+ @templ.Raw(d.Data["tools"])
+
+
+
+
+
+ @templ.Raw(d.Data["community"])
+
}
diff --git a/frontend/page/home_templ.go b/frontend/page/home_templ.go
index cd89111..74b0da0 100644
--- a/frontend/page/home_templ.go
+++ b/frontend/page/home_templ.go
@@ -8,7 +8,14 @@ package page
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
-func Home() templ.Component {
+import "github.com/gopl-dev/server/frontend/component/icon"
+
+type HomeData struct {
+ Title string
+ Data map[string]string
+}
+
+func Home(d HomeData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -29,7 +36,84 @@ func Home() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
hello world
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/page/home.templ`, Line: 14, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = icon.Pencil("w-4").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(d.Data["intro"]).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(d.Data["getting-started"]).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(d.Data["tools"]).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ.Raw(d.Data["community"]).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/frontend/page/view_page.templ b/frontend/page/view_page.templ
index 14c69f3..073f32f 100644
--- a/frontend/page/view_page.templ
+++ b/frontend/page/view_page.templ
@@ -3,13 +3,15 @@ package page
import "github.com/gopl-dev/server/frontend/component/icon"
templ ViewPage(id, title, text string) {
-
+
- @templ.Raw(text)
+
+ @templ.Raw(text)
+
}
diff --git a/frontend/page/view_page_templ.go b/frontend/page/view_page_templ.go
index 01c9ee8..18b4803 100644
--- a/frontend/page/view_page_templ.go
+++ b/frontend/page/view_page_templ.go
@@ -31,14 +31,14 @@ func ViewPage(id, title, text string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/page/view_page.templ`, Line: 8, Col: 19}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `frontend/page/view_page.templ`, Line: 8, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -65,7 +65,7 @@ func ViewPage(id, title, text string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Edit
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Edit
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -73,7 +73,7 @@ func ViewPage(id, title, text string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/go.mod b/go.mod
index 8a0fec6..92fc1ed 100644
--- a/go.mod
+++ b/go.mod
@@ -8,6 +8,9 @@ require (
github.com/Oudwins/zog v0.21.9
github.com/a-h/templ v0.3.960
github.com/brianvoe/gofakeit/v7 v7.14.0
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/chzyer/readline v1.5.1
github.com/georgysavva/scany/v2 v2.1.4
github.com/go-co-op/gocron/v2 v2.19.0
@@ -40,12 +43,22 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cli/browser v1.3.0 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
@@ -74,12 +87,20 @@ require (
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/runtime v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
diff --git a/go.sum b/go.sum
index eec20bf..471e491 100644
--- a/go.sum
+++ b/go.sum
@@ -14,6 +14,10 @@ github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
@@ -24,6 +28,20 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
@@ -32,11 +50,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs=
github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -135,6 +161,8 @@ github.com/lithammer/shortuuid v3.0.0+incompatible h1:NcD0xWW/MZYXEHa6ITy6kaXN5n
github.com/lithammer/shortuuid v3.0.0+incompatible/go.mod h1:FR74pbAuElzOUuenUHTK2Tciko1/vKuIKS9dSkDrA4w=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/markbates/goth v1.82.0 h1:8j/c34AjBSTNzO7zTsOyP5IYCQCMBTRBHAbBt/PI0bQ=
github.com/markbates/goth v1.82.0/go.mod h1:/DRlcq0pyqkKToyZjsL2KgiA1zbF1HIjE7u2uC79rUk=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -142,14 +170,26 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -175,6 +215,8 @@ github.com/uptrace/uptrace-go v1.38.0 h1:QdJfyQkaz7HNPbqM9OkaQ2L9jfdf0DpfZJv9em7
github.com/uptrace/uptrace-go v1.38.0/go.mod h1:SdE9nA+/y+SOIzatuIK2tZeYhoWgrAzAr08kJEquZyM=
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
@@ -240,6 +282,7 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/sample.config.yaml b/sample.config.yaml
deleted file mode 100644
index a1a6f19..0000000
--- a/sample.config.yaml
+++ /dev/null
@@ -1,91 +0,0 @@
-# This file contains sample and default values for the app configuration.
-# Copy this file and rename it to '.config.yaml' and then update values as needed.
-
-# General configuration for the app
-app:
- # Unique identifier for the app instance, useful when running multiple instances.
- id: "local-dev"
- # The name of the app, currently used for logging purposes.
- name: "gopl.dev"
- # The version of the app. You can use any format (e.g., semantic versioning, timestamp, commit hash or whatever else).
- # This is used for logging and to verify the correct version of app is deployed (e.g., via the "/api/status/" endpoint).
- version: "ognev-local"
-
- # Specifies the environment in which the app is running. Possible values:
- # - dev: Development environment (e.g., for active coding/debugging).
- # - test: Testing environment (e.g., running automated tests).
- # - staging: Pre-release testing environment. Ideally, it should mimic the release environment,
- # but you may use it for specific checks (like credentials for third-party).
- # - release: Production environment where the app is live and handling real operations.
- env: dev
-
-# Server configuration
-server:
- # Hostname or IP address the server will bind to.
- host: localhost
- # Port number the server will listen on.
- port: 8080
- # A prefix applied to all API endpoints
- api_base_path: api
- addr: "https://gopl.dev"
-
- autocert_hosts: "" # comma separated
-
-# Database configuration
-db:
- host: localhost:5432
- port: 5432
- user: postgres
- password: postgres
- name: gopl_dev_server
- log_queries: true
-
-email:
- driver: test
- from: mail@gopl.dev
- host: sandbox.smtp.mailtrap.io
- port: 25
- username:
- password:
-
-session:
- duration_hours: 24
- key: "secret-key"
-
-tracing:
- enabled: true
- # uptrace | log
- driver: uptrace
- # https://uptrace.dev/get#dsn
- uptrace_dsn: "https://**********@api.uptrace.dev?grpc=4317"
-
-files:
- storage_driver: 'local-fs'
- max_upload_size_mb: 20
- image_max_width: 1500
- image_max_height: 1500
- preview_width: 400
- preview_height: 400
- local_fs:
- storage_path: "/vo/gopl-files"
-
- entities:
- books:
- covers:
- path: "book-covers"
-
-openapi:
- enabled: true
- serve_path: openapi
-
-google_oauth:
- client_id: "hello"
- client_secret: "here"
-
-github_oauth:
- client_id: "hello"
- client_secret: "here"
-
-admins:
- - "uuid-of-admin"
- - "uuid-of-another-admin"
\ No newline at end of file
diff --git a/server/handler/dashboard_handler.go b/server/handler/dashboard_handler.go
index 7bbe733..89c5ee4 100644
--- a/server/handler/dashboard_handler.go
+++ b/server/handler/dashboard_handler.go
@@ -23,7 +23,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
renderTempl(ctx, w, layout.Dashboard(layout.Data{
Title: "Dashboard",
- Body: page.Home(),
+ Body: page.Home(page.HomeData{}),
User: frontend.NewUser(ds.UserFromContext(r.Context())),
}))
}
diff --git a/server/handler/home_handler.go b/server/handler/home_handler.go
index e2c4ad8..ff4bb5b 100644
--- a/server/handler/home_handler.go
+++ b/server/handler/home_handler.go
@@ -7,6 +7,7 @@ import (
"github.com/gopl-dev/server/frontend"
"github.com/gopl-dev/server/frontend/layout"
"github.com/gopl-dev/server/frontend/page"
+ "github.com/gopl-dev/server/test/factory/random"
)
// Home sweet home.
@@ -14,9 +15,45 @@ func (h *Handler) Home(w http.ResponseWriter, r *http.Request) {
ctx, span := h.tracer.Start(r.Context(), "Home")
defer span.End()
+ pages, err := h.service.GetPagesByPublicID(ctx,
+ "intro", "getting-started", "tools", "community",
+ )
+ if err != nil {
+ Abort(w, r, err)
+ return
+ }
+
+ data := page.HomeData{
+ Title: "Welcome",
+ Data: map[string]string{},
+ }
+
+ for _, p := range pages {
+ data.Data[p.PublicID] = p.Content
+ }
+
+ title := random.Element([]string{
+ // Now that you have found a list of silly titles, please read through it.
+ "Green Olives Party Late",
+ "Goats Organizing Pajama Launches",
+ "Grandma’s Overpowered Pancake Love",
+ "Galactic Octopus Pizza League",
+ "Giraffes On Purple Ladders",
+ "Goblin Operations & Potion Logistics",
+ "Grumpy Owls Playing Lute",
+ "Gravity Occasionally Pauses Lunch",
+ "Global Organization of Professional Llamas",
+ "Gigantic Overengineered Paperclip Lab",
+ // Thanks. Now that you have committed and if you want to continue, do this three steps:
+ // 1. remove one string from this list that you dislike
+ // 2. add two of your silly titles of your liking (that explain the acronym GOPL).
+ // 3. move this list elsewhere (within this repo, ofc)
+ })
+
+ data.Title = title
renderTempl(ctx, w, layout.Default(layout.Data{
- Title: "Welcome!",
- Body: page.Home(),
+ Title: "Welcome",
+ Body: page.Home(data),
User: frontend.NewUser(ds.UserFromContext(r.Context())),
}))
}