oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// viewer.go is the paged viewer and the notes page: the screens that
2// show a deck to somebody who is reading it rather than writing it.
3//
4// One surface, three audiences (spec §5). The owner, a team member and
5// a stranger holding a share link all get the same page from the same
6// template — mode is a field on the view data, never a second template
7// and never a second handler, because the day the three drift is the
8// day one of them leaks the speaker notes.
9//
10// It is the scriptless floor (§8) as a whole screen: one slide per
11// page, previous and next as ordinary links, a filmstrip of ordinary
12// links, notes in a <details>. Nothing here needs JavaScript to work,
13// and task 6's live presenter is an addition to it rather than a
14// replacement for it.
15package slides
16
17import (
18 "html/template"
19 "net/http"
20 "strconv"
21 "strings"
22
23 "amadan.net/rastrillo/rastrillo"
24
25 "amadan.net/oficina/slides/internal/slides/deck"
26 // Aliased: this package already has a render — render.go's, which
27 // executes a page template — and the two would collide on the
28 // name. slidehtml says which of the two a call means at the call
29 // site, which is the more useful thing for a reader anyway.
30 slidehtml "amadan.net/oficina/slides/internal/slides/render"
31)
32
33// The three audiences a viewer is rendered for. Owner and member are
34// both signed in and both see the notes; link is a share-link reader,
35// who sees the slides and nothing else about the deck.
36const (
37 modeOwner = "owner"
38 modeMember = "member"
39 modeLink = "link"
40)
41
42// vpPhone is the ?vp= value that asks for the phone rendering. Anything
43// else is the desktop one — an unknown viewport is not an error, it is
44// simply not the phone.
45const vpPhone = "phone"
46
47// filmItem is one slide in the filmstrip: where it is, what it is
48// called, and whether it is the one being shown.
49type filmItem struct {
50 Href string
51 Label string
52 Current bool
53}
54
55// viewerPage is the whole viewer screen. Mode is what the template
56// branches on; every href is built in Go so the query string a link
57// carries (the viewport, the presenting flag) is decided in one place.
58type viewerPage struct {
59 shell
60 Mode string
61 Present bool
62 // VP is "desktop" or "phone" — the wrapper's data-vp, so the page
63 // itself says which shape it was rendered for and not only the
64 // section inside it.
65 VP string
66
67 Title string
68 Slide template.HTML
69
70 // HasSlides is false for a deck with nothing in it. It is a real
71 // state rather than a 404: a deck you own that renders as "not
72 // found" reads as data loss.
73 HasSlides bool
74 EmptyTitle string
75 EmptyBody string
76
77 Counter string
78 PagerLabel string
79 PrevHref string
80 PrevLabel string
81 NextHref string
82 NextLabel string
83 FilmLabel string
84 Film []filmItem
85
86 HasNotes bool
87 NotesLabel string
88 Notes template.HTML
89
90 ToolsLabel string
91 // EditHref opens the editor (editor.go). Owner and member alike:
92 // the editor is deckFor's audience, the same people the live-sync
93 // endpoints behind it let write.
94 EditHref string
95 EditLabel string
96 SettingsHref string
97 SettingsLabel string
98 ShareHref string
99 ShareLabel string
100 NotesHref string
101 NotesPageLabel string
102 // ExportHref opens the export screen (exports.go). Owner and
103 // member alike, like the editor and presenting: anybody who can
104 // read the deck can take a copy of it away.
105 ExportHref string
106 ExportLabel string
107 // PresentAction is the form that opens a presentation session
108 // (presentation.go). A form and not a link, because pressing it
109 // WRITES: it mints a session and a remote capability.
110 PresentAction string
111 PresentHref string
112 PresentLabel string
113
114 // FollowHref is offered on a share link while somebody is
115 // presenting this deck — spec §3.4's "follow the presenter". Empty
116 // the rest of the time, which is nearly always.
117 FollowHref string
118 FollowLabel string
119
120 BackHref string
121 BackLabel string
122}
123
124// --- GET /d/{sqid} ---
125
126// showDeck serves the signed-in viewer. Which of the two signed-in
127// modes it renders is the ownership question and nothing else: a
128// member reached this deck through visibleDecks, so they read the
129// notes, and only the owner is offered the screens that change it.
130func (a *app) showDeck(w http.ResponseWriter, r *http.Request) {
131 u, ok := a.viewer(w, r)
132 if !ok {
133 return
134 }
135 d, ok := a.deckFor(w, r, u)
136 if !ok {
137 return
138 }
139 mode := modeMember
140 if d.OwnerID == u.ID {
141 mode = modeOwner
142 }
143 a.renderViewer(w, r, d, mode, "/d/"+d.Sqid, a.shellFor(r, u, titled(d.Title)))
144}
145
146// renderViewer is the one viewer, for all three audiences. base is the
147// URL every link on the page hangs off — "/d/{sqid}" signed in,
148// "/s/{token}" over a share link — so the page never has to know which
149// of the two it is serving to build a next link.
150func (a *app) renderViewer(w http.ResponseWriter, r *http.Request, d Deck, mode, base string, sh shell) {
151 h, view, err := a.openDeck(r.Context(), d)
152 if err != nil {
153 a.fail(w, r, "open deck", err)
154 return
155 }
156 defer h.Close(r.Context())
157
158 q := r.URL.Query()
159 vp := q.Get("vp")
160 if vp != vpPhone {
161 vp = "desktop"
162 }
163 present := q.Get("present") == "1"
164
165 page := viewerPage{
166 shell: sh,
167 Mode: mode,
168 Present: present,
169 VP: vp,
170 Title: view.Meta.Title,
171 }
172 if mode != modeLink {
173 page.BackHref = "/"
174 page.BackLabel = rastrillo.T(r, "slides.decks.title")
175 }
176
177 // The buttons, before anything about which slide is being shown:
178 // they are about the deck, not about the slide, so an owner whose
179 // document has come back empty still has a way to its settings.
180 // modeLink's present link is the exception and waits for the index,
181 // because it is a link to a slide.
182 page.ToolsLabel = rastrillo.T(r, "slides.viewer.tools")
183 switch mode {
184 case modeOwner:
185 page.EditHref = base + "/edit"
186 page.EditLabel = rastrillo.T(r, "slides.viewer.edit")
187 page.SettingsHref = base + "/settings"
188 page.SettingsLabel = rastrillo.T(r, "slides.deck.settings")
189 page.ShareHref = base + "/share"
190 page.ShareLabel = rastrillo.T(r, "slides.viewer.share")
191 page.NotesHref = base + "/notes"
192 page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page")
193 page.ExportHref = base + "/export"
194 page.ExportLabel = rastrillo.T(r, "slides.viewer.export")
195 page.PresentAction = base + "/present"
196 page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
197 case modeMember:
198 page.EditHref = base + "/edit"
199 page.EditLabel = rastrillo.T(r, "slides.viewer.edit")
200 page.NotesHref = base + "/notes"
201 page.NotesPageLabel = rastrillo.T(r, "slides.viewer.notes.page")
202 // A member exports too, for the same reason.
203 page.ExportHref = base + "/export"
204 page.ExportLabel = rastrillo.T(r, "slides.viewer.export")
205 // A member presents too: reading a deck to a room is reading
206 // it (presentation.go's startPresenting).
207 page.PresentAction = base + "/present"
208 page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
209 case modeLink:
210 // Spec §3.4: a share link opened while somebody is presenting
211 // offers to follow along. The lookup is here and only here,
212 // because this is the one mode that can be reached by an
213 // audience — a signed-in reader has the deck's own screens.
214 if _, live := a.liveSessionFor(r, d.ID); live {
215 page.FollowHref = base + "?follow=1"
216 page.FollowLabel = rastrillo.T(r, "slides.follow.offer")
217 }
218 }
219
220 if len(view.Slides) == 0 {
221 page.EmptyTitle = rastrillo.T(r, "slides.viewer.empty.title")
222 // Somebody who can edit is told how to fill it; a stranger
223 // holding a share link only that it is empty (the audit found
224 // this line telling people to read docs/api.md).
225 page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body.link")
226 if page.EditHref != "" {
227 page.EmptyBody = rastrillo.T(r, "slides.viewer.empty.body")
228 }
229 render(w, viewerTemplate(mode), page)
230 return
231 }
232
233 index, ok := slideIndex(q.Get("slide"), len(view.Slides))
234 if !ok {
235 // Off the end of the deck is a page that does not exist, not a
236 // clamp to the last slide: a link to slide 40 of an 8-slide
237 // deck is wrong, and quietly showing slide 8 hides that.
238 a.notFound(w, r)
239 return
240 }
241
242 // The images go under this page's own base — "/d/{sqid}/assets"
243 // signed in, "/s/{token}/assets" over a share link — because the
244 // reader looking at the page is the reader whose request has to
245 // fetch them (assets.go).
246 html, err := slidehtml.SlideWith(view, index, viewport(vp),
247 slidehtml.Options{AssetBase: assetBaseFor(base)})
248 if err != nil {
249 a.fail(w, r, "render slide", err)
250 return
251 }
252 s := view.Slides[index]
253
254 page.HasSlides = true
255 page.Slide = html
256 page.Counter = rastrillo.Tf(r, "slides.viewer.counter",
257 "n", index+1, "total", len(view.Slides))
258 page.PagerLabel = rastrillo.T(r, "slides.viewer.pager")
259 page.PrevLabel = rastrillo.T(r, "slides.viewer.prev")
260 page.NextLabel = rastrillo.T(r, "slides.viewer.next")
261 if index > 0 {
262 page.PrevHref = viewerHref(base, index, vp, present)
263 }
264 if index+1 < len(view.Slides) {
265 page.NextHref = viewerHref(base, index+2, vp, present)
266 }
267
268 // The presenting surface is the slide and the two links, and
269 // nothing else: no filmstrip, no notes, no buttons. Task 6 replaces
270 // it with the real fullscreen surface; what it must not do is
271 // arrive before then as a page that leaks the notes.
272 if present {
273 render(w, viewerTemplate(mode), page)
274 return
275 }
276
277 page.FilmLabel = rastrillo.T(r, "slides.viewer.film")
278 page.Film = make([]filmItem, 0, len(view.Slides))
279 for i, sl := range view.Slides {
280 page.Film = append(page.Film, filmItem{
281 Href: viewerHref(base, i+1, vp, false),
282 Label: slideLabel(r, sl, i+1),
283 Current: i == index,
284 })
285 }
286
287 // The notes are the deck's own working notes, so they belong to
288 // the people who reached the deck rather than to whoever holds a
289 // link to it (spec §5: a share link is read-only, and read-only
290 // means the slides).
291 if mode != modeLink && len(s.Notes) > 0 {
292 page.HasNotes = true
293 page.NotesLabel = rastrillo.T(r, "slides.viewer.notes")
294 page.Notes = slidehtml.Notes(s.Notes)
295 }
296
297 // Spec §5: the present link is this same capability with
298 // ?present=1, so a link reader gets a working one rather than the
299 // placeholder the signed-in screens carry until task 6.
300 if mode == modeLink {
301 page.PresentHref = viewerHref(base, index+1, vp, true)
302 page.PresentLabel = rastrillo.T(r, "slides.viewer.present")
303 }
304
305 render(w, viewerTemplate(mode), page)
306}
307
308// viewerTemplate picks which build of viewer.html to execute. Two
309// builds, one file (render.go): the signed-in one carries the account
310// menu, and the share-link one carries a sign-in link instead, because
311// nobody reading over a link has an account to show.
312func viewerTemplate(mode string) string {
313 if mode == modeLink {
314 return "viewer_link"
315 }
316 return "viewer"
317}
318
319// viewport maps the ?vp= value onto the renderer's own two.
320func viewport(vp string) slidehtml.Viewport {
321 if vp == vpPhone {
322 return slidehtml.Phone
323 }
324 return slidehtml.Desktop
325}
326
327// slideIndex turns the ?slide= parameter into a 0-based index. Empty is
328// the first slide; anything else must be a whole number naming a slide
329// this deck actually has.
330func slideIndex(raw string, count int) (int, bool) {
331 if raw == "" {
332 return 0, count > 0
333 }
334 n, err := strconv.Atoi(raw)
335 if err != nil || n < 1 || n > count {
336 return 0, false
337 }
338 return n - 1, true
339}
340
341// viewerHref builds a link to slide n (1-based), carrying whatever the
342// current request was rendered with. The viewport travels because
343// paging out of a phone preview back into the desktop one is a trap;
344// the presenting flag travels for the same reason.
345//
346// Built by hand rather than with url.Values so the order is fixed —
347// slide, then vp, then present — which is what makes these URLs stable
348// enough to assert on and to read in a log.
349func viewerHref(base string, n int, vp string, present bool) string {
350 var b strings.Builder
351 b.WriteString(base)
352 b.WriteString("?slide=")
353 b.WriteString(strconv.Itoa(n))
354 if vp == vpPhone {
355 b.WriteString("&vp=phone")
356 }
357 if present {
358 b.WriteString("&present=1")
359 }
360 return b.String()
361}
362
363// slideLabel is what the filmstrip and the notes page call a slide: its
364// own first heading, or its number when it has none. The heading is the
365// better label by a distance — "Regions, not coordinates" is findable
366// in a list of eight and "Slide 3" is not — but a slide need not have
367// one, so the number is always there to fall back to.
368func slideLabel(r *http.Request, s deck.Slide, n int) string {
369 for _, b := range s.Blocks {
370 if b.Type != "heading" {
371 continue
372 }
373 if text := strings.TrimSpace(b.Text.Plain()); text != "" {
374 return text
375 }
376 }
377 return rastrillo.Tf(r, "slides.deck.slide", "n", n)
378}
379
380// --- GET /d/{sqid}/notes ---
381
382type notesRow struct {
383 Label string
384 Href string
385 Notes template.HTML
386 Empty string
387}
388
389type notesPage struct {
390 shell
391 Title string
392 DeckTitle string
393 Body string
394 BackHref string
395 BackLabel string
396 Slides []notesRow
397}
398
399// deckNotes serves GET /d/{sqid}/notes: every slide's label and its
400// notes, in order, on one page.
401//
402// It is spec §8's "read the presenter notes page" — the thing a person
403// prints, or keeps open on a second screen, when the live presenter
404// view is not available to them. Signed in only, and behind the same
405// visibility seam as the deck itself: notes are not part of what a
406// share link carries.
407func (a *app) deckNotes(w http.ResponseWriter, r *http.Request) {
408 u, ok := a.viewer(w, r)
409 if !ok {
410 return
411 }
412 d, ok := a.deckFor(w, r, u)
413 if !ok {
414 return
415 }
416
417 h, view, err := a.openDeck(r.Context(), d)
418 if err != nil {
419 a.fail(w, r, "open deck", err)
420 return
421 }
422 defer h.Close(r.Context())
423
424 base := "/d/" + d.Sqid
425 page := notesPage{
426 shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.notes.title"), view.Meta.Title)),
427 Title: rastrillo.T(r, "slides.notes.title"),
428 DeckTitle: view.Meta.Title,
429 Body: rastrillo.T(r, "slides.notes.body"),
430 BackHref: base,
431 BackLabel: rastrillo.T(r, "slides.deck.back"),
432 Slides: make([]notesRow, 0, len(view.Slides)),
433 }
434 empty := rastrillo.T(r, "slides.notes.none")
435 for i, s := range view.Slides {
436 row := notesRow{
437 Label: slideLabel(r, s, i+1),
438 Href: viewerHref(base, i+1, "desktop", false),
439 }
440 if len(s.Notes) > 0 {
441 row.Notes = slidehtml.Notes(s.Notes)
442 } else {
443 // A slide with no notes says so rather than rendering as a
444 // heading over nothing: on a page being read while
445 // presenting, a gap is indistinguishable from a bug.
446 row.Empty = empty
447 }
448 page.Slides = append(page.Slides, row)
449 }
450 render(w, "deck_notes", page)
451}
452