feat: decks with persistence, list, settings and a sample deck
Decks are rows plus a Y.Doc: the row carries what a list, a link or a permission check needs, and every word of content lives in the update log the core replays. Three models (Deck, DeckUpdate, DeckGrant), the one visibility seam every read goes through, and the six screens that stand on them. - internal/slides/deck reads the core's view JSON. The core is the contract; the only repair is a fractional z, which a browser peer can write and which would otherwise fail a whole deck's parse. - openDeck replays the log into a fresh wasm instance; applyOps writes the update row and bumps Deck.Seq under an optimistic lock in one transaction, then tells the (still nil) hub. - visibleDecks is owner-or-team-or-granted. Not yours and not there are the same 404. Grants have no screen yet; the seam already honours them. - A sample deck, "Welcome to Slides", is seeded on first sign-in: eight slides covering all seven templates, both layout modes and the phone layer, with notes on each saying what it shows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
internal/slides/app.go+30 −2internal/slides/auth.go+30 −9internal/slides/auth_test.go+14 −5internal/slides/deck/deck.go+179 −0internal/slides/deck/deck_test.go+170 −0internal/slides/decks.go+1007 −0internal/slides/handlers.go+17 −54internal/slides/locales/en.toml+77 −4internal/slides/migrations/0002_create_decks.sql+16 −0internal/slides/migrations/schema.sql+16 −0internal/slides/models.go+84 −0internal/slides/render.go+42 −18internal/slides/sample.go+302 −0internal/slides/templates/account.html+42 −0internal/slides/templates/deck.html+41 −0internal/slides/templates/deck_delete.html+19 −0internal/slides/templates/deck_new.html+24 −0internal/slides/templates/deck_settings.html+38 −0internal/slides/templates/decks.html+32 −0internal/slides/templates/index.html+0 −52internal/slidestest/decks_test.go+225 −0
Intent
<task-notification> <task-id>aa6ded79afb816ba3</task-id> <tool-use-id>toolu_01PsprRBdyrVXoQV5Stcjodz</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/aa6ded79afb816ba3.output</output-file> <status>completed</status> <summary>Agent "Task 2 fix round 1 (Rust core)" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Confirmed: only my paths were committed; the concurrent agent's Go work is untouched and unstaged. Status: Done, all findings closed. Commit: `8867408` — "fix: core review round 1 — validate columns children, split layout.rs" Tests: 25 Rust tests pass (14 unit incl. 9 new layout.rs, 1 no_host_imports, 9 in tests/ops.rs incl. 3 new columns-children), clippy clean, 4 Go tests pass; wasm rebuild is byte-identical (`cmp`) to committed `internal/slides/core/testdata/slides_core.wasm`. Concerns: none — concurrent agent's Go files (`internal/slides/deck/`, `migrations/`, `models.go`, `slidestest/decks_test.go`) verified untouched and unstaged after commit. Report: `/home/paulca/amadan.net/oficina/slides/.claude/worktrees/slice-1/.superpowers/sdd/2026-09-07-slice-1-live/task-2-report.md`</result> <usage><subagent_tokens>146629</subagent_tokens><tool_uses>84</tool_uses><duration_ms>519094</duration_ms></usage> </task-notification>
<task-notification> <task-id>a4f7028869dad6234</task-id> <tool-use-id>toolu_01QajvUrN4HAuhjFkKUasxVn</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina/2420c44b-a27a-4e0d-bbe7-8b3ca0464c21/tasks/a4f7028869dad6234.output</output-file> <status>completed</status> <summary>Agent "Re-review Task 2 fix round 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Clean, no warnings. All verification confirms the report's claims. Now I have enough to write the final verdict. ### Findings - **Important 1 — `columns.children` referential integrity**: ADDRESSED — `core/src/ops.rs:410-419` (`BlockModel.children`), `:443-461` (`Model::read` populates it), `:617-643` (`validate_children`: rejects non-array, non-string entry, self-reference, and any id not a block on the slide), `:531-538` (`AddBlock` check calls it), `:587-592` (`SetBlock` check calls it), `:558-565` (`RemoveBlock` check drops the id from tracked children, not rejected), `:483-510` (`DuplicateSlide` check remaps children through `id_map`), `:672-693` (`remove_from_columns_children` applied at mutation time in `RemoveBlock`'s `apply_one` arm, `:894`). Tests `columns_children_must_name_blocks_on_the_slide`, `remove_block_drops_it_from_columns_children`, `duplicate_slide_remaps_columns_children` all present in `core/tests/ops.rs:950-1081` and pass (verified by running `cargo test --manifest-path core/Cargo.toml`: 9/9 in `tests/ops.rs`, all green, including these three). - **Important 2 — layout-conversion geometry moved to `core/src/layout.rs`**: ADDRESSED — `default_region`, `region_rect`, `region_of`, `to_canvas`, `to_flow` now live in `core/src/layout.rs` (new file, 327 lines), `core/src/lib.rs:365` adds `mod layout;`, `ops.rs` calls through `layout::*` at the three call sites (`:844-845`, `:868`, `:872`). `blocks_of` stayed in `ops.rs`, promoted to `pub(crate)` (`ops.rs:654`) so `layout.rs` can call it — confirmed it's used from multiple other `apply_one` arms in `ops.rs`, so keeping it there rather than moving it is reasonable and matches the report's stated rationale. No other restructuring found in the diff. New unit tests (`heading_region_rect`, `aside_region_rect`, `media_region_rect`, `body_region_rect_narrows_for_an_aside`, `default_region_by_kind`, `to_canvas_splits_a_shared_region_equally_and_sets_z_by_index`, `to_canvas_narrows_body_when_an_aside_is_present`, `to_flow_classifies_by_position_and_kind`, `to_flow_orders_y_then_x`, `to_flow_is_a_no_op_when_already_in_order`) are all present in `layout.rs:142-349` and pass (14/14 unit tests including these 9, verified via `cargo test`). - **Minor — vendored fastrand comment, build-wasm.sh comment, set_block resize comment**: ADDRESSED — `core/vendor/fastrand-2.5.0/Cargo.toml` diff renames `docs-wt-core/vendor/README.md`→`core/vendor/README.md` and `docs-core`→`slides-core`; `scripts/build-wasm.sh` diff removes the `emit_update`/`wire-gate.sh` references and rewords generically; `core/src/ops.rs:916-924` adds the row-major cols-resize comment on the `SetBlock` table-resize block. - **Also required — wasm reproducibility**: ADDRESSED — independently rebuilt via `scripts/build-wasm.sh` (clippy clean, release build succeeded) and ran `cmp` against `internal/slides/core/testdata/slides_core.wasm`: byte-identical. ### New Breakage in Fix Diff (Critical/Important only) None found. `cargo test` (14 unit + 1 no_host_imports + 9 ops.rs, all pass) and `cargo clippy --all-targets -D warnings -D clippy::disallowed_methods` (clean) both confirm no regressions from the `layout.rs` extraction or the columns-children validation. ### Out-of-Scope Observations None — the diff is confined to `core/src/{layout.rs,lib.rs,ops.rs}`, `core/tests/ops.rs`, `core/vendor/fastrand-2.5.0/Cargo.toml`, `internal/slides/core/testdata/slides_core.wasm`, and `scripts/build-wasm.sh`, matching the report's stated scope note (concurrent Go-side work under `internal/slides/*.go` untouched). ### Verdict All findings addressed</result> <usage><subagent_tokens>79435</subagent_tokens><tool_uses>18</tool_uses><duration_ms>133525</duration_ms></usage> </task-notification>
Attributed to this commit: 15.1 g CO₂e (1733838 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on worktree-slice-1, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 4f25d94..2efcc5e 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -10,6 +10,8 @@ import ( |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + |
| + "amadan.net/oficina/slides/internal/slides/core" |
| ) |
| // New wires the whole app: schema, sign-in, router, static files. It |
| @@ -55,7 +57,21 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| // signed-out GET/HEAD redirects to /signin; anything else 403s. |
| r.Group(func(r chi.Router) { |
| r.Use(authPlugin.RequireSession) |
| - r.Get("/", a.index) |
| + r.Get("/", a.decks) |
| + // One screen, one job (SKILL.md §7): the list links to a page |
| + // for creating, and that page is the only place a deck is made. |
| + r.Get("/decks/new", a.newDeck) |
| + r.Post("/decks", a.createDeck) |
| + // /d/{sqid} is the deck's own short URL — short because it is |
| + // what a person pastes into a chat window. |
| + r.Get("/d/{sqid}", a.showDeck) |
| + r.Get("/d/{sqid}/settings", a.deckSettings) |
| + r.Post("/d/{sqid}/settings", a.saveDeckSettings) |
| + // Deleting gets its own URL and its own page, never a modal |
| + // and never a button on the settings form (AGENTS.md's |
| + // destructive-action convention). The GET changes nothing. |
| + r.Get("/d/{sqid}/delete", a.confirmDeleteDeck) |
| + r.Post("/d/{sqid}/delete", a.deleteDeck) |
| }) |
| mux := http.NewServeMux() |
| @@ -109,5 +125,17 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) { |
| return nil, nil, err |
| } |
| - return &app{db: d, logger: logger, cfg: cfg, allowed: list}, authPlugin, nil |
| + // One Runtime for the process: it owns the compiled wasm module, |
| + // which is expensive to build and cheap to instantiate, and every |
| + // deck opened gets its own instance from it (decks.go's openDeck). |
| + // Built here rather than lazily on first use so a module that will |
| + // not compile — a stale or truncated .wasm — is a startup refusal |
| + // like every other one in this function, not a 500 on whichever |
| + // request happens to open the first deck. |
| + rt, err := core.New(context.Background()) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + |
| + return &app{db: d, logger: logger, cfg: cfg, allowed: list, core: rt}, authPlugin, nil |
| } |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| index d547802..5f047bb 100644 |
| --- a/internal/slides/auth.go |
| +++ b/internal/slides/auth.go |
| @@ -295,32 +295,39 @@ var presenceColours = []string{ |
| // sign-in can both miss and both try to insert. DO NOTHING makes the |
| // loser a no-op instead of an error, and the read-back below gives both |
| // of them the same row. |
| -func ensureUser(ctx context.Context, g *gorm.DB, address string) (User, error) { |
| +// The second return says whether THIS call created the row — what |
| +// makes a first sign-in distinguishable from every later request, and |
| +// so where the sample deck is seeded (sample.go). It is read off the |
| +// insert's own RowsAffected rather than off "we took the insert path", |
| +// because DO NOTHING means both racing requests take that path and only |
| +// one of them writes. |
| +func ensureUser(ctx context.Context, g *gorm.DB, address string) (User, bool, error) { |
| email := normalizeAddress(address) |
| if email == "" { |
| - return User{}, errors.New("slides: cannot map an empty address to a user") |
| + return User{}, false, errors.New("slides: cannot map an empty address to a user") |
| } |
| var found User |
| err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error |
| switch { |
| case err == nil: |
| - return found, nil |
| + return found, false, nil |
| case !errors.Is(err, gorm.ErrRecordNotFound): |
| - return User{}, fmt.Errorf("slides: load user %q: %w", email, err) |
| + return User{}, false, fmt.Errorf("slides: load user %q: %w", email, err) |
| } |
| // First sight of this address: create the row, then read it back |
| // rather than trusting the insert's own return — DO NOTHING leaves |
| // ID at zero when a concurrent request won the race. |
| created := User{Email: email, Colour: colourFor(email)} |
| - if err := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created).Error; err != nil { |
| - return User{}, fmt.Errorf("slides: create user %q: %w", email, err) |
| + res := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created) |
| + if res.Error != nil { |
| + return User{}, false, fmt.Errorf("slides: create user %q: %w", email, res.Error) |
| } |
| if err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil { |
| - return User{}, fmt.Errorf("slides: load user %q: %w", email, err) |
| + return User{}, false, fmt.Errorf("slides: load user %q: %w", email, err) |
| } |
| - return found, nil |
| + return found, res.RowsAffected == 1, nil |
| } |
| // colourFor picks a stable presence colour for an address: the same |
| @@ -339,17 +346,31 @@ func colourFor(email string) string { |
| // it is false for a signed-out request, or when the row could not be |
| // read (logged, never surfaced as a half-signed-in page). |
| // |
| +// It is also where a first sign-in seeds the sample deck (sample.go) — |
| +// right beside the insert that makes the row, because "the users row |
| +// was just created" is the only place in the app that knows a person is |
| +// new. The seed is guarded by a second check for a deck this person |
| +// already owns, so no arrangement of races or restores can produce two |
| +// welcome decks, and a failure to build one never stops a sign-in. |
| +// |
| // sessions.Current(r).Subject, never sessions.UserID — see ensureUser. |
| func (a *app) currentUser(r *http.Request) (User, bool) { |
| s, ok := sessions.Current(r) |
| if !ok || s.Subject == "" { |
| return User{}, false |
| } |
| - u, err := ensureUser(r.Context(), a.db.G, s.Subject) |
| + u, created, err := ensureUser(r.Context(), a.db.G, s.Subject) |
| if err != nil { |
| a.logger.Error("slides: resolve current user", "err", err) |
| return User{}, false |
| } |
| + if created { |
| + if has, err := hasDeck(r.Context(), a.db.G, u); err != nil { |
| + a.logger.Error("slides: check for an existing deck before seeding", "user", u.ID, "err", err) |
| + } else if !has { |
| + a.seedSampleDeckFor(r.Context(), u) |
| + } |
| + } |
| return u, true |
| } |
diff --git a/internal/slides/auth_test.go b/internal/slides/auth_test.go| index df1b1e5..3a6a14d 100644 |
| --- a/internal/slides/auth_test.go |
| +++ b/internal/slides/auth_test.go |
| @@ -43,25 +43,34 @@ func TestEnsureUserWritesOnceOnly(t *testing.T) { |
| creates := countCreates(t, d.G) |
| ctx := context.Background() |
| - first, err := ensureUser(ctx, d.G, "paul@example.test") |
| + first, created, err := ensureUser(ctx, d.G, "paul@example.test") |
| if err != nil { |
| t.Fatalf("ensureUser (first): %v", err) |
| } |
| if first.ID == 0 { |
| t.Fatal("ensureUser (first) returned a row with no id") |
| } |
| + // The created flag is what tells a first sign-in from every later |
| + // request, and so what decides whether the sample deck is seeded |
| + // (sample.go). It has to be true exactly once. |
| + if !created { |
| + t.Error("ensureUser (first) reported the row as already existing") |
| + } |
| if got := creates(); got != 1 { |
| t.Fatalf("first sign-in ran %d inserts, want 1", got) |
| } |
| for i := 2; i <= 3; i++ { |
| - again, err := ensureUser(ctx, d.G, "paul@example.test") |
| + again, createdAgain, err := ensureUser(ctx, d.G, "paul@example.test") |
| if err != nil { |
| t.Fatalf("ensureUser (call %d): %v", i, err) |
| } |
| if again.ID != first.ID { |
| t.Errorf("call %d returned id %d, want the same row %d", i, again.ID, first.ID) |
| } |
| + if createdAgain { |
| + t.Errorf("call %d reported the row as newly created", i) |
| + } |
| if !again.CreatedAt.Equal(first.CreatedAt) { |
| t.Errorf("call %d rewrote CreatedAt (%v, was %v)", i, again.CreatedAt, first.CreatedAt) |
| } |
| @@ -82,14 +91,14 @@ func TestEnsureUserNormalizesTheAddress(t *testing.T) { |
| } |
| ctx := context.Background() |
| - first, err := ensureUser(ctx, d.G, "Paul@Example.Test") |
| + first, _, err := ensureUser(ctx, d.G, "Paul@Example.Test") |
| if err != nil { |
| t.Fatalf("ensureUser: %v", err) |
| } |
| if first.Email != "paul@example.test" { |
| t.Errorf("stored Email = %q, want it normalized", first.Email) |
| } |
| - again, err := ensureUser(ctx, d.G, " paul@example.test ") |
| + again, _, err := ensureUser(ctx, d.G, " paul@example.test ") |
| if err != nil { |
| t.Fatalf("ensureUser (again): %v", err) |
| } |
| @@ -113,7 +122,7 @@ func TestEnsureUserRefusesAnEmptyAddress(t *testing.T) { |
| if _, _, err := newApp(d, Config{Env: EnvDevelopment, Logger: quietLogger()}); err != nil { |
| t.Fatalf("newApp: %v", err) |
| } |
| - if _, err := ensureUser(context.Background(), d.G, " "); err == nil { |
| + if _, _, err := ensureUser(context.Background(), d.G, " "); err == nil { |
| t.Fatal("ensureUser accepted a blank address; want an error") |
| } |
| } |
diff --git a/internal/slides/deck/deck.go b/internal/slides/deck/deck.go| new file mode 100644 |
| index 0000000..4e8da71 |
| --- /dev/null |
| +++ b/internal/slides/deck/deck.go |
| @@ -0,0 +1,179 @@ |
| +// Package deck is the Go reading of the core's deck view — spec §2's |
| +// model as it arrives over the wasm boundary. |
| +// |
| +// The core is the contract, not this file: `core/src/view.rs` decides |
| +// the JSON, and every type here exists to receive it. The view fills a |
| +// default for nearly every key itself (an absent layout is `flow`, an |
| +// absent template is `blank`, an absent z is 0), so a struct of plain |
| +// fields is enough — this package validates nothing and repairs |
| +// nothing. The one exception is Block.UnmarshalJSON, and it says why. |
| +// |
| +// It is deliberately read-only. Writes go the other way, as op batches |
| +// the core applies (core/src/ops.rs); nothing here ever produces one. |
| +package deck |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +// Deck is one deck's whole view. |
| +type Deck struct { |
| + Meta Meta `json:"meta"` |
| + Slides []Slide `json:"slides"` |
| + Assets map[string]Asset `json:"assets"` |
| +} |
| + |
| +// Meta is the deck's own settings (§2.1). Accent arrives as null when |
| +// unset, which decodes to the empty string; Fonts arrives as null or as |
| +// one {heading, body} object, so it is a pointer — "no fonts chosen" and |
| +// "a pair of empty names" are different things to a renderer. |
| +type Meta struct { |
| + Title string `json:"title"` |
| + Theme string `json:"theme"` |
| + Accent string `json:"accent"` |
| + Fonts *Fonts `json:"fonts"` |
| + Aspect string `json:"aspect"` |
| +} |
| + |
| +// Fonts is meta.fonts: the heading and body families, chosen together. |
| +type Fonts struct { |
| + Heading string `json:"heading"` |
| + Body string `json:"body"` |
| +} |
| + |
| +// Slide is one slide (§2.2). Template is meaningful on a flow slide and |
| +// ignored on a canvas one; Phone is meaningful the other way round. |
| +type Slide struct { |
| + ID string `json:"id"` |
| + Layout string `json:"layout"` |
| + Template string `json:"template"` |
| + Background Background `json:"background"` |
| + Phone string `json:"phone"` |
| + Transition string `json:"transition"` |
| + Notes Text `json:"notes"` |
| + Blocks []Block `json:"blocks"` |
| +} |
| + |
| +// Background is a slide's backdrop: a theme default, a colour, or an |
| +// asset id. |
| +type Background struct { |
| + Kind string `json:"kind"` |
| + Value string `json:"value"` |
| +} |
| + |
| +// Block is one block (§2.4). A flow block carries Region and no Box; a |
| +// canvas block carries Box and Z and the view sends its region as null, |
| +// which decodes to the empty string. Props is free-form because the |
| +// vocabulary differs per type and the core already checked it. |
| +// |
| +// Text is present only for text-bearing types — the view omits the key |
| +// entirely for the others, so an empty Text means "this type carries |
| +// none", not "somebody deleted the words". |
| +type Block struct { |
| + ID string `json:"id"` |
| + Type string `json:"type"` |
| + Props map[string]any `json:"props"` |
| + Region string `json:"region"` |
| + Box *Box `json:"box"` |
| + Z int `json:"z"` |
| + Phone PhoneOverride `json:"phone"` |
| + Text Text `json:"text"` |
| +} |
| + |
| +// UnmarshalJSON is the plain decoding with one repair: z is read as a |
| +// number and truncated, rather than decoded straight into Z. |
| +// |
| +// The core writes whole z values, and its view serialises a whole |
| +// number as an integer — but z lives in the CRDT, and every browser |
| +// peer writing into the same document (task 6 onwards) can put a |
| +// fraction there, which is the ordinary trick for inserting between two |
| +// neighbours. Decoded straight, one such value would fail the whole |
| +// deck's parse and take the viewer down with it; truncating costs a |
| +// z-order tie and nothing else. |
| +func (b *Block) UnmarshalJSON(data []byte) error { |
| + // plain drops this method, so the embedded value decodes normally; |
| + // the outer Z is shallower and therefore wins for the "z" key. |
| + type plain Block |
| + var raw struct { |
| + plain |
| + Z float64 `json:"z"` |
| + } |
| + if err := json.Unmarshal(data, &raw); err != nil { |
| + return err |
| + } |
| + *b = Block(raw.plain) |
| + b.Z = int(raw.Z) |
| + return nil |
| +} |
| + |
| +// Box is a canvas block's frame, as fractions of the 16:9 stage (§2.3). |
| +type Box struct { |
| + X float64 `json:"x"` |
| + Y float64 `json:"y"` |
| + W float64 `json:"w"` |
| + H float64 `json:"h"` |
| +} |
| + |
| +// PhoneOverride is a block's phone layer (§2.5) — additions to the |
| +// desktop layout, never a replacement for it. Order is a pointer |
| +// because 0 is a real position and "unset" has to be distinguishable |
| +// from "first". |
| +type PhoneOverride struct { |
| + Hidden bool `json:"hidden"` |
| + Order *int `json:"order"` |
| + Size string `json:"size"` |
| + Box *Box `json:"box"` |
| +} |
| + |
| +// Asset is one registered image (§2.7). Ref is the object store |
| +// address; the bytes never pass through the CRDT. |
| +type Asset struct { |
| + Ref string `json:"ref"` |
| + Name string `json:"name"` |
| + Width int `json:"width"` |
| + Height int `json:"height"` |
| + Alt string `json:"alt"` |
| +} |
| + |
| +// Text is the §2.6 text schema as the view sends it: paragraphs of |
| +// runs. |
| +type Text [][]Run |
| + |
| +// Run is one stretch of text under one set of marks. The view omits a |
| +// false mark and an absent href, so the zero value is an unmarked run. |
| +type Run struct { |
| + T string `json:"t"` |
| + B bool `json:"b"` |
| + I bool `json:"i"` |
| + C bool `json:"c"` |
| + H bool `json:"h"` |
| + Href string `json:"href"` |
| +} |
| + |
| +// Plain is the text with every mark dropped: runs concatenated inside a |
| +// paragraph, paragraphs joined by a newline. It is what the plain deck |
| +// page renders, and what search, exports and the speaker view read when |
| +// they want words rather than formatting. |
| +func (t Text) Plain() string { |
| + var b strings.Builder |
| + for i, para := range t { |
| + if i > 0 { |
| + b.WriteByte('\n') |
| + } |
| + for _, run := range para { |
| + b.WriteString(run.T) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// Parse reads the core's view JSON (core.Handle.JSON) into a Deck. |
| +func Parse(b []byte) (*Deck, error) { |
| + var d Deck |
| + if err := json.Unmarshal(b, &d); err != nil { |
| + return nil, fmt.Errorf("deck: parse view: %w", err) |
| + } |
| + return &d, nil |
| +} |
diff --git a/internal/slides/deck/deck_test.go b/internal/slides/deck/deck_test.go| new file mode 100644 |
| index 0000000..8e24c60 |
| --- /dev/null |
| +++ b/internal/slides/deck/deck_test.go |
| @@ -0,0 +1,170 @@ |
| +package deck_test |
| + |
| +import ( |
| + "testing" |
| + |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| +) |
| + |
| +// viewFixture is the core's own view JSON (core/src/view.rs's module |
| +// comment, verbatim in shape), widened to cover every corner Parse has |
| +// to survive: a flow slide and a canvas one, a block with marked runs, |
| +// a block type that carries no `text` key at all, a phone override, a |
| +// registered asset, and the two meta keys that arrive as null. |
| +// |
| +// A literal rather than a call into the wasm core: this package must |
| +// parse what the core emits, and the core's own tests already prove |
| +// what it emits. Coupling this test to the module would mean a change |
| +// in the parser could only be tested by also loading 2MB of wasm. |
| +const viewFixture = `{ |
| + "meta": {"title":"Quarter review","theme":"ink","accent":"#2f6f4f", |
| + "fonts":{"heading":"Inter","body":"Inter"},"aspect":"16:9"}, |
| + "slides": [ |
| + {"id":"aaaaaaaaaaaa","layout":"flow","template":"title-body", |
| + "background":{"kind":"theme","value":""}, |
| + "phone":"inherit","transition":"none", |
| + "notes":[[{"t":"Open with the number, not the story."}]], |
| + "blocks":[ |
| + {"id":"bbbbbbbbbbbb","type":"heading","props":{"level":1}, |
| + "region":"heading","box":null,"z":0,"phone":{}, |
| + "text":[[{"t":"Hello ","b":true},{"t":"world"}]]}, |
| + {"id":"cccccccccccc","type":"text","props":{"size":"m"}, |
| + "region":"body","box":null,"z":0,"phone":{}, |
| + "text":[[{"t":"First"}],[{"t":"Second","i":true}]]}, |
| + {"id":"dddddddddddd","type":"divider","props":{}, |
| + "region":"body","box":null,"z":0,"phone":{}} |
| + ]}, |
| + {"id":"eeeeeeeeeeee","layout":"canvas","template":"blank", |
| + "background":{"kind":"color","value":"#101014"}, |
| + "phone":"reflow","transition":"fade","notes":[], |
| + "blocks":[ |
| + {"id":"ffffffffffff","type":"shape","props":{"kind":"rect"}, |
| + "region":null,"box":{"x":0.06,"y":0.3,"w":0.4,"h":0.5},"z":2, |
| + "phone":{"hidden":true,"order":1,"size":"s", |
| + "box":{"x":0,"y":0,"w":1,"h":0.4}}, |
| + "text":[[{"t":"label"}]]} |
| + ]} |
| + ], |
| + "assets": {"a1":{"ref":"sha256:beef","name":"chart.png", |
| + "width":1200,"height":800,"alt":"Revenue by month"}} |
| +}` |
| + |
| +func TestParseViewFixture(t *testing.T) { |
| + d, err := deck.Parse([]byte(viewFixture)) |
| + if err != nil { |
| + t.Fatalf("Parse: %v", err) |
| + } |
| + |
| + if d.Meta.Title != "Quarter review" || d.Meta.Theme != "ink" || d.Meta.Aspect != "16:9" { |
| + t.Errorf("meta = %+v", d.Meta) |
| + } |
| + if d.Meta.Accent != "#2f6f4f" { |
| + t.Errorf("meta.accent = %q, want %q", d.Meta.Accent, "#2f6f4f") |
| + } |
| + if d.Meta.Fonts == nil || d.Meta.Fonts.Heading != "Inter" { |
| + t.Errorf("meta.fonts = %+v, want the Inter pair", d.Meta.Fonts) |
| + } |
| + if len(d.Slides) != 2 { |
| + t.Fatalf("parsed %d slides, want 2", len(d.Slides)) |
| + } |
| + |
| + flow := d.Slides[0] |
| + if flow.ID != "aaaaaaaaaaaa" || flow.Layout != "flow" || flow.Template != "title-body" { |
| + t.Errorf("flow slide = %+v", flow) |
| + } |
| + if flow.Background.Kind != "theme" { |
| + t.Errorf("flow background = %+v", flow.Background) |
| + } |
| + if got := flow.Notes.Plain(); got != "Open with the number, not the story." { |
| + t.Errorf("notes.Plain() = %q", got) |
| + } |
| + if len(flow.Blocks) != 3 { |
| + t.Fatalf("flow slide has %d blocks, want 3", len(flow.Blocks)) |
| + } |
| + |
| + heading := flow.Blocks[0] |
| + if heading.Type != "heading" || heading.Region != "heading" { |
| + t.Errorf("heading block = %+v", heading) |
| + } |
| + if heading.Box != nil { |
| + t.Errorf("heading block box = %+v, want nil on a flow slide", heading.Box) |
| + } |
| + if lvl, _ := heading.Props["level"].(float64); lvl != 1 { |
| + t.Errorf("heading props = %v, want level 1", heading.Props) |
| + } |
| + // Runs concatenate inside a paragraph; marks ride along without |
| + // changing the plain text. |
| + if got := heading.Text.Plain(); got != "Hello world" { |
| + t.Errorf("heading.Text.Plain() = %q, want %q", got, "Hello world") |
| + } |
| + if !heading.Text[0][0].B || heading.Text[0][1].B { |
| + t.Errorf("heading runs lost their marks: %+v", heading.Text[0]) |
| + } |
| + |
| + // Paragraphs join with a newline, which is what the plain-text |
| + // deck page and every later exporter render between them. |
| + if got := flow.Blocks[1].Text.Plain(); got != "First\nSecond" { |
| + t.Errorf("text block Plain() = %q, want %q", got, "First\nSecond") |
| + } |
| + // A type with no `text` key at all parses to no paragraphs rather |
| + // than failing: the view omits the key for non-text-bearing types. |
| + if got := flow.Blocks[2].Text; got != nil { |
| + t.Errorf("divider carries text %+v, want none", got) |
| + } |
| + |
| + canvas := d.Slides[1] |
| + if canvas.Layout != "canvas" || canvas.Phone != "reflow" || canvas.Transition != "fade" { |
| + t.Errorf("canvas slide = %+v", canvas) |
| + } |
| + if canvas.Background.Kind != "color" || canvas.Background.Value != "#101014" { |
| + t.Errorf("canvas background = %+v", canvas.Background) |
| + } |
| + shape := canvas.Blocks[0] |
| + if shape.Region != "" { |
| + t.Errorf("canvas block region = %q, want empty (the view sends null)", shape.Region) |
| + } |
| + if shape.Box == nil || shape.Box.X != 0.06 || shape.Box.H != 0.5 { |
| + t.Errorf("canvas block box = %+v", shape.Box) |
| + } |
| + if shape.Z != 2 { |
| + t.Errorf("canvas block z = %d, want 2", shape.Z) |
| + } |
| + if !shape.Phone.Hidden || shape.Phone.Size != "s" { |
| + t.Errorf("phone override = %+v", shape.Phone) |
| + } |
| + if shape.Phone.Order == nil || *shape.Phone.Order != 1 { |
| + t.Errorf("phone override order = %v, want 1", shape.Phone.Order) |
| + } |
| + if shape.Phone.Box == nil || shape.Phone.Box.W != 1 { |
| + t.Errorf("phone override box = %+v", shape.Phone.Box) |
| + } |
| + |
| + asset, ok := d.Assets["a1"] |
| + if !ok { |
| + t.Fatalf("assets = %+v, want an a1", d.Assets) |
| + } |
| + if asset.Ref != "sha256:beef" || asset.Width != 1200 || asset.Alt != "Revenue by month" { |
| + t.Errorf("asset = %+v", asset) |
| + } |
| +} |
| + |
| +// A deck a peer left half-written still has to render: the core fills |
| +// most defaults itself, but `z` is the one number a browser peer can |
| +// write as a fraction, and a whole deck failing to parse over it would |
| +// take the viewer down with it. |
| +func TestParseToleratesFractionalZ(t *testing.T) { |
| + d, err := deck.Parse([]byte(`{"meta":{},"slides":[{"id":"a","blocks":[ |
| + {"id":"b","type":"shape","z":2.5}]}],"assets":{}}`)) |
| + if err != nil { |
| + t.Fatalf("Parse: %v", err) |
| + } |
| + if got := d.Slides[0].Blocks[0].Z; got != 2 { |
| + t.Errorf("z = %d, want 2 (2.5 truncated)", got) |
| + } |
| +} |
| + |
| +func TestParseRejectsRubbish(t *testing.T) { |
| + if _, err := deck.Parse([]byte("not json")); err == nil { |
| + t.Fatal("Parse accepted a non-JSON body") |
| + } |
| +} |
diff --git a/internal/slides/decks.go b/internal/slides/decks.go| new file mode 100644 |
| index 0000000..e0831cb |
| --- /dev/null |
| +++ b/internal/slides/decks.go |
| @@ -0,0 +1,1007 @@ |
| +// decks.go is the deck: its identifiers, the one seam every deck read |
| +// goes through, the two functions that move a document forward, and the |
| +// six screens that stand on them. |
| +// |
| +// The split it keeps: the `decks` row is what a list, a link or a |
| +// permission check needs; the Y.Doc is the content. Nothing here reads |
| +// content out of a column, and nothing writes content anywhere but |
| +// through an op batch the core applies. |
| +package slides |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/form" |
| + |
| + "amadan.net/oficina/slides/internal/slides/core" |
| + "amadan.net/oficina/slides/internal/slides/deck" |
| +) |
| + |
| +// --- identifiers --- |
| + |
| +// sqidAlphabet avoids the characters a person misreads or mistypes |
| +// when a deck link is read aloud: no 0/O, no 1/l/I. Docs' alphabet, |
| +// character for character — the two apps' URLs should look like they |
| +// come from the same suite. |
| +const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" |
| + |
| +// sqidLength is how many of those characters a deck's public name has. |
| +const sqidLength = 10 |
| + |
| +// maxSqidAttempts bounds the retry loop on a UNIQUE violation. Ten |
| +// characters over 31 symbols makes a collision astronomically |
| +// unlikely; this exists so one is handled rather than surprising. |
| +const maxSqidAttempts = 5 |
| + |
| +// newSqid mints a deck's public name. len(sqidAlphabet) is 31, not a |
| +// power of two, so `b % 31` is very slightly biased: 256 % 31 == 8, so |
| +// the first eight symbols come out about 3% more often than the rest. |
| +// Said out loud rather than papered over — at ten characters the bias |
| +// narrows the keyspace by a bounded fraction nowhere near enough to |
| +// make a deck link guessable, and Docs made the same trade. |
| +func newSqid() (string, error) { |
| + raw := make([]byte, sqidLength) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", fmt.Errorf("slides: generate sqid: %w", err) |
| + } |
| + out := make([]byte, sqidLength) |
| + for i, b := range raw { |
| + out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)] |
| + } |
| + return string(out), nil |
| +} |
| + |
| +// idAlphabet is spec §2.2's "12 lowercase base32 characters" for slide |
| +// and block ids. Thirty-two symbols exactly, so masking a random byte |
| +// with 31 is uniform — unlike the sqid alphabet above, this one has no |
| +// bias to admit to. |
| +const idAlphabet = "abcdefghijklmnopqrstuvwxyz234567" |
| + |
| +// idLength is spec §2.2's twelve. |
| +const idLength = 12 |
| + |
| +// newID mints a slide or block id. The core never draws entropy of its |
| +// own (core/src/ops.rs: on wasm the CRDT library's seed is a constant), |
| +// so every id in a document is minted here and travels in the op. |
| +func newID() (string, error) { |
| + raw := make([]byte, idLength) |
| + if _, err := rand.Read(raw); err != nil { |
| + return "", fmt.Errorf("slides: generate id: %w", err) |
| + } |
| + out := make([]byte, idLength) |
| + for i, b := range raw { |
| + out[i] = idAlphabet[b&31] |
| + } |
| + return string(out), nil |
| +} |
| + |
| +// --- vocabularies --- |
| + |
| +// The rungs of spec §5. Only private and team are offered by a screen |
| +// in this slice: granted needs a way to name people, which does not |
| +// exist yet, but a deck already sitting on that rung stays readable to |
| +// the people its grants name (visibleDecks) and its label renders. |
| +const ( |
| + rungPrivate = "private" |
| + rungGranted = "granted" |
| + rungTeam = "team" |
| +) |
| + |
| +// settableRungs is what the settings screen offers, in the order it |
| +// offers them. Not every rung there is — see the constants above. |
| +var settableRungs = []string{rungPrivate, rungTeam} |
| + |
| +// themeIDs are the six themes a deck can wear, in the order the picker |
| +// lists them. Identifiers, not copy: each one's visible name is a |
| +// catalog key (slides.theme.<id>). |
| +var themeIDs = []string{"paper", "ink", "sunset", "forest", "slate", "candy"} |
| + |
| +// defaultTheme is what a deck gets when nobody chose — the same value |
| +// the core defaults meta.theme to (core/src/schema.rs's DEFAULT_THEME), |
| +// so the row and the document agree before anything is written. |
| +const defaultTheme = "paper" |
| + |
| +// serverClientID is the Yjs client id every update this process writes |
| +// carries. One document is only ever edited server-side by one writer |
| +// at a time (a Handle is not safe for concurrent use, and every write |
| +// goes through applyOps), so a single fixed id is correct and stable |
| +// across restarts — which a random one would not be. |
| +const serverClientID = 1 |
| + |
| +// actorFor is spec §6.2's actor string for a signed-in person. Every |
| +// write records one. |
| +func actorFor(u User) string { return fmt.Sprintf("person:%d", u.ID) } |
| + |
| +// --- ops --- |
| + |
| +// op is one operation in a batch (spec §7.2, core/src/ops.rs). A map |
| +// rather than a Go type per op: the core owns the vocabulary and |
| +// validates every field of it, so a second declaration here would be a |
| +// copy to keep in step for no checking that isn't already done. What it |
| +// buys is that callers — sample.go especially — read as data. |
| +type op map[string]any |
| + |
| +// opsJSON encodes a batch for core.Handle.Ops. |
| +func opsJSON(ops []op) ([]byte, error) { |
| + b, err := json.Marshal(ops) |
| + if err != nil { |
| + return nil, fmt.Errorf("slides: encode ops: %w", err) |
| + } |
| + return b, nil |
| +} |
| + |
| +// --- the visibility seam --- |
| + |
| +// visibleDecks is the one query every deck read goes through: decks |
| +// viewer owns, decks on the team rung, and decks a grant names them on |
| +// (spec §5). |
| +// |
| +// Not rastrillo's scope.Owned (SKILL.md §3), which is owner-only: a |
| +// deck's audience here is wider than its owner by design, and a helper |
| +// that silently kept filtering to creator-only would make a shared deck |
| +// look deleted rather than broken. Written here so the day grants get a |
| +// screen, this is the one function that changes. |
| +// |
| +// A deck that is not in here does not exist: handlers answer 404, never |
| +// 403, so a stranger holding a sqid cannot learn whether it is real. |
| +// |
| +// The condition is parenthesised in the string rather than left to |
| +// GORM's clause building, because every caller chains another Where |
| +// onto it and an unparenthesised OR would quietly widen to "or this |
| +// deck is on the team rung" — the whole seam defeated by operator |
| +// precedence. |
| +func (a *app) visibleDecks(ctx context.Context, viewer User) *gorm.DB { |
| + g := a.db.G.WithContext(ctx).Model(&Deck{}) |
| + if viewer.ID <= 0 { |
| + // Never reached past RequireSession; refusing outright beats |
| + // trusting every caller to have checked, because the value |
| + // this guards against — 0 — would otherwise match every row a |
| + // backfill had ever left ownerless. |
| + return g.Where("1 = 0") |
| + } |
| + return g.Where( |
| + "(decks.owner_id = ? OR decks.rung = ? OR EXISTS ("+ |
| + "SELECT 1 FROM deck_grants WHERE deck_grants.deck_id = decks.id AND deck_grants.user_id = ?))", |
| + viewer.ID, rungTeam, viewer.ID) |
| +} |
| + |
| +// --- the document --- |
| + |
| +// openDeck instantiates d's Y.Doc: a fresh wasm module, every stored |
| +// update replayed into it in seq order, and the view it produces. |
| +// |
| +// The handle is the CALLER's to close. One module per request is |
| +// deliberate — an instance is not safe for concurrent use, and task 6's |
| +// hub is where a long-lived one will live. |
| +// |
| +// There are no snapshots yet: this replays the whole log every time. |
| +// That is fine at the sizes this app has today and stops being fine |
| +// somewhere around a thousand updates on one deck, which is the moment |
| +// to persist Handle.State as a snapshot row and replay from it. |
| +func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, error) { |
| + h, err := a.core.NewDoc(ctx, d.Sqid, serverClientID) |
| + if err != nil { |
| + return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err) |
| + } |
| + |
| + var updates []DeckUpdate |
| + if err := a.db.G.WithContext(ctx). |
| + Where("deck_id = ?", d.ID).Order("seq").Find(&updates).Error; err != nil { |
| + h.Close(ctx) |
| + return nil, nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err) |
| + } |
| + for _, u := range updates { |
| + if err := h.Apply(ctx, u.Bytes); err != nil { |
| + h.Close(ctx) |
| + return nil, nil, fmt.Errorf("slides: replay deck %s update %d: %w", d.Sqid, u.Seq, err) |
| + } |
| + } |
| + |
| + view, err := h.JSON(ctx) |
| + if err != nil { |
| + h.Close(ctx) |
| + return nil, nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err) |
| + } |
| + parsed, err := deck.Parse(view) |
| + if err != nil { |
| + h.Close(ctx) |
| + return nil, nil, err |
| + } |
| + return h, parsed, nil |
| +} |
| + |
| +// errDeckBusy is applyOps' optimistic-lock failure: somebody else's |
| +// update landed between this one reading Deck.Seq and writing it. The |
| +// batch applied nothing; the caller reopens the deck and tries again. |
| +var errDeckBusy = errors.New("slides: the deck moved while this update was being written") |
| + |
| +// applyOps runs one batch against an open handle and records what it |
| +// produced: the update bytes as the next row of d's log, and d.Seq |
| +// bumped to match — both in one transaction, so a deck can never have |
| +// a seq the log cannot account for. |
| +// |
| +// The seq bump is the optimistic lock (`WHERE id = ? AND seq = ?`): if |
| +// no row matched, another writer got there first and the whole |
| +// transaction rolls back with errDeckBusy. |
| +// |
| +// One honest wrinkle: h.Ops runs INSIDE the transaction, so a rollback |
| +// leaves the handle holding a change the database does not. That is |
| +// harmless while handles are per-request and discarded after a failure |
| +// — which is every caller today — and is the reason editDeck reopens |
| +// rather than retrying on the same handle. |
| +// |
| +// d.Seq is updated in place on success so a caller applying two batches |
| +// in a row does not have to re-read the row. |
| +func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor string, ops []byte) (*deck.Deck, error) { |
| + err := a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + update, err := h.Ops(ctx, ops) |
| + if err != nil { |
| + return fmt.Errorf("slides: apply ops to deck %s: %w", d.Sqid, err) |
| + } |
| + |
| + next := d.Seq + 1 |
| + if err := tx.Create(&DeckUpdate{ |
| + DeckID: d.ID, |
| + Seq: next, |
| + Actor: actor, |
| + Bytes: update, |
| + CreatedAt: time.Now().UTC(), |
| + }).Error; err != nil { |
| + return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err) |
| + } |
| + |
| + res := tx.Model(&Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq). |
| + Updates(map[string]any{"seq": next}) |
| + if res.Error != nil { |
| + return fmt.Errorf("slides: bump deck %s seq: %w", d.Sqid, res.Error) |
| + } |
| + if res.RowsAffected != 1 { |
| + return errDeckBusy |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + // After the commit, not inside it: reading the view is another set |
| + // of calls into the wasm module, and SQLite has one writer — doing |
| + // it under the transaction would hold that connection for the whole |
| + // round trip and serialise every other write behind it. |
| + raw, err := h.JSON(ctx) |
| + if err != nil { |
| + return nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err) |
| + } |
| + view, err := deck.Parse(raw) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + d.Seq++ |
| + // After the commit, never inside it: a subscriber told about a seq |
| + // a rolled-back transaction never wrote would broadcast an update |
| + // nobody can read back. Nil until task 6 mounts the hub. |
| + if a.onUpdate != nil { |
| + a.onUpdate(d.ID, d.Seq) |
| + } |
| + return view, nil |
| +} |
| + |
| +// editDeck is applyOps with the handle's whole life around it: open the |
| +// deck, apply, close. One retry on errDeckBusy, from a freshly opened |
| +// document — the batch applied nothing, so replaying it against the |
| +// state that beat us is the correct thing to do, and a second conflict |
| +// means real contention rather than a race worth spinning on. |
| +func (a *app) editDeck(ctx context.Context, d *Deck, actor string, ops []byte) error { |
| + var lastErr error |
| + for attempt := 0; attempt < 2; attempt++ { |
| + h, _, err := a.openDeck(ctx, *d) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = a.applyOps(ctx, h, d, actor, ops) |
| + h.Close(ctx) |
| + if err == nil { |
| + return nil |
| + } |
| + lastErr = err |
| + if !errors.Is(err, errDeckBusy) { |
| + return err |
| + } |
| + // Re-read the row: our copy's Seq is what just lost. |
| + if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil { |
| + return fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err) |
| + } |
| + } |
| + return lastErr |
| +} |
| + |
| +// createDeckRow inserts the row, retrying on a sqid collision. The |
| +// document itself is empty at this point — the caller's first op batch |
| +// is what puts anything in it. |
| +func (a *app) createDeckRow(ctx context.Context, owner User, title, theme string) (Deck, error) { |
| + now := time.Now().UTC() |
| + var lastErr error |
| + for attempt := 0; attempt < maxSqidAttempts; attempt++ { |
| + sqid, err := newSqid() |
| + if err != nil { |
| + return Deck{}, err |
| + } |
| + d := Deck{ |
| + Sqid: sqid, |
| + Title: title, |
| + OwnerID: owner.ID, |
| + Rung: rungPrivate, // spec §5: every deck starts private |
| + Theme: theme, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + err = a.db.G.WithContext(ctx).Create(&d).Error |
| + if err == nil { |
| + return d, nil |
| + } |
| + lastErr = err |
| + if !errors.Is(err, gorm.ErrDuplicatedKey) { |
| + break |
| + } |
| + } |
| + return Deck{}, fmt.Errorf("slides: create deck: %w", lastErr) |
| +} |
| + |
| +// --- request plumbing --- |
| + |
| +// viewer is the signed-in person, or a rendered 500 and false. Past |
| +// RequireSession a failure here means the users row could not be read, |
| +// not that nobody is signed in, so it is an error page and not a |
| +// redirect. |
| +func (a *app) viewer(w http.ResponseWriter, r *http.Request) (User, bool) { |
| + u, ok := a.currentUser(r) |
| + if !ok { |
| + a.fail(w, r, "no current user past the session gate", nil) |
| + return User{}, false |
| + } |
| + return u, true |
| +} |
| + |
| +// fail logs why with a ref and renders the app's own 500. |
| +func (a *app) fail(w http.ResponseWriter, r *http.Request, what string, err error) { |
| + ref := rastrillo.NewRef() |
| + a.logger.Error("slides: "+what, "err", err, "ref", ref) |
| + ErrorPage(w, r, http.StatusInternalServerError, ref) |
| +} |
| + |
| +// notFound is the answer for a deck that is not this person's to see — |
| +// and for one that does not exist. Deliberately the same answer: see |
| +// visibleDecks. |
| +func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| + ErrorPage(w, r, http.StatusNotFound, "") |
| +} |
| + |
| +// deckFor resolves the {sqid} in r to a deck u may read, answering 404 |
| +// itself when there is none. |
| +func (a *app) deckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) { |
| + var d Deck |
| + err := a.visibleDecks(r.Context(), u). |
| + Where("decks.sqid = ?", chi.URLParam(r, "sqid")).Take(&d).Error |
| + switch { |
| + case err == nil: |
| + return d, true |
| + case errors.Is(err, gorm.ErrRecordNotFound): |
| + a.notFound(w, r) |
| + default: |
| + a.fail(w, r, "load deck", err) |
| + } |
| + return Deck{}, false |
| +} |
| + |
| +// ownedDeckFor is deckFor for a write: settings and delete are the |
| +// owner's alone, and a team member who can read the deck gets the same |
| +// 404 as a stranger rather than a 403 that would confirm it exists. |
| +func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) { |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return Deck{}, false |
| + } |
| + if d.OwnerID != u.ID { |
| + a.notFound(w, r) |
| + return Deck{}, false |
| + } |
| + return d, true |
| +} |
| + |
| +// --- shared view data --- |
| + |
| +// shell is what every signed-in page carries for the layout's account |
| +// menu (templates/account.html). Embedded in each page's own view |
| +// struct, so the menu is one partial rather than a block redefined in |
| +// every template. |
| +type shell struct { |
| + Person string |
| + SignoutLabel string |
| +} |
| + |
| +func shellFor(r *http.Request, u User) shell { |
| + person := u.Name |
| + if person == "" { |
| + person = u.Email |
| + } |
| + return shell{Person: person, SignoutLabel: rastrillo.T(r, "slides.account.signout")} |
| +} |
| + |
| +// selectOption is one entry of ui's field-select Options. |
| +type selectOption struct { |
| + Value string |
| + Label string |
| + Selected bool |
| +} |
| + |
| +// choiceOption is one card of ui's choice-field Options. |
| +type choiceOption struct { |
| + Value string |
| + Title string |
| + Desc string |
| + Checked bool |
| +} |
| + |
| +// themeOptions builds the theme picker with current pre-selected. |
| +func themeOptions(r *http.Request, current string) []selectOption { |
| + opts := make([]selectOption, 0, len(themeIDs)) |
| + for _, id := range themeIDs { |
| + opts = append(opts, selectOption{ |
| + Value: id, |
| + Label: rastrillo.T(r, "slides.theme."+id), |
| + Selected: id == current, |
| + }) |
| + } |
| + return opts |
| +} |
| + |
| +// rungOptions builds the rung radio group. Each card is a short title |
| +// plus one explaining line, per choice-field's own contract — "Private" |
| +// on its own does not say who that leaves out. |
| +func rungOptions(r *http.Request, current string) []choiceOption { |
| + opts := make([]choiceOption, 0, len(settableRungs)) |
| + for _, rung := range settableRungs { |
| + opts = append(opts, choiceOption{ |
| + Value: rung, |
| + Title: rastrillo.T(r, "slides.rung."+rung), |
| + Desc: rastrillo.T(r, "slides.rung."+rung+".desc"), |
| + Checked: rung == current, |
| + }) |
| + } |
| + return opts |
| +} |
| + |
| +// rungLabel is the pill's visible text. Always text: state is never |
| +// colour alone. |
| +func rungLabel(r *http.Request, rung string) string { |
| + switch rung { |
| + case rungPrivate, rungGranted, rungTeam: |
| + return rastrillo.T(r, "slides.rung."+rung) |
| + default: |
| + // A rung nothing here knows: name the raw value rather than |
| + // rendering an empty pill, so it is debuggable from the page. |
| + return rung |
| + } |
| +} |
| + |
| +// rungTone tints the pill. Private is the quiet default; team is the |
| +// one worth noticing, because it is the rung that widens who can read. |
| +func rungTone(rung string) string { |
| + if rung == rungTeam { |
| + return "positive" |
| + } |
| + return "neutral" |
| +} |
| + |
| +// validTheme reports whether a submitted theme id is one of the six. |
| +func validTheme(id string) bool { |
| + for _, t := range themeIDs { |
| + if t == id { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// validRung reports whether a submitted rung is one a screen may set. |
| +func validRung(rung string) bool { |
| + for _, r := range settableRungs { |
| + if r == rung { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// --- GET / : the deck list --- |
| + |
| +type deckRow struct { |
| + Href string |
| + Title string |
| + Sub string |
| + RungLabel string |
| + RungTone string |
| +} |
| + |
| +type decksPage struct { |
| + shell |
| + Title string |
| + NewHref string |
| + NewLabel string |
| + EmptyTitle string |
| + EmptyBody string |
| + Decks []deckRow |
| +} |
| + |
| +// decks serves GET /: every deck this person may open, most recently |
| +// edited first. |
| +// |
| +// The slide count costs one wasm instance per deck, because the count |
| +// lives in the document and nothing else knows it. That is the honest |
| +// price of not denormalising a number that a peer's update can change |
| +// without this process noticing; it is fine at a small team's deck |
| +// count and it is the first thing to revisit if this screen ever feels |
| +// slow — task 6's hub already holds a live handle per open deck. |
| +func (a *app) decks(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + |
| + var rows []Deck |
| + if err := a.visibleDecks(r.Context(), u). |
| + Order("decks.updated_at DESC, decks.id DESC").Find(&rows).Error; err != nil { |
| + a.fail(w, r, "list decks", err) |
| + return |
| + } |
| + |
| + page := decksPage{ |
| + shell: shellFor(r, u), |
| + Title: rastrillo.T(r, "slides.decks.title"), |
| + NewHref: "/decks/new", |
| + NewLabel: rastrillo.T(r, "slides.decks.new"), |
| + EmptyTitle: rastrillo.T(r, "slides.decks.empty.title"), |
| + EmptyBody: rastrillo.T(r, "slides.decks.empty.body"), |
| + Decks: make([]deckRow, 0, len(rows)), |
| + } |
| + for _, d := range rows { |
| + slides, counted := a.slideCount(r.Context(), d) |
| + page.Decks = append(page.Decks, deckRow{ |
| + Href: "/d/" + d.Sqid, |
| + Title: d.Title, |
| + Sub: rowSub(r, slides, counted, d.UpdatedAt), |
| + RungLabel: rungLabel(r, d.Rung), |
| + RungTone: rungTone(d.Rung), |
| + }) |
| + } |
| + render(w, "decks", page) |
| +} |
| + |
| +// slideCount opens d far enough to count its slides. A deck that will |
| +// not open is logged and reported as uncounted rather than failing the |
| +// whole screen: the list is how a person reaches every OTHER deck, so |
| +// one unreadable document must not take the other nine away with it. |
| +func (a *app) slideCount(ctx context.Context, d Deck) (int, bool) { |
| + h, view, err := a.openDeck(ctx, d) |
| + if err != nil { |
| + a.logger.Error("slides: open deck for the list", "deck", d.Sqid, "err", err) |
| + return 0, false |
| + } |
| + defer h.Close(ctx) |
| + return len(view.Slides), true |
| +} |
| + |
| +// rowSub is a list row's meta line: how many slides, and when it was |
| +// last edited. An uncounted deck (see slideCount) shows the time alone |
| +// rather than a count that would be a guess. |
| +func rowSub(r *http.Request, slides int, counted bool, updated time.Time) string { |
| + when := whenWords(r, updated) |
| + if !counted { |
| + return rastrillo.Tf(r, "slides.decks.row.sub.plain", "when", when) |
| + } |
| + count := rastrillo.Tf(r, "slides.decks.row.slides", "n", slides) |
| + if slides == 1 { |
| + count = rastrillo.T(r, "slides.decks.row.slides.one") |
| + } |
| + return rastrillo.Tf(r, "slides.decks.row.sub", "slides", count, "when", when) |
| +} |
| + |
| +// whenWords is a day-grained relative phrase, falling back to the date |
| +// itself once "n days ago" stops being easier to read than a date. |
| +// Day granularity on purpose: a deck list is scanned for which deck, |
| +// not for what minute. |
| +func whenWords(r *http.Request, t time.Time) string { |
| + days := int(time.Since(t).Hours() / 24) |
| + switch { |
| + case days <= 0: |
| + return rastrillo.T(r, "slides.when.today") |
| + case days == 1: |
| + return rastrillo.T(r, "slides.when.yesterday") |
| + case days < 7: |
| + return rastrillo.Tf(r, "slides.when.days", "n", days) |
| + default: |
| + return rastrillo.Tf(r, "slides.when.on", "date", t.Format("2 January 2006")) |
| + } |
| +} |
| + |
| +// --- GET /decks/new, POST /decks --- |
| + |
| +type deckNewPage struct { |
| + shell |
| + Title string |
| + Body string |
| + TitleLabel string |
| + TitleValue string |
| + ThemeLabel string |
| + Themes []selectOption |
| + Submit string |
| + CancelHref string |
| + CancelLabel string |
| + Errors map[string]string |
| +} |
| + |
| +func (a *app) newDeck(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + render(w, "deck_new", a.newDeckPage(r, u, "", defaultTheme, nil)) |
| +} |
| + |
| +func (a *app) newDeckPage(r *http.Request, u User, title, theme string, errs map[string]string) deckNewPage { |
| + return deckNewPage{ |
| + shell: shellFor(r, u), |
| + Title: rastrillo.T(r, "slides.decks.new"), |
| + Body: rastrillo.T(r, "slides.decks.new.body"), |
| + TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| + TitleValue: title, |
| + ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"), |
| + Themes: themeOptions(r, theme), |
| + Submit: rastrillo.T(r, "slides.decks.new.submit"), |
| + CancelHref: "/", |
| + CancelLabel: rastrillo.T(r, "slides.cancel"), |
| + Errors: errs, |
| + } |
| +} |
| + |
| +// createDeck serves POST /decks: the row, then the document's opening |
| +// batch — the title and theme in meta, and one title slide carrying the |
| +// same words, so a new deck opens on something rather than on nothing. |
| +func (a *app) createDeck(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + |
| + p := form.Parse(r, form.Field{Name: "title", Required: true}) |
| + title := p.String("title") |
| + theme := r.PostFormValue("theme") |
| + errs := map[string]string{} |
| + if !p.OK() { |
| + // The framework's own message is English built from the field |
| + // name; this app's every visible string is a catalog key, so |
| + // the failure is kept and the sentence replaced. |
| + errs["Title"] = rastrillo.T(r, "slides.deck.title.required") |
| + } |
| + if !validTheme(theme) { |
| + errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid") |
| + } |
| + if len(errs) > 0 { |
| + w.WriteHeader(http.StatusUnprocessableEntity) |
| + render(w, "deck_new", a.newDeckPage(r, u, p.Echo()["title"], theme, errs)) |
| + return |
| + } |
| + |
| + d, err := a.createDeckRow(r.Context(), u, title, theme) |
| + if err != nil { |
| + a.fail(w, r, "create deck", err) |
| + return |
| + } |
| + ops, err := openingOps(title, theme) |
| + if err != nil { |
| + a.fail(w, r, "build the opening ops", err) |
| + return |
| + } |
| + if err := a.editDeck(r.Context(), &d, actorFor(u), ops); err != nil { |
| + a.fail(w, r, "write the opening ops", err) |
| + return |
| + } |
| + http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther) |
| +} |
| + |
| +// openingOps is a brand-new deck's first batch: meta, one flow slide on |
| +// the `title` template, and a level-1 heading carrying the deck's name. |
| +func openingOps(title, theme string) ([]byte, error) { |
| + slide, err := newID() |
| + if err != nil { |
| + return nil, err |
| + } |
| + heading, err := newID() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return opsJSON([]op{ |
| + {"op": "set_meta", "title": title, "theme": theme}, |
| + {"op": "add_slide", "id": slide, "layout": "flow", "template": "title"}, |
| + {"op": "add_block", "slide": slide, "id": heading, "type": "heading", |
| + "props": map[string]any{"level": 1}, "region": "heading", "markdown": title}, |
| + }) |
| +} |
| + |
| +// --- GET /d/{sqid} --- |
| + |
| +type blockView struct { |
| + Type string |
| + Text string |
| +} |
| + |
| +type slideView struct { |
| + Heading string |
| + Layout string |
| + Template string |
| + Notes string |
| + Blocks []blockView |
| +} |
| + |
| +type deckPage struct { |
| + shell |
| + Title string |
| + RungLabel string |
| + RungTone string |
| + SettingsHref string |
| + SettingsLabel string |
| + NotesLabel string |
| + BackHref string |
| + BackLabel string |
| + Slides []slideView |
| +} |
| + |
| +// showDeck serves GET /d/{sqid}. It is deliberately the plainest thing |
| +// that shows the document is real: the title, the rung, and one section |
| +// per slide naming each block's type and its words. Task 5 replaces |
| +// this whole handler with the renderer and the editor — nothing here is |
| +// meant to survive it, so nothing here is styled beyond the shell. |
| +func (a *app) showDeck(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.deckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + |
| + h, view, err := a.openDeck(r.Context(), d) |
| + if err != nil { |
| + a.fail(w, r, "open deck", err) |
| + return |
| + } |
| + defer h.Close(r.Context()) |
| + |
| + page := deckPage{ |
| + shell: shellFor(r, u), |
| + Title: view.Meta.Title, |
| + RungLabel: rungLabel(r, d.Rung), |
| + RungTone: rungTone(d.Rung), |
| + NotesLabel: rastrillo.T(r, "slides.deck.notes.label"), |
| + BackHref: "/", |
| + BackLabel: rastrillo.T(r, "slides.decks.title"), |
| + SettingsLabel: rastrillo.T(r, "slides.deck.settings"), |
| + Slides: make([]slideView, 0, len(view.Slides)), |
| + } |
| + if d.OwnerID == u.ID { |
| + page.SettingsHref = "/d/" + d.Sqid + "/settings" |
| + } |
| + for i, s := range view.Slides { |
| + sv := slideView{ |
| + Heading: rastrillo.Tf(r, "slides.deck.slide", "n", i+1), |
| + Layout: s.Layout, |
| + Template: s.Template, |
| + Notes: s.Notes.Plain(), |
| + Blocks: make([]blockView, 0, len(s.Blocks)), |
| + } |
| + for _, b := range s.Blocks { |
| + sv.Blocks = append(sv.Blocks, blockView{Type: b.Type, Text: b.Text.Plain()}) |
| + } |
| + page.Slides = append(page.Slides, sv) |
| + } |
| + render(w, "deck", page) |
| +} |
| + |
| +// --- GET/POST /d/{sqid}/settings --- |
| + |
| +type deckSettingsPage struct { |
| + shell |
| + Title string |
| + Action string |
| + TitleLabel string |
| + TitleValue string |
| + ThemeLabel string |
| + Themes []selectOption |
| + RungLegend string |
| + Rungs []choiceOption |
| + Submit string |
| + CancelHref string |
| + CancelLabel string |
| + DeleteHref string |
| + DeleteLabel string |
| + DeleteBody string |
| + BackHref string |
| + BackLabel string |
| + Errors map[string]string |
| +} |
| + |
| +func (a *app) settingsPage(r *http.Request, u User, d Deck, title, theme, rung string, errs map[string]string) deckSettingsPage { |
| + base := "/d/" + d.Sqid |
| + return deckSettingsPage{ |
| + shell: shellFor(r, u), |
| + Title: rastrillo.T(r, "slides.deck.settings"), |
| + Action: base + "/settings", |
| + TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| + TitleValue: title, |
| + ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"), |
| + Themes: themeOptions(r, theme), |
| + RungLegend: rastrillo.T(r, "slides.deck.rung.legend"), |
| + Rungs: rungOptions(r, rung), |
| + Submit: rastrillo.T(r, "slides.deck.settings.submit"), |
| + CancelHref: base, |
| + CancelLabel: rastrillo.T(r, "slides.cancel"), |
| + DeleteHref: base + "/delete", |
| + DeleteLabel: rastrillo.T(r, "slides.deck.delete"), |
| + DeleteBody: rastrillo.T(r, "slides.deck.delete.lead"), |
| + BackHref: base, |
| + BackLabel: rastrillo.T(r, "slides.deck.back"), |
| + Errors: errs, |
| + } |
| +} |
| + |
| +func (a *app) deckSettings(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.ownedDeckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + render(w, "deck_settings", a.settingsPage(r, u, d, d.Title, d.Theme, d.Rung, nil)) |
| +} |
| + |
| +// saveDeckSettings serves POST /d/{sqid}/settings. |
| +// |
| +// Title and theme live in two places — the row the list reads and the |
| +// document everyone edits — so both are written. The document goes |
| +// first: it is what the deck IS, and a crash between the two writes |
| +// should leave the list stale rather than leave the list promising a |
| +// name the deck does not have. |
| +func (a *app) saveDeckSettings(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.ownedDeckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + |
| + p := form.Parse(r, form.Field{Name: "title", Required: true}) |
| + title := p.String("title") |
| + theme := r.PostFormValue("theme") |
| + rung := r.PostFormValue("rung") |
| + errs := map[string]string{} |
| + if !p.OK() { |
| + errs["Title"] = rastrillo.T(r, "slides.deck.title.required") |
| + } |
| + if !validTheme(theme) { |
| + errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid") |
| + } |
| + if !validRung(rung) { |
| + errs["Rung"] = rastrillo.T(r, "slides.deck.rung.invalid") |
| + } |
| + if len(errs) > 0 { |
| + w.WriteHeader(http.StatusUnprocessableEntity) |
| + render(w, "deck_settings", a.settingsPage(r, u, d, p.Echo()["title"], theme, rung, errs)) |
| + return |
| + } |
| + |
| + ops, err := opsJSON([]op{{"op": "set_meta", "title": title, "theme": theme}}) |
| + if err != nil { |
| + a.fail(w, r, "build the settings ops", err) |
| + return |
| + } |
| + if err := a.editDeck(r.Context(), &d, actorFor(u), ops); err != nil { |
| + a.fail(w, r, "write the settings ops", err) |
| + return |
| + } |
| + // Named columns, never a bound struct (SKILL.md §4), and scoped to |
| + // this owner's deck so the write carries the same condition the |
| + // read did. |
| + if err := a.db.G.WithContext(r.Context()).Model(&Deck{}). |
| + Where("id = ? AND owner_id = ?", d.ID, u.ID). |
| + Updates(map[string]any{"title": title, "theme": theme, "rung": rung}).Error; err != nil { |
| + a.fail(w, r, "save deck settings", err) |
| + return |
| + } |
| + http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther) |
| +} |
| + |
| +// --- GET/POST /d/{sqid}/delete --- |
| + |
| +type deckDeletePage struct { |
| + shell |
| + Title string |
| + Body string |
| + Action string |
| + Label string |
| + CancelHref string |
| + CancelLabel string |
| + BackHref string |
| + BackLabel string |
| +} |
| + |
| +// confirmDeleteDeck serves GET /d/{sqid}/delete: the destructive |
| +// action's own URL and its own page, per the app's own convention. No |
| +// modal, no JavaScript, and a GET that changes nothing. |
| +func (a *app) confirmDeleteDeck(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.ownedDeckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + base := "/d/" + d.Sqid |
| + render(w, "deck_delete", deckDeletePage{ |
| + shell: shellFor(r, u), |
| + Title: rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title), |
| + Body: rastrillo.T(r, "slides.deck.delete.body"), |
| + Action: base + "/delete", |
| + Label: rastrillo.T(r, "slides.deck.delete.confirm"), |
| + CancelHref: base + "/settings", |
| + CancelLabel: rastrillo.T(r, "slides.cancel"), |
| + BackHref: base + "/settings", |
| + BackLabel: rastrillo.T(r, "slides.deck.settings"), |
| + }) |
| +} |
| + |
| +// deleteDeck serves POST /d/{sqid}/delete. The row, its whole update |
| +// log and its grants go in one transaction: a deck's updates without |
| +// their deck are unreachable bytes nothing will ever replay, and a |
| +// grant to a deck that is gone is a permission pointing at nothing. |
| +func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| + u, ok := a.viewer(w, r) |
| + if !ok { |
| + return |
| + } |
| + d, ok := a.ownedDeckFor(w, r, u) |
| + if !ok { |
| + return |
| + } |
| + |
| + err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { |
| + if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckUpdate{}).Error; err != nil { |
| + return err |
| + } |
| + if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckGrant{}).Error; err != nil { |
| + return err |
| + } |
| + return tx.Where("id = ? AND owner_id = ?", d.ID, u.ID).Delete(&Deck{}).Error |
| + }) |
| + if err != nil { |
| + a.fail(w, r, "delete deck", err) |
| + return |
| + } |
| + http.Redirect(w, r, "/", http.StatusSeeOther) |
| +} |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index 2c964b7..e051dd4 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -2,70 +2,33 @@ package slides |
| import ( |
| "log/slog" |
| - "net/http" |
| - "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| + |
| + "amadan.net/oficina/slides/internal/slides/core" |
| ) |
| // app holds what every handler needs: the database, the logger, the |
| // RESOLVED config — every fail-closed default already applied, so |
| // a.cfg.Origin is the origin this app is really protecting and |
| -// a.cfg.Mailer is the sender it is really using — and the parsed |
| -// allowlist. Config is also where the object store arrives |
| -// (Config.Blobs) for the deck screens that come next. |
| +// a.cfg.Mailer is the sender it is really using — the parsed allowlist, |
| +// and the deck core. Config is also where the object store arrives |
| +// (Config.Blobs) for the image screens that come next. |
| +// |
| +// The handlers themselves live beside what they serve: sign-in in |
| +// auth.go, the deck screens in decks.go. |
| type app struct { |
| db *db.DB |
| logger *slog.Logger |
| cfg Config |
| allowed allowlist |
| -} |
| - |
| -// indexPage is GET /'s view data — the deck list. Every string on it is |
| -// resolved here rather than written into the template, so the whole |
| -// screen is one catalog lookup away from another language. |
| -type indexPage struct { |
| - Title string |
| - EmptyTitle string |
| - EmptyBody string |
| - SignoutLabel string |
| - // Person is who this is, for the account menu. The name when there |
| - // is one, the address otherwise. |
| - Person string |
| -} |
| - |
| -// index serves GET /: this person's decks. Behind RequireSession, so |
| -// currentUser always finds somebody — the row is created here, on the |
| -// first request after a first sign-in. |
| -// |
| -// The list itself is empty for now: there is no deck to put in it until |
| -// decks exist. The empty state is the screen, not a placeholder for |
| -// one — it says what this screen is for and it is what a new person |
| -// meets on their very first visit either way. |
| -func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - u, ok := a.currentUser(r) |
| - if !ok { |
| - // Past RequireSession this means the users row could not be |
| - // read (currentUser logged why), not that nobody is signed in. |
| - // ErrorPage, not http.Error: a failure here gets the app's own |
| - // page in the app's own shell, the same one a panic gets, and |
| - // a ref a person can quote — plain text on a blank page is |
| - // what this app renders when the renderer itself is broken, |
| - // which is not the case here. |
| - ref := rastrillo.NewRef() |
| - a.logger.Error("slides: index: no current user past the session gate", "ref", ref) |
| - ErrorPage(w, r, http.StatusInternalServerError, ref) |
| - return |
| - } |
| - person := u.Name |
| - if person == "" { |
| - person = u.Email |
| - } |
| - render(w, "index", indexPage{ |
| - Title: rastrillo.T(r, "slides.decks.title"), |
| - EmptyTitle: rastrillo.T(r, "slides.decks.empty.title"), |
| - EmptyBody: rastrillo.T(r, "slides.decks.empty.body"), |
| - SignoutLabel: rastrillo.T(r, "slides.account.signout"), |
| - Person: person, |
| - }) |
| + // core is the compiled deck core, shared by every request. |
| + // Compiling the wasm module is expensive and instantiating it is |
| + // not, so there is exactly one of these per process and a Handle |
| + // (one module instance) per deck opened. |
| + core *core.Runtime |
| + // onUpdate is told, after the transaction commits, that a deck |
| + // moved to a new seq. Nil until task 6 mounts the hub that |
| + // broadcasts to open browsers; every caller is nil-safe. |
| + onUpdate func(deckID, seq int64) |
| } |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| index 6492417..8bbb2ce 100644 |
| --- a/internal/slides/locales/en.toml |
| +++ b/internal/slides/locales/en.toml |
| @@ -27,14 +27,87 @@ slides.signin.error.address = "That doesn't look like a valid email address." |
| slides.signin.error.expired = "That link has expired or was already used. Enter your email below for a new one." |
| slides.signin.error.unknown = "We couldn't sign you in. Enter your email below for a new link." |
| -# slides.decks.* — GET /, the deck list. Title is the screen's noun, |
| -# per page-header's own contract ("Decks", not "Home"). |
| +# slides.decks.* — GET /, the deck list, and GET /decks/new. Title is |
| +# the screen's noun, per page-header's own contract ("Decks", not |
| +# "Home"). |
| # |
| # The empty state is a designed screen, not a blank page: it says what |
| -# this screen is for rather than only that there is nothing on it. |
| +# this screen is for rather than only that there is nothing on it. In |
| +# practice a new person never sees it — their first sign-in seeds a |
| +# sample deck — so it is what somebody meets after deleting the last |
| +# deck they had, which is exactly when a screen should say what it is |
| +# for. |
| slides.decks.title = "Decks" |
| slides.decks.empty.title = "No decks yet" |
| -slides.decks.empty.body = "Decks you build will show up here, newest first." |
| +slides.decks.empty.body = "A deck is a set of slides you write, present and share. Start one and it will show up here." |
| +slides.decks.new = "New deck" |
| +slides.decks.new.body = "Give it a name and a look. Both are easy to change later." |
| +slides.decks.new.submit = "Create deck" |
| + |
| +# A list row's meta line: how many slides, and when it last changed. |
| +# Two keys for the count because English has two forms and the catalog |
| +# has no plural machinery — the handler picks (decks.go's rowSub). |
| +slides.decks.row.slides.one = "1 slide" |
| +slides.decks.row.slides = "{n} slides" |
| +slides.decks.row.sub = "{slides} · edited {when}" |
| +# The same line for a deck whose document would not open: the time is |
| +# still true, and a slide count would be a guess. |
| +slides.decks.row.sub.plain = "edited {when}" |
| + |
| +# slides.when.* — day-grained relative time. A deck list is scanned for |
| +# which deck, never for what minute, so nothing here is finer than a |
| +# day, and past a week the date itself is easier to read than a count. |
| +slides.when.today = "today" |
| +slides.when.yesterday = "yesterday" |
| +slides.when.days = "{n} days ago" |
| +slides.when.on = "on {date}" |
| + |
| +# slides.deck.* — one deck: its own page, its settings, its deletion. |
| +slides.deck.settings = "Deck settings" |
| +slides.deck.settings.submit = "Save settings" |
| +slides.deck.back = "Back to the deck" |
| +slides.deck.slide = "Slide {n}" |
| +slides.deck.notes.label = "Notes:" |
| +slides.deck.title.label = "Title" |
| +slides.deck.title.required = "Give the deck a title." |
| +slides.deck.theme.label = "Theme" |
| +slides.deck.theme.invalid = "Pick one of the themes listed." |
| +slides.deck.rung.legend = "Who can open this deck" |
| +slides.deck.rung.invalid = "Pick who can open this deck." |
| + |
| +# Deleting: its own page, its own question. The title names the deck, |
| +# because "Delete this deck?" on a page you reached by accident is a |
| +# question you cannot answer. |
| +slides.deck.delete = "Delete this deck" |
| +slides.deck.delete.lead = "Deleting a deck removes it and everything written in it. Nobody else can open it afterwards, including people it was shared with." |
| +slides.deck.delete.title = "Delete “{title}”?" |
| +slides.deck.delete.body = "The slides, the speaker notes and the whole edit history go with it. This cannot be undone." |
| +slides.deck.delete.confirm = "Delete the deck" |
| + |
| +# slides.rung.* — spec §5's sharing rungs, as a person reads them. Each |
| +# is a short title plus the line that says who it leaves out; "Private" |
| +# on its own does not. Only private and team are offered by a screen |
| +# today, but a deck already on the granted rung still has to be |
| +# labelled, so all three are here. |
| +slides.rung.private = "Only me" |
| +slides.rung.private.desc = "Nobody else on this instance can open it." |
| +slides.rung.granted = "People I've named" |
| +slides.rung.granted.desc = "Only the people who have been given access." |
| +slides.rung.team = "Everyone here" |
| +slides.rung.team.desc = "Anyone signed in to this instance can open and edit it." |
| + |
| +# slides.theme.* — the six themes, by name. The ids themselves |
| +# (paper, ink, …) are identifiers and never rendered. |
| +slides.theme.paper = "Paper" |
| +slides.theme.ink = "Ink" |
| +slides.theme.sunset = "Sunset" |
| +slides.theme.forest = "Forest" |
| +slides.theme.slate = "Slate" |
| +slides.theme.candy = "Candy" |
| + |
| +# slides.cancel — leaving a form. One key, because it means the same |
| +# thing on every screen that has one. |
| +slides.cancel = "Cancel" |
| # slides.account.* — the shell's account menu. |
| slides.account.signout = "Sign out" |
diff --git a/internal/slides/migrations/0002_create_decks.sql b/internal/slides/migrations/0002_create_decks.sql| new file mode 100644 |
| index 0000000..3e560b8 |
| --- /dev/null |
| +++ b/internal/slides/migrations/0002_create_decks.sql |
| @@ -0,0 +1,16 @@ |
| +CREATE TABLE `decks` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`title` text,`owner_id` integer,`rung` text,`theme` text,`seq` integer,`created_at` datetime,`updated_at` datetime); |
| + |
| +CREATE INDEX `idx_decks_owner_id` ON `decks`(`owner_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_decks_sqid` ON `decks`(`sqid`); |
| + |
| +CREATE TABLE `deck_updates` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`actor` text,`bytes` blob,`created_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_deck_updates_deck_seq` ON `deck_updates`(`deck_id`,`seq`); |
| + |
| +CREATE TABLE `deck_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`user_id` integer,`actor` text,`created_at` datetime); |
| + |
| +CREATE INDEX `idx_deck_grants_user_id` ON `deck_grants`(`user_id`); |
| + |
| +CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`); |
| + |
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql| index 2d6bcee..3796f04 100644 |
| --- a/internal/slides/migrations/schema.sql |
| +++ b/internal/slides/migrations/schema.sql |
| @@ -1,7 +1,23 @@ |
| -- Generated by rastrillo migration generate; DO NOT EDIT. |
| -- The schema every migration in this directory adds up to. |
| +CREATE TABLE `deck_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`user_id` integer,`actor` text,`created_at` datetime); |
| + |
| +CREATE TABLE `deck_updates` (`id` integer PRIMARY KEY AUTOINCREMENT,`deck_id` integer,`seq` integer,`actor` text,`bytes` blob,`created_at` datetime); |
| + |
| +CREATE TABLE `decks` (`id` integer PRIMARY KEY AUTOINCREMENT,`sqid` text,`title` text,`owner_id` integer,`rung` text,`theme` text,`seq` integer,`created_at` datetime,`updated_at` datetime); |
| + |
| CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`name` text,`colour` text,`created_at` datetime); |
| +CREATE INDEX `idx_deck_grants_deck_id` ON `deck_grants`(`deck_id`); |
| + |
| +CREATE INDEX `idx_deck_grants_user_id` ON `deck_grants`(`user_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_deck_updates_deck_seq` ON `deck_updates`(`deck_id`,`seq`); |
| + |
| +CREATE INDEX `idx_decks_owner_id` ON `decks`(`owner_id`); |
| + |
| +CREATE UNIQUE INDEX `idx_decks_sqid` ON `decks`(`sqid`); |
| + |
| CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`); |
diff --git a/internal/slides/models.go b/internal/slides/models.go| index 7315c62..2f143fe 100644 |
| --- a/internal/slides/models.go |
| +++ b/internal/slides/models.go |
| @@ -9,6 +9,9 @@ import "time" |
| // migrations disagree. |
| var Models = []any{ |
| &User{}, |
| + &Deck{}, |
| + &DeckUpdate{}, |
| + &DeckGrant{}, |
| } |
| // User is a person who has signed in here — spec §6.1's |
| @@ -42,3 +45,84 @@ type User struct { |
| Colour string |
| CreatedAt time.Time |
| } |
| + |
| +// Deck is one presentation: the row that owns a Y.Doc, not the document |
| +// itself. The content lives in DeckUpdate's log (spec §2.1); what is on |
| +// this row is everything a list, a link or a permission check needs |
| +// without opening the document at all. |
| +// |
| +// Sqid is the deck's public name — ten random characters, unique, |
| +// minted at creation and never derived from ID (decks.go's newSqid). It |
| +// is also the Y.Doc's guid, so a document and its URL cannot drift |
| +// apart. |
| +// |
| +// Title and Theme are duplicated: they are meta.title and meta.theme |
| +// inside the document too, and both move together on every write. The |
| +// copy exists so the deck list can render fifty rows without |
| +// instantiating fifty wasm modules. |
| +// |
| +// Seq is the log's high-water mark and the optimistic lock every write |
| +// takes: an insert at Seq+1 lands only if the row still reads Seq (see |
| +// decks.go's applyOps). It is not a version anybody sees. |
| +type Deck struct { |
| + ID int64 |
| + Sqid string `gorm:"uniqueIndex"` |
| + // Title is the deck's name, and the only free text on this row. |
| + Title string |
| + // OwnerID is the creator — spec §5's private rung, and the one |
| + // person who may change settings or delete. |
| + OwnerID int64 `gorm:"index"` |
| + // Rung is who may open this deck: "private", "granted" or "team" |
| + // (suite ruling 15, spec §5). Every deck starts private. This |
| + // slice's UI offers private and team only; granted needs a screen |
| + // for naming people, which has not been built — but DeckGrant and |
| + // decks.go's visibleDecks already honour it. |
| + Rung string |
| + // Theme is the theme id (spec §2.1's meta.theme). |
| + Theme string |
| + Seq int64 |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +// DeckUpdate is one entry in a deck's update log: the Yjs update bytes |
| +// one write produced. Replaying every row of a deck in Seq order |
| +// reproduces the document exactly (decks.go's openDeck) — there are no |
| +// snapshots yet. |
| +// |
| +// Actor is spec §6.2's actor string, "person:<user id>" or |
| +// "agent:<agent id>". It lives on this row rather than in the CRDT |
| +// deliberately: the document is what every peer converges on, and who |
| +// wrote which byte of it is this instance's bookkeeping, not shared |
| +// content. |
| +type DeckUpdate struct { |
| + ID int64 |
| + // DeckID and Seq carry one composite unique index rather than an |
| + // index each. That index is what makes "the update after seq n" a |
| + // single insert that either lands or violates, with no |
| + // read-then-write window; and being leftmost in it, DeckID alone is |
| + // still indexed, so a second index on DeckID would only cost |
| + // writes. |
| + DeckID int64 `gorm:"uniqueIndex:idx_deck_updates_deck_seq"` |
| + Seq int64 `gorm:"uniqueIndex:idx_deck_updates_deck_seq"` |
| + Actor string |
| + Bytes []byte |
| + CreatedAt time.Time |
| +} |
| + |
| +// DeckGrant names one person who may open one deck — spec §5's |
| +// `granted` rung. |
| +// |
| +// No screen creates one yet. The model and visibleDecks' join exist |
| +// from the start anyway, because the alternative is a visibility seam |
| +// that quietly means "owner-or-team" long after grants land: the day |
| +// the screen arrives, nothing about who can read what has to be |
| +// rewritten. |
| +type DeckGrant struct { |
| + ID int64 |
| + DeckID int64 `gorm:"index"` |
| + UserID int64 `gorm:"index"` |
| + // Actor is who granted it, in DeckUpdate.Actor's form. |
| + Actor string |
| + CreatedAt time.Time |
| +} |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index b5c5d92..3c0974e 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -47,28 +47,52 @@ var assets = rastrillo.NewAssets(appFS) |
| // template named "content" without colliding with the others. |
| var pages = map[string]*template.Template{} |
| +// publicPages are the screens a signed-out request can reach. They get |
| +// no account menu, because there is no account to show. |
| +var publicPages = []string{"signin", "errors"} |
| + |
| +// signedInPages are the screens behind the session gate. They are built |
| +// with templates/account.html parsed after the layout, so the shell's |
| +// account menu is defined once rather than in each page (see that |
| +// file's own comment). |
| +var signedInPages = []string{"decks", "deck", "deck_new", "deck_settings", "deck_delete"} |
| + |
| func init() { |
| - for _, name := range []string{"index", "signin", "errors"} { |
| - pages[name] = template.Must( |
| - // ui's partials and their helpers first, so every page can |
| - // call one without registering anything. WithIcons points |
| - // ui's two icon seams at the app's own set |
| - // (internal/slides/icons, scaffolded by rastrillo new) — |
| - // both of them, always: passing only icon would leave |
| - // iconAssets on the framework default and silently break |
| - // cdn and js delivery. iconAssets is whatever <head> markup |
| - // the chosen delivery needs, empty for the vendored-inline |
| - // default, so the layout calls it unconditionally and |
| - // switching delivery later needs no template edit. |
| - template.Must(template.New("layout"). |
| - Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path}). |
| - ParseFS(ui.Templates(), "*.html")). |
| - // Then the app's own: the shell and this page's body. |
| - ParseFS(appFS, "templates/layout.html", "templates/"+name+".html")) |
| + for _, name := range publicPages { |
| + pages[name] = buildPage(name) |
| + } |
| + for _, name := range signedInPages { |
| + pages[name] = buildPage(name, "templates/account.html") |
| } |
| } |
| +// buildPage parses one page: ui's partials, then the layout, then any |
| +// shared app partials, then the page's own file. Order is the whole |
| +// mechanism — a later definition of a name replaces an earlier one, so |
| +// account.html's "account" wins over the layout's empty default. |
| +func buildPage(name string, shared ...string) *template.Template { |
| + files := append([]string{"templates/layout.html"}, shared...) |
| + files = append(files, "templates/"+name+".html") |
| + return template.Must( |
| + // ui's partials and their helpers first, so every page can |
| + // call one without registering anything. WithIcons points |
| + // ui's two icon seams at the app's own set |
| + // (internal/slides/icons, scaffolded by rastrillo new) — |
| + // both of them, always: passing only icon would leave |
| + // iconAssets on the framework default and silently break |
| + // cdn and js delivery. iconAssets is whatever <head> markup |
| + // the chosen delivery needs, empty for the vendored-inline |
| + // default, so the layout calls it unconditionally and |
| + // switching delivery later needs no template edit. |
| + template.Must(template.New("layout"). |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path}). |
| + ParseFS(ui.Templates(), "*.html")). |
| + // Then the app's own: the shell, any shared partial, and |
| + // this page's body. |
| + ParseFS(appFS, files...)) |
| +} |
| + |
| // render executes name's layout into a buffer before anything touches |
| // the wire: a template error becomes a clean 500 instead of garbage |
| // appended to a half-written page, and headers (flash cookies, say) |
diff --git a/internal/slides/sample.go b/internal/slides/sample.go| new file mode 100644 |
| index 0000000..4607898 |
| --- /dev/null |
| +++ b/internal/slides/sample.go |
| @@ -0,0 +1,302 @@ |
| +// sample.go builds the deck a person meets on their first sign-in. |
| +// |
| +// It is documentation that runs. Every flow template, both layout |
| +// modes, the phone layer, and the block types worth knowing about are |
| +// exercised once each, and each slide's speaker notes say what it is |
| +// showing — so the first answer to "what can this thing do" is a deck |
| +// rather than a page of prose nobody reads. |
| +// |
| +// The ops are Go values, not a JSON blob in a string constant, for the |
| +// same reason: this file is meant to be read. |
| +package slides |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// sampleDeckTitle is the deck's name. Not a catalog key: it is the |
| +// title of a document, which is content, and translating it would mean |
| +// translating the eight slides inside it too. When a locale wants its |
| +// own sample deck, this whole builder is what gets a second version. |
| +const sampleDeckTitle = "Welcome to Slides" |
| + |
| +// sampleTheme is the theme the sample wears — the default, so the deck |
| +// shows what an untouched deck looks like. |
| +const sampleTheme = defaultTheme |
| + |
| +// seedSampleDeck builds sampleDeckTitle for u. Called once, from |
| +// ensureUser's caller, the moment a person's users row is created. |
| +// |
| +// One row, then one op batch: the whole deck is a single DeckUpdate, so |
| +// the log a later request replays is one entry rather than forty. |
| +func (a *app) seedSampleDeck(ctx context.Context, u User) error { |
| + d, err := a.createDeckRow(ctx, u, sampleDeckTitle, sampleTheme) |
| + if err != nil { |
| + return err |
| + } |
| + ops, err := sampleOps() |
| + if err != nil { |
| + return err |
| + } |
| + encoded, err := opsJSON(ops) |
| + if err != nil { |
| + return err |
| + } |
| + return a.editDeck(ctx, &d, actorFor(u), encoded) |
| +} |
| + |
| +// slideIDs mints the ids one slide's ops need: the slide's own and n |
| +// blocks'. Every id is minted here because the core draws no entropy of |
| +// its own (core/src/ops.rs). |
| +func slideIDs(n int) (string, []string, error) { |
| + slide, err := newID() |
| + if err != nil { |
| + return "", nil, err |
| + } |
| + blocks := make([]string, n) |
| + for i := range blocks { |
| + if blocks[i], err = newID(); err != nil { |
| + return "", nil, err |
| + } |
| + } |
| + return slide, blocks, nil |
| +} |
| + |
| +// sampleOps is the whole deck as one batch. |
| +// |
| +// Eight slides. Six are flow slides, one per template that has anything |
| +// to show — title, title-body, two-column, section, image-full, |
| +// quote — and the last two are canvas slides, which carry the seventh |
| +// template (`blank`) because a canvas slide ignores its template |
| +// anyway: that is the honest place for `blank` to appear, rather than a |
| +// seventh flow slide with nothing on it. |
| +func sampleOps() ([]op, error) { |
| + ops := []op{{"op": "set_meta", "title": sampleDeckTitle, "theme": sampleTheme}} |
| + for _, build := range []func() ([]op, error){ |
| + sampleTitleSlide, |
| + sampleTitleBodySlide, |
| + sampleTwoColumnSlide, |
| + sampleSectionSlide, |
| + sampleImageFullSlide, |
| + sampleQuoteSlide, |
| + sampleCanvasSlide, |
| + samplePhoneReflowSlide, |
| + } { |
| + part, err := build() |
| + if err != nil { |
| + return nil, err |
| + } |
| + ops = append(ops, part...) |
| + } |
| + return ops, nil |
| +} |
| + |
| +// addSlide is the op that starts a flow slide. |
| +func addSlide(id, template string) op { |
| + return op{"op": "add_slide", "id": id, "layout": "flow", "template": template} |
| +} |
| + |
| +// addCanvas is the op that starts a canvas slide. Its template is |
| +// `blank` and it is ignored — see sampleOps. |
| +func addCanvas(id string) op { |
| + return op{"op": "add_slide", "id": id, "layout": "canvas", "template": "blank"} |
| +} |
| + |
| +// addBlock places a block in a flow slide's region. |
| +func addBlock(slide, id, kind, region, markdown string, props map[string]any) op { |
| + o := op{"op": "add_block", "slide": slide, "id": id, "type": kind, "region": region} |
| + if props != nil { |
| + o["props"] = props |
| + } |
| + if markdown != "" { |
| + o["markdown"] = markdown |
| + } |
| + return o |
| +} |
| + |
| +// addBoxed places a block on a canvas slide: a box in fractions of the |
| +// 16:9 frame, and a z. Region is meaningless here and is not sent. |
| +func addBoxed(slide, id, kind string, x, y, w, h float64, z int, markdown string, props map[string]any) op { |
| + o := op{ |
| + "op": "add_block", "slide": slide, "id": id, "type": kind, |
| + "box": map[string]any{"x": x, "y": y, "w": w, "h": h}, |
| + "z": z, |
| + } |
| + if props != nil { |
| + o["props"] = props |
| + } |
| + if markdown != "" { |
| + o["markdown"] = markdown |
| + } |
| + return o |
| +} |
| + |
| +// notes attaches speaker notes. Every slide in this deck has them: |
| +// they are what makes the sample teach rather than merely exist. |
| +func notes(slide, markdown string) op { |
| + return op{"op": "set_notes", "id": slide, "markdown": markdown} |
| +} |
| + |
| +// 1. title — the opening slide: a heading and one line under it. |
| +func sampleTitleSlide() ([]op, error) { |
| + s, b, err := slideIDs(2) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "title"), |
| + addBlock(s, b[0], "heading", "heading", sampleDeckTitle, map[string]any{"level": 1}), |
| + addBlock(s, b[1], "text", "body", "Eight slides that show what a deck is made of. Open the notes on each one.", map[string]any{"size": "l"}), |
| + notes(s, "The **title** template: one heading and one supporting line, centred. It is what a new deck opens on."), |
| + }, nil |
| +} |
| + |
| +// 2. title-body — the workhorse: a heading, prose with every inline |
| +// mark, and a list. |
| +func sampleTitleBodySlide() ([]op, error) { |
| + s, b, err := slideIDs(3) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "title-body"), |
| + addBlock(s, b[0], "heading", "heading", "Text and marks", map[string]any{"level": 1}), |
| + addBlock(s, b[1], "text", "body", |
| + "Text carries **bold**, *italic*, `code`, ==highlight== and [links](https://amadan.net). Nothing else — a heading is a block, not a mark.", |
| + map[string]any{"size": "m"}), |
| + addBlock(s, b[2], "list", "body", "- One item per paragraph\n- Bullets or numbers\n- Marks work here too, like **this**", |
| + map[string]any{"style": "bullet", "size": "m"}), |
| + notes(s, "The **title-body** template. Text arrives as a small Markdown subset: bold, italic, code, highlight and links. A list block is one paragraph per item."), |
| + }, nil |
| +} |
| + |
| +// 3. two-column — the heading, body and aside regions together. |
| +func sampleTwoColumnSlide() ([]op, error) { |
| + s, b, err := slideIDs(3) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "two-column"), |
| + addBlock(s, b[0], "heading", "heading", "Regions, not coordinates", map[string]any{"level": 1}), |
| + addBlock(s, b[1], "text", "body", "A flow slide names regions — heading, body, aside, media — and blocks stack inside the one they are in.", nil), |
| + addBlock(s, b[2], "text", "aside", "The aside is the second column. On a phone the grid collapses to one column, so reflow is CSS rather than a second layout.", nil), |
| + notes(s, "The **two-column** template puts `body` and `aside` side by side. Nothing here is positioned by hand; the template decides where a region sits."), |
| + }, nil |
| +} |
| + |
| +// 4. section — the divider slide, one heading and nothing else. |
| +func sampleSectionSlide() ([]op, error) { |
| + s, b, err := slideIDs(1) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "section"), |
| + addBlock(s, b[0], "heading", "heading", "Part two", map[string]any{"level": 1}), |
| + notes(s, "The **section** template: a chapter break. One heading, large, and deliberately nothing else on the slide."), |
| + }, nil |
| +} |
| + |
| +// 5. image-full — the media region, standing in for a picture. |
| +// |
| +// There is no image here, because there is no way to upload one yet: |
| +// assets arrive browser-to-bucket in a later slice (spec §2.7). A |
| +// `shape` rectangle fills the media region instead, and the slide's own |
| +// notes say so rather than leaving a reader to wonder why the sample |
| +// deck's picture slide has no picture. |
| +func sampleImageFullSlide() ([]op, error) { |
| + s, b, err := slideIDs(2) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "image-full"), |
| + addBlock(s, b[0], "heading", "heading", "A picture would go here", map[string]any{"level": 2}), |
| + addBlock(s, b[1], "shape", "media", "Image placeholder", |
| + map[string]any{"kind": "rect", "fill": "#d8d4cc", "stroke": "#9a948a", "strokeWidth": 2, "radius": 8}), |
| + notes(s, "The **image-full** template gives the `media` region the whole frame. This deck has no image in it: uploads land in a later slice, so a `shape` rectangle is standing in for one."), |
| + }, nil |
| +} |
| + |
| +// 6. quote — a pull quote with its attribution. |
| +func sampleQuoteSlide() ([]op, error) { |
| + s, b, err := slideIDs(1) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addSlide(s, "quote"), |
| + addBlock(s, b[0], "quote", "body", "A deck is a document, not a drawing. Everything here is text you can search, export and read aloud.", |
| + map[string]any{"cite": "The design spec"}), |
| + notes(s, "The **quote** template. A `quote` block carries its attribution in `cite` rather than as a second block of text."), |
| + }, nil |
| +} |
| + |
| +// 7. canvas — free placement: a heading, two shapes and a text block, |
| +// each with its own box and z. |
| +func sampleCanvasSlide() ([]op, error) { |
| + s, b, err := slideIDs(4) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addCanvas(s), |
| + addBoxed(s, b[0], "heading", 0.06, 0.08, 0.88, 0.16, 3, "Canvas: put it where you want it", map[string]any{"level": 1}), |
| + addBoxed(s, b[1], "shape", 0.08, 0.34, 0.36, 0.44, 1, "", |
| + map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": 2, "radius": 12}), |
| + addBoxed(s, b[2], "shape", 0.26, 0.46, 0.30, 0.30, 2, "", |
| + map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": 2}), |
| + addBoxed(s, b[3], "text", 0.58, 0.36, 0.36, 0.40, 4, |
| + "Every box is a fraction of the 16:9 frame, so a canvas slide scales to any screen without reflowing. `z` decides what sits on top — the ellipse overlaps the rectangle because its z is higher.", nil), |
| + notes(s, "A **canvas** slide. Blocks carry `box` (x, y, w, h as fractions of the frame) and `z` instead of a region. The template is ignored here."), |
| + }, nil |
| +} |
| + |
| +// 8. canvas with phone: reflow — the same freedom, plus the phone |
| +// layer. The slide reflows to a single column on a phone, and one block |
| +// is hidden there outright. |
| +func samplePhoneReflowSlide() ([]op, error) { |
| + s, b, err := slideIDs(3) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return []op{ |
| + addCanvas(s), |
| + op{"op": "set_slide", "id": s, "phone": "reflow", "transition": "fade"}, |
| + addBoxed(s, b[0], "heading", 0.06, 0.10, 0.88, 0.16, 1, "On a phone this one reflows", map[string]any{"level": 1}), |
| + addBoxed(s, b[1], "text", 0.06, 0.32, 0.52, 0.44, 2, |
| + "A canvas slide normally scales to fit a phone, text and all. Setting the slide's `phone` to `reflow` makes the renderer lay its blocks out as a single column instead.", nil), |
| + addBoxed(s, b[2], "shape", 0.64, 0.32, 0.30, 0.44, 3, "Desktop only", |
| + map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": 2, "radius": 12}), |
| + op{"op": "set_phone", "slide": s, "id": b[2], "phone": map[string]any{"hidden": true}}, |
| + notes(s, "The same canvas slide with `phone: reflow`, and a phone override that hides the shape on a small screen. The phone layer only ever adds to the desktop layout; it never replaces it."), |
| + }, nil |
| +} |
| + |
| +// seedSampleDeckFor is what currentUser calls on a first sign-in. A |
| +// failure is logged and swallowed on purpose: a sample deck is a |
| +// welcome, and refusing to sign somebody in because their welcome could |
| +// not be built would be the wrong trade by a distance. |
| +func (a *app) seedSampleDeckFor(ctx context.Context, u User) { |
| + if err := a.seedSampleDeck(ctx, u); err != nil { |
| + a.logger.Error("slides: seed the sample deck", "user", u.ID, "err", err) |
| + } |
| +} |
| + |
| +// hasDeck reports whether u owns any deck at all — the belt to |
| +// ensureUser's braces. ensureUser only reports "created" for the |
| +// request that actually inserted the row, so this should always be |
| +// false when seeding runs; it costs one indexed count and it is what |
| +// stops a race, a restore or a future second caller from handing |
| +// somebody two welcome decks. |
| +func hasDeck(ctx context.Context, g *gorm.DB, u User) (bool, error) { |
| + var n int64 |
| + if err := g.WithContext(ctx).Model(&Deck{}).Where("owner_id = ?", u.ID).Count(&n).Error; err != nil { |
| + return false, fmt.Errorf("slides: count decks for user %d: %w", u.ID, err) |
| + } |
| + return n > 0, nil |
| +} |
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html| new file mode 100644 |
| index 0000000..d9a06db |
| --- /dev/null |
| +++ b/internal/slides/templates/account.html |
| @@ -0,0 +1,42 @@ |
| +{{/* account — the shell's account menu body, for every signed-in |
| + screen. |
| + |
| + A file of its own rather than a block each page redefines: the |
| + layout owns the details/summary, an override supplies only what |
| + sits inside [rst-dropdown-menu], and five copies of these eight |
| + lines would drift within a week. render.go parses this after |
| + layout.html for every signed-in page, so this definition replaces |
| + the layout's empty default; the sign-in page does not get it, |
| + because nobody is signed in there to have an account. |
| + |
| + Every page's view data embeds `shell` (decks.go), which is where |
| + .Person and .SignoutLabel come from. */}} |
| +{{/* This file owns "head" for every signed-in page, because that is |
| + where the one rule below has to live. A page that defines its own |
| + "head" replaces this one and loses the rule — so no signed-in page |
| + does; they style with the rst-* vocabulary instead. */}} |
| +{{define "head"}}<style> |
| + /* The address the account menu is signed in as: a label above the |
| + one control, not a control itself. Muted and small so it reads as |
| + context rather than as a second thing to click, and padded to the |
| + same inset as the button below it so the two line up. */ |
| + [slides-account-who] { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + margin: 0; |
| + overflow-wrap: anywhere; |
| + padding: 0.4rem 0.65rem; |
| + } |
| +</style>{{end}} |
| +{{define "account"}} |
| +<p slides-account-who>{{.Person}}</p> |
| +<hr> |
| +{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect |
| + guards state-changing methods, and a GET that ends a session is one |
| + a link prefetch can fire. A form also works with scripts off, which |
| + a fetch() would not — tokens.css already styles a bare button |
| + inside [rst-dropdown-menu] as a menu item. */}} |
| +<form method="post" action="/signout"> |
| + <button type="submit">{{.SignoutLabel}}</button> |
| +</form> |
| +{{end}} |
diff --git a/internal/slides/templates/deck.html b/internal/slides/templates/deck.html| new file mode 100644 |
| index 0000000..14e3cf5 |
| --- /dev/null |
| +++ b/internal/slides/templates/deck.html |
| @@ -0,0 +1,41 @@ |
| +{{/* deck — GET /d/{sqid}, the plainest possible view of a document. |
| + |
| + It exists to prove the deck is real: the title, who can open it, |
| + and one <section> per slide naming each block's type and its words. |
| + The renderer and the editor land in task 5 and replace this whole |
| + screen, so nothing here is invested in — no stage, no theme, no |
| + layout engine. |
| + |
| + Block, layout and template ids render verbatim. They are |
| + identifiers, like a sqid, not copy: translating "heading" would |
| + make this page harder to read against the model, not easier. |
| + |
| + Each slide is a box-head SIBLING followed by its box, which is how |
| + tokens.css means a section card to be built — the heading is not |
| + inside the card. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| +{{template "page-header" dict |
| + "Title" .Title |
| + "ActionHref" .SettingsHref "ActionLabel" .SettingsLabel}} |
| +<div rst-box> |
| + {{template "status-pill" dict "Tone" .RungTone "Label" .RungLabel}} |
| +</div> |
| +{{range .Slides}} |
| +<div rst-box-head> |
| + <h2>{{.Heading}}</h2> |
| + <p><code class="rst-mono">{{.Layout}}</code> <code class="rst-mono">{{.Template}}</code></p> |
| +</div> |
| +<section rst-box> |
| + <ul> |
| + {{range .Blocks}} |
| + <li><code class="rst-mono">{{.Type}}</code> {{.Text}}</li> |
| + {{end}} |
| + </ul> |
| + {{with .Notes}} |
| + <p><strong>{{$.NotesLabel}}</strong> {{.}}</p> |
| + {{end}} |
| +</section> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/slides/templates/deck_delete.html b/internal/slides/templates/deck_delete.html| new file mode 100644 |
| index 0000000..cdf1e98 |
| --- /dev/null |
| +++ b/internal/slides/templates/deck_delete.html |
| @@ -0,0 +1,19 @@ |
| +{{/* deck_delete — GET /d/{sqid}/delete, the destructive action's own |
| + page. |
| + |
| + The shape confirm-form is written for: back-nav, the question as |
| + the page title, the explanation, then the form. The GET mutates |
| + nothing, Cancel is a real link and comes first in the DOM, and the |
| + whole thing works with scripts off — which is the point of a page |
| + rather than a modal. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<div rst-box> |
| + <p>{{.Body}}</p> |
| + {{template "confirm-form" dict |
| + "Action" .Action "Label" .Label "Danger" true |
| + "CancelHref" .CancelHref "CancelLabel" .CancelLabel}} |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/deck_new.html b/internal/slides/templates/deck_new.html| new file mode 100644 |
| index 0000000..3cbd34d |
| --- /dev/null |
| +++ b/internal/slides/templates/deck_new.html |
| @@ -0,0 +1,24 @@ |
| +{{/* deck_new — GET /decks/new, the create form and nothing else. |
| + |
| + One form, one primary button, a cancel that is a real link. Both |
| + controls come from ui: a hand-rolled <label><input> validates fine |
| + and renders as ragged inline labels over an unstyled button, which |
| + is exactly the failure nothing tells you about. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict "Title" .Title "Sub" .Body}} |
| +<div rst-box> |
| + <form rst-form method="post" action="/decks"> |
| + {{template "field-text" dict |
| + "Name" "title" "Label" .TitleLabel "Value" .TitleValue |
| + "Required" true "Primary" true "Autofocus" true |
| + "Error" (index .Errors "Title")}} |
| + {{template "field-select" dict |
| + "ID" "theme" "Name" "theme" "Label" .ThemeLabel |
| + "Options" .Themes "Required" true |
| + "Error" (index .Errors "Theme")}} |
| + {{template "form-foot" dict |
| + "Submit" .Submit "CancelHref" .CancelHref "CancelLabel" .CancelLabel}} |
| + </form> |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/deck_settings.html b/internal/slides/templates/deck_settings.html| new file mode 100644 |
| index 0000000..6262db1 |
| --- /dev/null |
| +++ b/internal/slides/templates/deck_settings.html |
| @@ -0,0 +1,38 @@ |
| +{{/* deck_settings — GET/POST /d/{sqid}/settings. |
| + |
| + The deck's own settings and nothing else: name, theme, and who can |
| + open it. The rung is a choice-field rather than a select, because |
| + the three answers each need a sentence — "Private" on its own does |
| + not say who that leaves out — and because a radio group signals |
| + pick-one where a dropdown hides the alternatives. |
| + |
| + Deleting is not a control on this form. It is a link to its own |
| + page, which asks the question and carries the only button that |
| + answers it. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "back-nav" dict "Href" .BackHref "Label" .BackLabel}} |
| +{{template "page-header" dict "Title" .Title}} |
| +<div rst-box> |
| + <form rst-form method="post" action="{{.Action}}"> |
| + {{template "field-text" dict |
| + "Name" "title" "Label" .TitleLabel "Value" .TitleValue |
| + "Required" true "Primary" true |
| + "Error" (index .Errors "Title")}} |
| + {{template "field-select" dict |
| + "ID" "theme" "Name" "theme" "Label" .ThemeLabel |
| + "Options" .Themes "Required" true |
| + "Error" (index .Errors "Theme")}} |
| + {{template "choice-field" dict |
| + "Legend" .RungLegend "Name" "rung" "Options" .Rungs}} |
| + {{template "form-error" (index .Errors "Rung")}} |
| + {{template "form-foot" dict |
| + "Submit" .Submit "CancelHref" .CancelHref "CancelLabel" .CancelLabel}} |
| + </form> |
| +</div> |
| +<div rst-box-head><h2>{{.DeleteLabel}}</h2></div> |
| +<div rst-box> |
| + <p>{{.DeleteBody}}</p> |
| + <p><a rst-btn href="{{.DeleteHref}}">{{.DeleteLabel}}</a></p> |
| +</div> |
| +{{end}} |
diff --git a/internal/slides/templates/decks.html b/internal/slides/templates/decks.html| new file mode 100644 |
| index 0000000..8a61f66 |
| --- /dev/null |
| +++ b/internal/slides/templates/decks.html |
| @@ -0,0 +1,32 @@ |
| +{{/* decks — GET /, the deck list. |
| + |
| + One job: show the decks, and carry the single link that starts a |
| + new one. The create form is a page of its own (SKILL.md §7) — an |
| + empty list is step one of that flow, not an exception to it, so the |
| + empty state carries the same call to action the header does rather |
| + than a form inlined here. |
| + |
| + The rows live in [rst-list], which holds rows and nothing else. The |
| + rung rides as a status-pill inside each row: always visible text, |
| + never colour alone. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +{{template "page-header" dict |
| + "Title" .Title |
| + "ActionHref" .NewHref "ActionLabel" .NewLabel "ActionIcon" "plus"}} |
| +{{if .Decks}} |
| +<div rst-list> |
| + {{range .Decks}} |
| + {{template "list-row-action" dict |
| + "Href" .Href "Main" .Title "Sub" .Sub |
| + "StatusLabel" .RungLabel "StatusTone" .RungTone}} |
| + {{end}} |
| +</div> |
| +{{else}} |
| +<div rst-box> |
| + {{template "empty-state" dict |
| + "Title" .EmptyTitle "Body" .EmptyBody |
| + "ActionHref" .NewHref "ActionLabel" .NewLabel}} |
| +</div> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/slides/templates/index.html b/internal/slides/templates/index.html| deleted file mode 100644 |
| index 281e47c..0000000 |
| --- a/internal/slides/templates/index.html |
| +++ /dev/null |
| @@ -1,52 +0,0 @@ |
| -{{/* index — GET /, the deck list, behind the session gate. |
| - |
| - There are no decks to list yet; the deck model and its screens are |
| - the next task. What is here is not a placeholder for a list, it is |
| - the screen a new person meets on their very first visit either way: |
| - empty-state says what this screen is for rather than only that |
| - nothing is on it. Its call to action arrives with the create route |
| - that can answer it — an empty state whose button 404s is worse than |
| - one with no button. |
| - |
| - The list lives inside [rst-box] so that adding rows later is |
| - filling a container that already exists, rather than restyling the |
| - screen around them. |
| - |
| - Bare rst-* attribute grammar, no JavaScript: this page is a |
| - heading, a box and a sign-out form. */}} |
| -{{define "title"}}{{.Title}}{{end}} |
| -{{define "head"}}<style> |
| - /* The address the account menu is signed in as: a label above the |
| - one control, not a control itself. Muted and small so it reads as |
| - context rather than as a second thing to click, and padded to the |
| - same inset as the button below it so the two line up. */ |
| - [slides-account-who] { |
| - color: var(--rst-text-muted); |
| - font-size: var(--rst-fs-sm); |
| - margin: 0; |
| - overflow-wrap: anywhere; |
| - padding: 0.4rem 0.65rem; |
| - } |
| -</style>{{end}} |
| -{{define "account"}} |
| -<p slides-account-who>{{.Person}}</p> |
| -<hr> |
| -{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect |
| - guards state-changing methods, and a GET that ends a session is one |
| - a link prefetch can fire. A form also works with scripts off, which |
| - a fetch() would not — tokens.css already styles a bare button |
| - inside [rst-dropdown-menu] as a menu item. */}} |
| -<form method="post" action="/signout"> |
| - <button type="submit">{{.SignoutLabel}}</button> |
| -</form> |
| -{{end}} |
| -{{define "content"}} |
| -<header rst-page-header> |
| - <div rst-page-header-titles> |
| - <h1>{{.Title}}</h1> |
| - </div> |
| -</header> |
| -<div rst-box> |
| - {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}} |
| -</div> |
| -{{end}} |
diff --git a/internal/slidestest/decks_test.go b/internal/slidestest/decks_test.go| new file mode 100644 |
| index 0000000..93a9972 |
| --- /dev/null |
| +++ b/internal/slidestest/decks_test.go |
| @@ -0,0 +1,225 @@ |
| +package slidestest |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// otherAddress is a second person on the instance, for the tests whose |
| +// whole subject is what one person can see of another's deck. |
| +const otherAddress = "sam@example.test" |
| + |
| +// sampleDeckTitle is the deck every new person's first sign-in seeds. |
| +const sampleDeckTitle = "Welcome to Slides" |
| + |
| +// deckPath finds /d/{sqid} in a Location header or a rendered page. |
| +var deckPath = regexp.MustCompile(`/d/[23456789abcdefghjkmnpqrstuvwxyz]{10}`) |
| + |
| +// newSharedApp is newApp with both test addresses on the allowlist, so |
| +// two people can sign in to the same instance. |
| +func newSharedApp(t *testing.T) *testApp { |
| + t.Helper() |
| + cfg, mailer := newConfig(t) |
| + cfg.AllowedAddresses = []string{allowedAddress, otherAddress} |
| + return newAppWith(t, cfg, mailer) |
| +} |
| + |
| +// createDeck posts the new-deck form and returns the sqid path the app |
| +// redirected to. |
| +func createDeck(t *testing.T, a *testApp, cookies []*http.Cookie, title string) string { |
| + t.Helper() |
| + rec := post(t, a, "/decks", url.Values{"title": {title}, "theme": {"ink"}}, cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /decks: status %d, want %d\n%s", rec.Code, http.StatusSeeOther, rec.Body) |
| + } |
| + loc := rec.Header().Get("Location") |
| + if !deckPath.MatchString(loc) { |
| + t.Fatalf("POST /decks: Location = %q, want a /d/{sqid}", loc) |
| + } |
| + return loc |
| +} |
| + |
| +// mustGet fetches target and fails unless the status is want. |
| +func mustGet(t *testing.T, a *testApp, target string, want int, cookies []*http.Cookie) *httptest.ResponseRecorder { |
| + t.Helper() |
| + rec := get(t, a, target, cookies...) |
| + if rec.Code != want { |
| + t.Fatalf("GET %s: status %d, want %d", target, rec.Code, want) |
| + } |
| + return rec |
| +} |
| + |
| +// TestNewDeckAppearsInList: the create flow end to end — the form |
| +// screen, the POST, the redirect to the deck, and the row in the list. |
| +func TestNewDeckAppearsInList(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + |
| + mustGet(t, a, "/decks/new", http.StatusOK, cookies) |
| + |
| + path := createDeck(t, a, cookies, "Roadmap") |
| + |
| + deckPage := mustGet(t, a, path, http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(deckPage, "Roadmap") { |
| + t.Errorf("GET %s does not name the deck:\n%s", path, deckPage) |
| + } |
| + |
| + list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(list, "Roadmap") { |
| + t.Errorf("GET / does not list the new deck:\n%s", list) |
| + } |
| + if !strings.Contains(list, path) { |
| + t.Errorf("GET / does not link %s:\n%s", path, list) |
| + } |
| +} |
| + |
| +// TestSampleDeckSeededOnFirstSignin: a person's very first authenticated |
| +// request builds their sample deck, so the app is never a blank screen |
| +// with a button on it. |
| +func TestSampleDeckSeededOnFirstSignin(t *testing.T) { |
| + a := newApp(t) |
| + cookies := signedIn(t, a, allowedAddress) |
| + |
| + list := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() |
| + if !strings.Contains(list, sampleDeckTitle) { |
| + t.Fatalf("GET / after first sign-in does not list %q:\n%s", sampleDeckTitle, list) |
| + } |
| + |
| + path := deckPath.FindString(list) |
| + if path == "" { |
| + t.Fatalf("GET / lists no deck link:\n%s", list) |
| + } |
| + page := mustGet(t, a, path, http.StatusOK, cookies).Body.String() |
| + // Eight slides, every flow template plus two canvas slides, and |
| + // notes on each: the sample is what teaches the model, so an empty |
| + // one would pass every other assertion here. |
| + if n := strings.Count(page, "<section"); n != 8 { |
| + t.Errorf("the sample deck renders %d slides, want 8:\n%s", n, page) |
| + } |
| + |
| + // Seeding happens once, not on every request past the gate. |
| + again := mustGet(t, a, "/", http.StatusOK, cookies).Body.String() |
| + if n := strings.Count(again, sampleDeckTitle); n != 1 { |
| + t.Errorf("%q appears %d times on the list, want once", sampleDeckTitle, n) |
| + } |
| +} |
| + |
| +// TestForeignPrivateDeckIs404: a deck that is not yours does not exist. |
| +// Not 403 — a 403 tells a stranger the sqid is real. |
| +func TestForeignPrivateDeckIs404(t *testing.T) { |
| + a := newSharedApp(t) |
| + mine := signedIn(t, a, allowedAddress) |
| + theirs := signedIn(t, a, otherAddress) |
| + |
| + path := createDeck(t, a, mine, "Board update") |
| + |
| + mustGet(t, a, path, http.StatusNotFound, theirs) |
| + mustGet(t, a, path+"/settings", http.StatusNotFound, theirs) |
| + |
| + list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String() |
| + if strings.Contains(list, "Board update") { |
| + t.Errorf("another person's private deck is on the list:\n%s", list) |
| + } |
| +} |
| + |
| +// TestTeamDeckVisibleToOthers: raising a deck to the team rung opens it |
| +// to everyone signed in to this instance, and only the owner may raise |
| +// it. |
| +func TestTeamDeckVisibleToOthers(t *testing.T) { |
| + a := newSharedApp(t) |
| + mine := signedIn(t, a, allowedAddress) |
| + theirs := signedIn(t, a, otherAddress) |
| + |
| + path := createDeck(t, a, mine, "All hands") |
| + mustGet(t, a, path, http.StatusNotFound, theirs) |
| + |
| + // Someone who cannot see the deck cannot raise its rung either. |
| + if rec := post(t, a, path+"/settings", url.Values{ |
| + "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"}, |
| + }, theirs...); rec.Code != http.StatusNotFound { |
| + t.Fatalf("POST %s/settings as a stranger: status %d, want 404", path, rec.Code) |
| + } |
| + |
| + if rec := post(t, a, path+"/settings", url.Values{ |
| + "title": {"All hands"}, "theme": {"ink"}, "rung": {"team"}, |
| + }, mine...); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body) |
| + } |
| + |
| + mustGet(t, a, path, http.StatusOK, theirs) |
| + list := mustGet(t, a, "/", http.StatusOK, theirs).Body.String() |
| + if !strings.Contains(list, "All hands") { |
| + t.Errorf("a team deck is missing from another person's list:\n%s", list) |
| + } |
| +} |
| + |
| +// TestRenameAndDelete: the settings screen writes the title through to |
| +// both the list and the document, and delete is a confirm page whose |
| +// POST takes the deck away for good. |
| +func TestRenameAndDelete(t *testing.T) { |
| + a := newSharedApp(t) |
| + mine := signedIn(t, a, allowedAddress) |
| + theirs := signedIn(t, a, otherAddress) |
| + |
| + path := createDeck(t, a, mine, "Draft") |
| + |
| + form := mustGet(t, a, path+"/settings", http.StatusOK, mine).Body.String() |
| + if !strings.Contains(form, `value="Draft"`) { |
| + t.Errorf("the settings form does not seed the current title:\n%s", form) |
| + } |
| + |
| + rec := post(t, a, path+"/settings", url.Values{ |
| + "title": {"Final"}, "theme": {"sunset"}, "rung": {"private"}, |
| + }, mine...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/settings: status %d, want 303\n%s", path, rec.Code, rec.Body) |
| + } |
| + |
| + list := mustGet(t, a, "/", http.StatusOK, mine).Body.String() |
| + if !strings.Contains(list, "Final") || strings.Contains(list, "Draft") { |
| + t.Errorf("the rename did not reach the list:\n%s", list) |
| + } |
| + // The document carries the title too, not only the row: the deck |
| + // page renders what the core holds. |
| + page := mustGet(t, a, path, http.StatusOK, mine).Body.String() |
| + if !strings.Contains(page, "Final") { |
| + t.Errorf("the rename did not reach the document:\n%s", page) |
| + } |
| + |
| + // Deleting is its own URL with its own page, and it is the owner's |
| + // alone. |
| + mustGet(t, a, path+"/delete", http.StatusOK, mine) |
| + if rec := post(t, a, path+"/delete", nil, theirs...); rec.Code != http.StatusNotFound { |
| + t.Fatalf("POST %s/delete as a stranger: status %d, want 404", path, rec.Code) |
| + } |
| + |
| + if rec := post(t, a, path+"/delete", nil, mine...); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/delete: status %d, want 303\n%s", path, rec.Code, rec.Body) |
| + } |
| + mustGet(t, a, path, http.StatusNotFound, mine) |
| + gone := mustGet(t, a, "/", http.StatusOK, mine).Body.String() |
| + if strings.Contains(gone, "Final") { |
| + t.Errorf("the deleted deck is still on the list:\n%s", gone) |
| + } |
| + |
| + // Delete what is left — the sample deck — and the list falls back |
| + // to its empty state. Worth reaching: it is the one branch of the |
| + // list screen nothing else in this suite renders, and a person who |
| + // has cleared their decks out is exactly who needs the screen to |
| + // say what it is for. |
| + rest := deckPath.FindString(gone) |
| + if rest == "" { |
| + t.Fatalf("expected the sample deck still on the list:\n%s", gone) |
| + } |
| + if rec := post(t, a, rest+"/delete", nil, mine...); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST %s/delete: status %d, want 303", rest, rec.Code) |
| + } |
| + empty := mustGet(t, a, "/", http.StatusOK, mine).Body.String() |
| + if !strings.Contains(empty, "No decks yet") { |
| + t.Errorf("an emptied list does not render its empty state:\n%s", empty) |
| + } |
| +} |