| 1 | // editor.go is the browser editor's own surface (spec §3.1): the page |
| 2 | // the bundle enhances, and the slide HTML its stage and strip fetch. |
| 3 | // |
| 4 | // The page is server-rendered whole — the strip with every thumbnail, |
| 5 | // the stage with the current slide, the notes under it — so the first |
| 6 | // paint is right before a byte of script has run, and so the one thing |
| 7 | // the bundle adds is editing. What the bundle needs to know (the deck, |
| 8 | // the log's seq, the current slide, who this person is) rides on data |
| 9 | // attributes; every visible string it will ever show is rendered into |
| 10 | // one too, from the catalog, so the bundle carries no English. |
| 11 | package slides |
| 12 | |
| 13 | import ( |
| 14 | "fmt" |
| 15 | "html/template" |
| 16 | "net/http" |
| 17 | "strconv" |
| 18 | "strings" |
| 19 | |
| 20 | "github.com/go-chi/chi/v5" |
| 21 | |
| 22 | "amadan.net/rastrillo/rastrillo" |
| 23 | |
| 24 | slidehtml "amadan.net/oficina/slides/internal/slides/render" |
| 25 | ) |
| 26 | |
| 27 | // stripItem is one slide in the strip: the address of the server's |
| 28 | // picture of it, its label, and whether it is the one on stage. |
| 29 | // |
| 30 | // A URL and not markup. The strip used to carry nine whole renderings |
| 31 | // of the deck scaled down by CSS; each is now one <img> of the SVG |
| 32 | // thumbs.go draws, which is smaller to send, cheaper to paint, and the |
| 33 | // same picture the export is (thumbs.go says why at length). |
| 34 | type stripItem struct { |
| 35 | ID string |
| 36 | Label string |
| 37 | Thumb string |
| 38 | Current bool |
| 39 | } |
| 40 | |
| 41 | // editorPage is the whole editor screen. The Text* fields are the |
| 42 | // strings the bundle shows at run time — the status line's three |
| 43 | // states, the link dialog — rendered into data attributes rather than |
| 44 | // into the DOM, because the bundle decides WHEN to show them and the |
| 45 | // catalog decides WHAT they say. |
| 46 | type editorPage struct { |
| 47 | shell |
| 48 | Title string |
| 49 | DeckSqid string |
| 50 | Seq int64 |
| 51 | SlideID string |
| 52 | ClientName string |
| 53 | ClientColour string |
| 54 | BundleSrc string |
| 55 | |
| 56 | BackHref string |
| 57 | BackLabel string |
| 58 | |
| 59 | MenuLabel string |
| 60 | NewSlideLabel string |
| 61 | DuplicateLabel string |
| 62 | UndoLabel string |
| 63 | RedoLabel string |
| 64 | PresentAction string |
| 65 | PresentLabel string |
| 66 | |
| 67 | StripLabel string |
| 68 | CurrentLabel string |
| 69 | // SlideLabel is the "Slide {n}" template itself, placeholder and |
| 70 | // all, for the strip to label a slide with no heading by its |
| 71 | // number after the order changes (strip.mjs's labelFor). |
| 72 | SlideLabel string |
| 73 | Strip []stripItem |
| 74 | |
| 75 | StageLabel string |
| 76 | HasSlides bool |
| 77 | Stage template.HTML |
| 78 | EmptyTitle string |
| 79 | EmptyBody string |
| 80 | |
| 81 | NotesLabel string |
| 82 | Notes template.HTML |
| 83 | |
| 84 | InspectorLabel string |
| 85 | InspectorEmpty string |
| 86 | |
| 87 | StatusLabel string |
| 88 | TextSaved string |
| 89 | TextSaving string |
| 90 | TextOffline string |
| 91 | TextFailed string |
| 92 | LinkTitle string |
| 93 | LinkURLLabel string |
| 94 | LinkInsert string |
| 95 | LinkRemove string |
| 96 | LinkCancel string |
| 97 | NoScriptBody string |
| 98 | NoScriptLink string |
| 99 | |
| 100 | // Themes is the deck section's theme picker, the settings screen's |
| 101 | // own list with the deck's theme selected. |
| 102 | Themes []selectOption |
| 103 | // HasStore says whether this deployment has an object store bound |
| 104 | // (Config.Blobs). False disables the image block's file input and |
| 105 | // puts the "no store" line in its place — the page says so rather |
| 106 | // than the bundle discovering it from a 503 after somebody has |
| 107 | // already chosen a file. |
| 108 | HasStore bool |
| 109 | // AcceptTypes is the file input's accept attribute: §2.7's four |
| 110 | // types, from the same map the upload endpoint validates against |
| 111 | // (assets.go), so the picker and the gate cannot drift. |
| 112 | AcceptTypes string |
| 113 | // S holds every other string the inspector, the strip's menu, the |
| 114 | // switcher and the canvas overlay show, keyed by the tail of its |
| 115 | // catalog key (slides.editor.<key>). The inspector has some ninety |
| 116 | // labels; one named field each would be a page type nobody could |
| 117 | // read, and the template reads them through L. |
| 118 | S map[string]string |
| 119 | } |
| 120 | |
| 121 | // L is the template's way to a string in S: {{.L "deck.title"}}. |
| 122 | func (p editorPage) L(key string) string { return p.S[key] } |
| 123 | |
| 124 | // editorStrings is every key the template reads through L. The list |
| 125 | // is here, once, so a missing key is a missing label on the page |
| 126 | // (rastrillo.T's own fallback) rather than a template failure. |
| 127 | var editorStrings = []string{ |
| 128 | "strip.actions", "strip.reorder", |
| 129 | "view", "view.desktop", "view.phone", "view.both", "view.phone.readonly", |
| 130 | "inspector.deck", "inspector.slide", "inspector.block", "inspector.blocks", "inspector.blocks.none", |
| 131 | "deck.title", "deck.theme", "deck.accent", "deck.accent.reset", "deck.font.heading", "deck.font.body", |
| 132 | "font.theme", "font.system", "font.serif", "font.mono", |
| 133 | "slide.layout", "layout.flow", "layout.flow.desc", "layout.canvas", "layout.canvas.desc", |
| 134 | "slide.template", "template.title", "template.title-body", "template.two-column", "template.section", |
| 135 | "template.image-full", "template.quote", "template.blank", |
| 136 | "slide.background", "background.theme", "background.color", "slide.background.color", |
| 137 | "slide.transition", "transition.none", "transition.fade", |
| 138 | "slide.phone", "slide.phone.inherit", "slide.phone.reflow", "slide.notes.show", |
| 139 | "slide.add", "slide.move.up", "slide.move.down", "slide.delete", "slide.delete.confirm", "slide.delete.cancel", |
| 140 | "block.add", "type.heading", "type.text", "type.list", "type.quote", "type.image", "type.columns", |
| 141 | "type.code", "type.shape", "type.table", "type.divider", "type.spacer", |
| 142 | "block.level", "block.size", "size.s", "size.m", "size.l", |
| 143 | "block.align", "align.start", "align.center", "align.end", |
| 144 | "block.style", "style.bullet", "style.number", "block.cite", |
| 145 | "block.fit", "fit.cover", "fit.contain", "block.alt", "block.decorative", "block.radius", |
| 146 | "block.image.file", "block.image.hint", "block.image.nostore", "block.image.uploading", |
| 147 | "block.image.checking", "block.image.done", "block.image.failed", "block.image.toolarge", |
| 148 | "block.image.type", "block.image.altfirst", |
| 149 | "assets.unused", "assets.unused.none", "assets.remove", |
| 150 | "block.count", "block.language", |
| 151 | "block.kind", "kind.rect", "kind.ellipse", "kind.line", "kind.arrow", |
| 152 | "block.fill", "block.stroke", "block.strokeWidth", "block.label", |
| 153 | "block.rows", "block.cols", "block.header", |
| 154 | "block.region", "region.heading", "region.body", "region.aside", "region.media", |
| 155 | "block.box", "box.x", "box.y", "box.w", "box.h", |
| 156 | "block.forward", "block.back", "block.move.up", "block.move.down", "block.delete", |
| 157 | "block.phone", "block.phone.hidden", "block.phone.order", "block.phone.size", "block.phone.same", |
| 158 | "block.phone.override", "block.phone.reset", |
| 159 | "canvas.selected", |
| 160 | "presence.slide", "presence.block", "presence.more", "presence.joined", "presence.left", |
| 161 | "status.error", |
| 162 | } |
| 163 | |
| 164 | // blockTypes is §2.4's vocabulary in the order the add menu lists it. |
| 165 | var blockTypes = []string{"heading", "text", "list", "quote", "image", "columns", "code", "shape", "table", "divider", "spacer"} |
| 166 | |
| 167 | // BlockTypes is the add menu's list, for the template's range. |
| 168 | func (p editorPage) BlockTypes() []string { return blockTypes } |
| 169 | |
| 170 | // --- GET /d/{sqid}/edit --- |
| 171 | |
| 172 | // deckEditor serves the editor page to deckFor's audience — the owner |
| 173 | // and, on a team deck, every member. A team deck is edited by the team |
| 174 | // (live.go's rule for the write endpoints, which this page is the |
| 175 | // front of), so there is no owner-only branch here. |
| 176 | func (a *app) deckEditor(w http.ResponseWriter, r *http.Request) { |
| 177 | u, ok := a.viewer(w, r) |
| 178 | if !ok { |
| 179 | return |
| 180 | } |
| 181 | d, ok := a.deckFor(w, r, u) |
| 182 | if !ok { |
| 183 | return |
| 184 | } |
| 185 | h, view, err := a.openDeck(r.Context(), d) |
| 186 | if err != nil { |
| 187 | a.fail(w, r, "open a deck to edit", err) |
| 188 | return |
| 189 | } |
| 190 | defer h.Close(r.Context()) |
| 191 | |
| 192 | base := "/d/" + d.Sqid |
| 193 | person := u.Name |
| 194 | if person == "" { |
| 195 | person = u.Email |
| 196 | } |
| 197 | page := editorPage{ |
| 198 | shell: a.shellFor(r, u, titled(view.Meta.Title)), |
| 199 | Title: view.Meta.Title, |
| 200 | DeckSqid: d.Sqid, |
| 201 | Seq: d.Seq, |
| 202 | ClientName: person, |
| 203 | ClientColour: u.Colour, |
| 204 | BundleSrc: editorBundlePath(), |
| 205 | |
| 206 | BackHref: base, |
| 207 | BackLabel: rastrillo.T(r, "slides.deck.back"), |
| 208 | |
| 209 | MenuLabel: rastrillo.T(r, "slides.editor.menu"), |
| 210 | NewSlideLabel: rastrillo.T(r, "slides.editor.slide.new"), |
| 211 | DuplicateLabel: rastrillo.T(r, "slides.editor.slide.duplicate"), |
| 212 | UndoLabel: rastrillo.T(r, "slides.editor.undo"), |
| 213 | RedoLabel: rastrillo.T(r, "slides.editor.redo"), |
| 214 | PresentAction: base + "/present", |
| 215 | PresentLabel: rastrillo.T(r, "slides.viewer.present"), |
| 216 | |
| 217 | StripLabel: rastrillo.T(r, "slides.editor.strip"), |
| 218 | CurrentLabel: rastrillo.T(r, "slides.editor.strip.current"), |
| 219 | SlideLabel: rastrillo.T(r, "slides.deck.slide"), |
| 220 | StageLabel: rastrillo.T(r, "slides.editor.stage"), |
| 221 | EmptyTitle: rastrillo.T(r, "slides.viewer.empty.title"), |
| 222 | EmptyBody: rastrillo.T(r, "slides.editor.empty.body"), |
| 223 | NotesLabel: rastrillo.T(r, "slides.viewer.notes"), |
| 224 | |
| 225 | InspectorLabel: rastrillo.T(r, "slides.editor.inspector"), |
| 226 | InspectorEmpty: rastrillo.T(r, "slides.editor.inspector.empty"), |
| 227 | |
| 228 | StatusLabel: rastrillo.T(r, "slides.editor.status"), |
| 229 | TextSaved: rastrillo.T(r, "slides.editor.status.saved"), |
| 230 | TextSaving: rastrillo.T(r, "slides.editor.status.saving"), |
| 231 | TextOffline: rastrillo.T(r, "slides.editor.status.offline"), |
| 232 | TextFailed: rastrillo.T(r, "slides.editor.status.failed"), |
| 233 | LinkTitle: rastrillo.T(r, "slides.editor.link.title"), |
| 234 | LinkURLLabel: rastrillo.T(r, "slides.editor.link.url"), |
| 235 | LinkInsert: rastrillo.T(r, "slides.editor.link.insert"), |
| 236 | LinkRemove: rastrillo.T(r, "slides.editor.link.remove"), |
| 237 | LinkCancel: rastrillo.T(r, "slides.editor.link.cancel"), |
| 238 | NoScriptBody: rastrillo.T(r, "slides.editor.noscript"), |
| 239 | NoScriptLink: rastrillo.T(r, "slides.editor.noscript.link"), |
| 240 | |
| 241 | Themes: themeOptions(r, view.Meta.Theme), |
| 242 | HasStore: a.cfg.Blobs != nil, |
| 243 | AcceptTypes: acceptTypes(), |
| 244 | S: make(map[string]string, len(editorStrings)), |
| 245 | } |
| 246 | for _, k := range editorStrings { |
| 247 | page.S[k] = rastrillo.T(r, "slides.editor."+k) |
| 248 | } |
| 249 | |
| 250 | if len(view.Slides) == 0 { |
| 251 | render(w, "editor", page) |
| 252 | return |
| 253 | } |
| 254 | |
| 255 | // The page opens on the first slide. Which slide the URL hash |
| 256 | // names is the bundle's business — the hash never reaches the |
| 257 | // server — and the bundle moves the stage if it says otherwise. |
| 258 | page.HasSlides = true |
| 259 | page.SlideID = view.Slides[0].ID |
| 260 | opt := slidehtml.Options{AssetBase: assetBaseFor(base)} |
| 261 | stage, err := slidehtml.SlideWith(view, 0, slidehtml.Desktop, opt) |
| 262 | if err != nil { |
| 263 | a.fail(w, r, "render the stage", err) |
| 264 | return |
| 265 | } |
| 266 | page.Stage = stage |
| 267 | page.Notes = slidehtml.Notes(view.Slides[0].Notes) |
| 268 | |
| 269 | page.Strip = make([]stripItem, 0, len(view.Slides)) |
| 270 | for i, s := range view.Slides { |
| 271 | // The deck's seq is on the URL so the browser asks again when |
| 272 | // the deck has changed and reads its own cache when it has |
| 273 | // not. The route revalidates on an ETag either way; this is |
| 274 | // what keeps a returning tab from showing yesterday's slide |
| 275 | // out of the disk cache before the first flush. |
| 276 | page.Strip = append(page.Strip, stripItem{ |
| 277 | ID: s.ID, |
| 278 | Label: slideLabel(r, s, i+1), |
| 279 | Thumb: fmt.Sprintf("%s/slides/%s.svg?seq=%d", base, s.ID, d.Seq), |
| 280 | Current: i == 0, |
| 281 | }) |
| 282 | } |
| 283 | render(w, "editor", page) |
| 284 | } |
| 285 | |
| 286 | // --- GET /d/{sqid}/slides/{id}.html --- |
| 287 | |
| 288 | // deckSlide renders one slide as the section the viewer would show it |
| 289 | // in — the session-cookie twin of the API's apiSlide, for the editor's |
| 290 | // stage and its thumbnail strip. |
| 291 | // |
| 292 | // Two surfaces rather than one shared route because the two are |
| 293 | // authenticated differently and nothing else about them differs: the |
| 294 | // API's caller carries a bearer token and the API's own 404 shape, this |
| 295 | // one carries a session cookie and this app's. The rendering is |
| 296 | // literally the same call into the same package, which is the invariant |
| 297 | // that matters (AGENTS.md: render.Slide is the only place slide HTML is |
| 298 | // produced). |
| 299 | // |
| 300 | // It is the SECTION, never a page: the stage swaps this into the DOM, |
| 301 | // so a layout around it would be a page nested inside a page. |
| 302 | // |
| 303 | // vp names the viewport: `phone` reflows (spec §2.5), `desktop` and |
| 304 | // `thumb` both render desktop. A thumbnail is a desktop slide scaled by |
| 305 | // CSS rather than a third rendering — the one rule this app has is that |
| 306 | // a slide is one block tree rendered many ways, and "many ways" is two |
| 307 | // viewports, not one per place a slide is drawn. |
| 308 | func (a *app) deckSlide(w http.ResponseWriter, r *http.Request) { |
| 309 | u, ok := a.viewer(w, r) |
| 310 | if !ok { |
| 311 | return |
| 312 | } |
| 313 | d, ok := a.deckFor(w, r, u) |
| 314 | if !ok { |
| 315 | return |
| 316 | } |
| 317 | // The picture twin, on the same route because the two are the same |
| 318 | // slide behind the same capability and differ only in what a |
| 319 | // caller wants back (thumbs.go). |
| 320 | if strings.HasSuffix(chi.URLParam(r, "file"), ".svg") { |
| 321 | a.serveThumb(w, r, d, "/d/"+d.Sqid, func() { a.notFound(w, r) }) |
| 322 | return |
| 323 | } |
| 324 | |
| 325 | slideID, ok := strings.CutSuffix(chi.URLParam(r, "file"), ".html") |
| 326 | if !ok || slideID == "" { |
| 327 | a.notFound(w, r) |
| 328 | return |
| 329 | } |
| 330 | vp := editorViewport(r.URL.Query().Get("vp")) |
| 331 | |
| 332 | // Seq, slide and viewport: the three things that change what these |
| 333 | // bytes are. The stage re-fetches after every structural change, |
| 334 | // and the browser sends the tag back; a match answers 304 before |
| 335 | // the document is opened, which is what makes the fetch that |
| 336 | // changed nothing cheap. The slide id is in the tag so a stale |
| 337 | // slide's tag never matches a live one at the same seq — though a |
| 338 | // removed slide is a 404 below either way. |
| 339 | // |
| 340 | // The header goes on the two responses that ARE this |
| 341 | // representation — the 304 here and the 200 below — and never on |
| 342 | // the 404: a tag on "there is no such slide" invites a cache to |
| 343 | // treat the absence as the thing. |
| 344 | etag := strconv.Quote(strconv.FormatInt(d.Seq, 10) + "-" + slideID + "-" + vp.String()) |
| 345 | if matchesETag(r.Header.Get("If-None-Match"), etag) { |
| 346 | w.Header().Set("ETag", etag) |
| 347 | w.WriteHeader(http.StatusNotModified) |
| 348 | return |
| 349 | } |
| 350 | |
| 351 | h, view, err := a.openDeck(r.Context(), d) |
| 352 | if err != nil { |
| 353 | a.fail(w, r, "open a deck to render a slide", err) |
| 354 | return |
| 355 | } |
| 356 | defer h.Close(r.Context()) |
| 357 | |
| 358 | index := -1 |
| 359 | for i, s := range view.Slides { |
| 360 | if s.ID == slideID { |
| 361 | index = i |
| 362 | break |
| 363 | } |
| 364 | } |
| 365 | if index < 0 { |
| 366 | a.notFound(w, r) |
| 367 | return |
| 368 | } |
| 369 | |
| 370 | section, err := slidehtml.SlideWith(view, index, vp, |
| 371 | slidehtml.Options{AssetBase: assetBaseFor("/d/" + d.Sqid)}) |
| 372 | if err != nil { |
| 373 | a.fail(w, r, "render a slide for the editor", err) |
| 374 | return |
| 375 | } |
| 376 | w.Header().Set("ETag", etag) |
| 377 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 378 | w.WriteHeader(http.StatusOK) |
| 379 | fmt.Fprint(w, section) |
| 380 | } |
| 381 | |
| 382 | // editorViewport maps the ?vp= value onto a rendering. Anything |
| 383 | // unrecognised is desktop rather than a 400: this is a rendering hint |
| 384 | // on a read, and a stage that got a typo should show the slide. |
| 385 | func editorViewport(vp string) slidehtml.Viewport { |
| 386 | if vp == "phone" { |
| 387 | return slidehtml.Phone |
| 388 | } |
| 389 | return slidehtml.Desktop |
| 390 | } |
| 391 | |