oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
11package slides
12
13import (
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.
24const 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.
28const 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.
35func (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).
54func 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.
76func 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.
98func 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.
104func 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.
109func 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.
122func 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.
139func 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.
144func 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.
159func 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.
177func 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.
192func 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.
211func 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.
226func 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.
241func 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.
262func 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.
284func (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.
296func 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