| 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. |
| 15 | package slides |
| 16 | |
| 17 | import ( |
| 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. |
| 36 | const ( |
| 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. |
| 45 | const 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. |
| 49 | type 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. |
| 58 | type 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. |
| 130 | func (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. |
| 150 | func (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. |
| 312 | func 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. |
| 320 | func 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. |
| 330 | func 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. |
| 349 | func 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. |
| 368 | func 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 | |
| 382 | type notesRow struct { |
| 383 | Label string |
| 384 | Href string |
| 385 | Notes template.HTML |
| 386 | Empty string |
| 387 | } |
| 388 | |
| 389 | type 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. |
| 407 | func (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 | |