| 1 | // sample.go builds the deck a person meets on their first sign-in. |
| 2 | // |
| 3 | // It is documentation that runs. Every flow template, both layout |
| 4 | // modes, the phone layer, and the block types worth knowing about are |
| 5 | // exercised once each, and each slide's speaker notes say what it is |
| 6 | // showing — so the first answer to "what can this thing do" is a deck |
| 7 | // rather than a page of prose nobody reads. |
| 8 | // |
| 9 | // The ops are Go values, not a JSON blob in a string constant, for the |
| 10 | // same reason: this file is meant to be read. |
| 11 | package slides |
| 12 | |
| 13 | import ( |
| 14 | "context" |
| 15 | "fmt" |
| 16 | |
| 17 | "gorm.io/gorm" |
| 18 | ) |
| 19 | |
| 20 | // sampleDeckTitle is the deck's name. Not a catalog key: it is the |
| 21 | // title of a document, which is content, and translating it would mean |
| 22 | // translating the eight slides inside it too. When a locale wants its |
| 23 | // own sample deck, this whole builder is what gets a second version. |
| 24 | const sampleDeckTitle = "Welcome to Slides" |
| 25 | |
| 26 | // sampleTheme is the theme the sample wears — the default, so the deck |
| 27 | // shows what an untouched deck looks like. |
| 28 | const sampleTheme = defaultTheme |
| 29 | |
| 30 | // seedSampleDeck builds sampleDeckTitle for u. Called once, from |
| 31 | // ensureUser's caller, the moment a person's users row is created. |
| 32 | // |
| 33 | // One row, then one op batch: the whole deck is a single DeckUpdate, so |
| 34 | // the log a later request replays is one entry rather than forty. |
| 35 | func (a *app) seedSampleDeck(ctx context.Context, u User) error { |
| 36 | d, err := a.createDeckRow(ctx, u, sampleDeckTitle, sampleTheme) |
| 37 | if err != nil { |
| 38 | return err |
| 39 | } |
| 40 | ops, err := sampleOps() |
| 41 | if err != nil { |
| 42 | return err |
| 43 | } |
| 44 | encoded, err := opsJSON(ops) |
| 45 | if err != nil { |
| 46 | return err |
| 47 | } |
| 48 | return a.editDeck(ctx, &d, actorFor(u), encoded, nil) |
| 49 | } |
| 50 | |
| 51 | // slideIDs mints the ids one slide's ops need: the slide's own and n |
| 52 | // blocks'. Every id is minted here because the core draws no entropy of |
| 53 | // its own (core/src/ops.rs). |
| 54 | func slideIDs(n int) (string, []string, error) { |
| 55 | slide, err := newID() |
| 56 | if err != nil { |
| 57 | return "", nil, err |
| 58 | } |
| 59 | blocks := make([]string, n) |
| 60 | for i := range blocks { |
| 61 | if blocks[i], err = newID(); err != nil { |
| 62 | return "", nil, err |
| 63 | } |
| 64 | } |
| 65 | return slide, blocks, nil |
| 66 | } |
| 67 | |
| 68 | // sampleOps is the whole deck as one batch. |
| 69 | // |
| 70 | // Eight slides. Six are flow slides, one per template that has anything |
| 71 | // to show — title, title-body, two-column, section, image-full, |
| 72 | // quote — and the last two are canvas slides, which carry the seventh |
| 73 | // template (`blank`) because a canvas slide ignores its template |
| 74 | // anyway: that is the honest place for `blank` to appear, rather than a |
| 75 | // seventh flow slide with nothing on it. |
| 76 | func sampleOps() ([]op, error) { |
| 77 | ops := []op{{"op": "set_meta", "title": sampleDeckTitle, "theme": sampleTheme}} |
| 78 | for _, build := range []func() ([]op, error){ |
| 79 | sampleTitleSlide, |
| 80 | sampleTitleBodySlide, |
| 81 | sampleTwoColumnSlide, |
| 82 | sampleSectionSlide, |
| 83 | sampleImageFullSlide, |
| 84 | sampleQuoteSlide, |
| 85 | sampleCanvasSlide, |
| 86 | samplePhoneReflowSlide, |
| 87 | } { |
| 88 | part, err := build() |
| 89 | if err != nil { |
| 90 | return nil, err |
| 91 | } |
| 92 | ops = append(ops, part...) |
| 93 | } |
| 94 | return ops, nil |
| 95 | } |
| 96 | |
| 97 | // addSlide is the op that starts a flow slide. |
| 98 | func addSlide(id, template string) op { |
| 99 | return op{"op": "add_slide", "id": id, "layout": "flow", "template": template} |
| 100 | } |
| 101 | |
| 102 | // addCanvas is the op that starts a canvas slide. Its template is |
| 103 | // `blank` and it is ignored — see sampleOps. |
| 104 | func addCanvas(id string) op { |
| 105 | return op{"op": "add_slide", "id": id, "layout": "canvas", "template": "blank"} |
| 106 | } |
| 107 | |
| 108 | // addBlock places a block in a flow slide's region. |
| 109 | func addBlock(slide, id, kind, region, markdown string, props map[string]any) op { |
| 110 | o := op{"op": "add_block", "slide": slide, "id": id, "type": kind, "region": region} |
| 111 | if props != nil { |
| 112 | o["props"] = props |
| 113 | } |
| 114 | if markdown != "" { |
| 115 | o["markdown"] = markdown |
| 116 | } |
| 117 | return o |
| 118 | } |
| 119 | |
| 120 | // addBoxed places a block on a canvas slide: a box in fractions of the |
| 121 | // 16:9 frame, and a z. Region is meaningless here and is not sent. |
| 122 | func addBoxed(slide, id, kind string, x, y, w, h float64, z int, markdown string, props map[string]any) op { |
| 123 | o := op{ |
| 124 | "op": "add_block", "slide": slide, "id": id, "type": kind, |
| 125 | "box": map[string]any{"x": x, "y": y, "w": w, "h": h}, |
| 126 | "z": z, |
| 127 | } |
| 128 | if props != nil { |
| 129 | o["props"] = props |
| 130 | } |
| 131 | if markdown != "" { |
| 132 | o["markdown"] = markdown |
| 133 | } |
| 134 | return o |
| 135 | } |
| 136 | |
| 137 | // notes attaches speaker notes. Every slide in this deck has them: |
| 138 | // they are what makes the sample teach rather than merely exist. |
| 139 | func notes(slide, markdown string) op { |
| 140 | return op{"op": "set_notes", "id": slide, "markdown": markdown} |
| 141 | } |
| 142 | |
| 143 | // 1. title — the opening slide: a heading and one line under it. |
| 144 | func sampleTitleSlide() ([]op, error) { |
| 145 | s, b, err := slideIDs(2) |
| 146 | if err != nil { |
| 147 | return nil, err |
| 148 | } |
| 149 | return []op{ |
| 150 | addSlide(s, "title"), |
| 151 | addBlock(s, b[0], "heading", "heading", sampleDeckTitle, map[string]any{"level": 1}), |
| 152 | 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"}), |
| 153 | notes(s, "The **title** template: one heading and one supporting line, centred. It is what a new deck opens on."), |
| 154 | }, nil |
| 155 | } |
| 156 | |
| 157 | // 2. title-body — the workhorse: a heading, prose with every inline |
| 158 | // mark, and a list. |
| 159 | func sampleTitleBodySlide() ([]op, error) { |
| 160 | s, b, err := slideIDs(3) |
| 161 | if err != nil { |
| 162 | return nil, err |
| 163 | } |
| 164 | return []op{ |
| 165 | addSlide(s, "title-body"), |
| 166 | addBlock(s, b[0], "heading", "heading", "Text and marks", map[string]any{"level": 1}), |
| 167 | addBlock(s, b[1], "text", "body", |
| 168 | "Text carries **bold**, *italic*, `code`, ==highlight== and [links](https://amadan.net). Nothing else — a heading is a block, not a mark.", |
| 169 | map[string]any{"size": "m"}), |
| 170 | addBlock(s, b[2], "list", "body", "- One item per paragraph\n- Bullets or numbers\n- Marks work here too, like **this**", |
| 171 | map[string]any{"style": "bullet", "size": "m"}), |
| 172 | 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."), |
| 173 | }, nil |
| 174 | } |
| 175 | |
| 176 | // 3. two-column — the heading, body and aside regions together. |
| 177 | func sampleTwoColumnSlide() ([]op, error) { |
| 178 | s, b, err := slideIDs(3) |
| 179 | if err != nil { |
| 180 | return nil, err |
| 181 | } |
| 182 | return []op{ |
| 183 | addSlide(s, "two-column"), |
| 184 | addBlock(s, b[0], "heading", "heading", "Regions, not coordinates", map[string]any{"level": 1}), |
| 185 | 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), |
| 186 | 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), |
| 187 | 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."), |
| 188 | }, nil |
| 189 | } |
| 190 | |
| 191 | // 4. section — the divider slide, one heading and nothing else. |
| 192 | func sampleSectionSlide() ([]op, error) { |
| 193 | s, b, err := slideIDs(1) |
| 194 | if err != nil { |
| 195 | return nil, err |
| 196 | } |
| 197 | return []op{ |
| 198 | addSlide(s, "section"), |
| 199 | addBlock(s, b[0], "heading", "heading", "Part two", map[string]any{"level": 1}), |
| 200 | notes(s, "The **section** template: a chapter break. One heading, large, and deliberately nothing else on the slide."), |
| 201 | }, nil |
| 202 | } |
| 203 | |
| 204 | // 5. image-full — the media region, standing in for a picture. |
| 205 | // |
| 206 | // There is no image here, because there is no way to upload one yet: |
| 207 | // assets arrive browser-to-bucket in a later slice (spec §2.7). A |
| 208 | // `shape` rectangle fills the media region instead, and the slide's own |
| 209 | // notes say so rather than leaving a reader to wonder why the sample |
| 210 | // deck's picture slide has no picture. |
| 211 | func sampleImageFullSlide() ([]op, error) { |
| 212 | s, b, err := slideIDs(2) |
| 213 | if err != nil { |
| 214 | return nil, err |
| 215 | } |
| 216 | return []op{ |
| 217 | addSlide(s, "image-full"), |
| 218 | addBlock(s, b[0], "heading", "heading", "A picture would go here", map[string]any{"level": 2}), |
| 219 | addBlock(s, b[1], "shape", "media", "Image placeholder", |
| 220 | map[string]any{"kind": "rect", "fill": "#d8d4cc", "stroke": "#9a948a", "strokeWidth": 2, "radius": 8}), |
| 221 | 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."), |
| 222 | }, nil |
| 223 | } |
| 224 | |
| 225 | // 6. quote — a pull quote with its attribution. |
| 226 | func sampleQuoteSlide() ([]op, error) { |
| 227 | s, b, err := slideIDs(1) |
| 228 | if err != nil { |
| 229 | return nil, err |
| 230 | } |
| 231 | return []op{ |
| 232 | addSlide(s, "quote"), |
| 233 | 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.", |
| 234 | map[string]any{"cite": "The design spec"}), |
| 235 | notes(s, "The **quote** template. A `quote` block carries its attribution in `cite` rather than as a second block of text."), |
| 236 | }, nil |
| 237 | } |
| 238 | |
| 239 | // 7. canvas — free placement: a heading, two shapes and a text block, |
| 240 | // each with its own box and z. |
| 241 | func sampleCanvasSlide() ([]op, error) { |
| 242 | s, b, err := slideIDs(4) |
| 243 | if err != nil { |
| 244 | return nil, err |
| 245 | } |
| 246 | return []op{ |
| 247 | addCanvas(s), |
| 248 | addBoxed(s, b[0], "heading", 0.06, 0.06, 0.88, 0.28, 3, "Canvas: put it where you want it", map[string]any{"level": 1}), |
| 249 | addBoxed(s, b[1], "shape", 0.08, 0.34, 0.36, 0.44, 1, "", |
| 250 | map[string]any{"kind": "rect", "fill": "#e8e2d6", "stroke": "#8a7f6a", "strokeWidth": 2, "radius": 12}), |
| 251 | addBoxed(s, b[2], "shape", 0.26, 0.46, 0.30, 0.30, 2, "", |
| 252 | map[string]any{"kind": "ellipse", "fill": "#cfd9e8", "stroke": "#5a6f8a", "strokeWidth": 2}), |
| 253 | addBoxed(s, b[3], "text", 0.58, 0.38, 0.36, 0.54, 4, |
| 254 | "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.", map[string]any{"size": "s"}), |
| 255 | 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."), |
| 256 | }, nil |
| 257 | } |
| 258 | |
| 259 | // 8. canvas with phone: reflow — the same freedom, plus the phone |
| 260 | // layer. The slide reflows to a single column on a phone, and one block |
| 261 | // is hidden there outright. |
| 262 | func samplePhoneReflowSlide() ([]op, error) { |
| 263 | s, b, err := slideIDs(3) |
| 264 | if err != nil { |
| 265 | return nil, err |
| 266 | } |
| 267 | return []op{ |
| 268 | addCanvas(s), |
| 269 | op{"op": "set_slide", "id": s, "phone": "reflow", "transition": "fade"}, |
| 270 | addBoxed(s, b[0], "heading", 0.06, 0.06, 0.88, 0.28, 1, "On a phone this one reflows", map[string]any{"level": 1}), |
| 271 | addBoxed(s, b[1], "text", 0.06, 0.38, 0.52, 0.54, 2, |
| 272 | "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), |
| 273 | addBoxed(s, b[2], "shape", 0.64, 0.38, 0.30, 0.44, 3, "Desktop only", |
| 274 | map[string]any{"kind": "rect", "fill": "#e0d7e8", "stroke": "#75608a", "strokeWidth": 2, "radius": 12}), |
| 275 | op{"op": "set_phone", "slide": s, "id": b[2], "phone": map[string]any{"hidden": true}}, |
| 276 | 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."), |
| 277 | }, nil |
| 278 | } |
| 279 | |
| 280 | // seedSampleDeckFor is what currentUser calls on a first sign-in. A |
| 281 | // failure is logged and swallowed on purpose: a sample deck is a |
| 282 | // welcome, and refusing to sign somebody in because their welcome could |
| 283 | // not be built would be the wrong trade by a distance. |
| 284 | func (a *app) seedSampleDeckFor(ctx context.Context, u User) { |
| 285 | if err := a.seedSampleDeck(ctx, u); err != nil { |
| 286 | a.logger.Error("slides: seed the sample deck", "user", u.ID, "err", err) |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | // hasDeck reports whether u owns any deck at all — the belt to |
| 291 | // ensureUser's braces. ensureUser only reports "created" for the |
| 292 | // request that actually inserted the row, so this should always be |
| 293 | // false when seeding runs; it costs one indexed count and it is what |
| 294 | // stops a race, a restore or a future second caller from handing |
| 295 | // somebody two welcome decks. |
| 296 | func hasDeck(ctx context.Context, g *gorm.DB, u User) (bool, error) { |
| 297 | var n int64 |
| 298 | if err := g.WithContext(ctx).Model(&Deck{}).Where("owner_id = ?", u.ID).Count(&n).Error; err != nil { |
| 299 | return false, fmt.Errorf("slides: count decks for user %d: %w", u.ID, err) |
| 300 | } |
| 301 | return n > 0, nil |
| 302 | } |
| 303 | |