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) { +
+
+

+ { d.Title } +

+ + @icon.Pencil("w-4") + +
+
+ @templ.Raw(d.Data["intro"]) +
+
+
+
+

From zero to hero

+ + @icon.Pencil("w-4") + +
+
+ @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) { -
+ } 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())), })) }