clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / app / pages.go
pages.go 6.8K
1package app23import (4 "io"5 "log/slog"6 "mime"7 "net/http"8 "path"9 "strings"1011 "github.com/FelineStateMachine/atlas/format/bundle"12 "github.com/FelineStateMachine/atlas/internal/logging"13)1415// The app plane's pages. Real URLs replace the reference implementation's hash16// routing: a volume and a world are in the address bar, so a page can be17// bookmarked, reloaded, and linked to (issue #5 §4.2).1819// handleHome sends a reader to where they were, or explains an empty library.20//21// A redirect rather than a rendered page is the point: the explorer lives at22// exactly one URL per world, and / is a doorway, not a second name for it.23func (a *App) handleHome(w http.ResponseWriter, r *http.Request) {24 held := a.library()25 if len(held.order) == 0 {26 a.writePage(w, "empty-state", a.view(held, nil, Session{Schema: SessionSchema}))27 return28 }2930 volume, serving := held.bySlug[a.lastVolume()]31 if !serving {32 volume = held.order[0]33 }34 manifest := volume.Manifest()35 session := a.session(manifest.Volume.Slug)36 world := session.World37 if _, ok := worldEntry(manifest, world); !ok {38 world = manifest.Worlds[0].Slug39 }40 http.Redirect(w, r, "/v/"+manifest.Volume.Slug+"/"+world, http.StatusFound)41}4243// handleOpen is the doorway the volume and world selects go through.44//45// A select cannot build a path out of its own value, and a volume and a world46// are both in the address, so the two crumbs name what they want and this47// redirects to it. It exists so that the topbar can be pure hypermedia48// without the page holding a second, private idea of where it is: after the49// redirect the reader is at the real URL, which is the only place the50// explorer lives.51func (a *App) handleOpen(w http.ResponseWriter, r *http.Request) {52 slug := r.URL.Query().Get("volume")53 held := a.library()54 volume, serving := held.bySlug[slug]55 if !serving {56 if len(held.order) == 0 {57 http.Redirect(w, r, "/", http.StatusFound)58 return59 }60 volume = held.order[0]61 }62 manifest := volume.Manifest()63 world := r.URL.Query().Get("world")64 if _, ok := worldEntry(manifest, world); !ok {65 world = a.session(manifest.Volume.Slug).World66 }67 if _, ok := worldEntry(manifest, world); !ok {68 world = manifest.Worlds[0].Slug69 }70 http.Redirect(w, r, "/v/"+manifest.Volume.Slug+"/"+world, http.StatusFound)71}7273// handleExplorer serves one world of one volume: the whole page, server74// rendered, with every region already in its remembered state.75func (a *App) handleExplorer(w http.ResponseWriter, r *http.Request) {76 slug, world := r.PathValue("volume"), r.PathValue("world")77 if bundle.ValidSlug(slug) != nil || bundle.ValidSlug(world) != nil {78 http.NotFound(w, r)79 return80 }81 held := a.library()82 volume, serving := held.bySlug[slug]83 if !serving {84 http.NotFound(w, r)85 return86 }87 manifest := volume.Manifest()88 if _, ok := worldEntry(manifest, world); !ok {89 http.NotFound(w, r)90 return91 }9293 // Arriving at a URL is a choice, whether it was clicked, typed or94 // restored, so the session follows the address bar rather than the other95 // way round. A store that refuses the write costs the reader their place96 // on the next launch and nothing else, so the page is still served.97 session := a.session(slug)98 if session.World != world {99 // Arriving on a different ground is arriving on a different100 // ground however it was reached, so the arrangement is the new101 // world's to supply.102 session.Arranged = false103 session.Selected = ""104 session.Detail.Open = false105 session.Highlighted = nil106 session.Search = ""107 // A map opens on the map: the panel is away until the new ground gives108 // it something to hold, and nobody has yet put it away by hand here.109 session.Dock = Dock{Section: session.Dock.Section}110 }111 // The ground the reader last went to is a mark on a world they were112 // standing on. Coming back to this volume from another one stands them on113 // it again -- the reference rebuilt the whole index at exactly this moment114 // and dropped the mark with it -- while re-reading the page the reader is115 // already on, which is what a catalog reconcile does, changes nothing.116 if session.World != world || a.lastVolume() != slug {117 session.Focused = ""118 }119 session.World = world120 session.Stamp = bundle.ShortStamp(manifest.Version.Stamp)121 a.arrange(volume, &session)122 if err := a.saveSession(&session); err != nil {123 slog.Warn("the session could not be written", logging.Op("session"),124 logging.Volume(slug), slog.Any("error", err))125 }126 if err := a.rememberVolume(slug); err != nil {127 slog.Warn("the last volume could not be remembered", logging.Op("session"),128 logging.Volume(slug), slog.Any("error", err))129 }130131 a.writePage(w, "shell", a.view(held, volume, session))132}133134// handleDetail answers with one feature's card. The card is a fragment rather135// than a partial set: it is fetched for a target that asked for it, not pushed136// at regions an interaction moved.137func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {138 slug := r.URL.Query().Get("volume")139 if bundle.ValidSlug(slug) != nil {140 http.Error(w, "the fragment names no volume", http.StatusBadRequest)141 return142 }143 held := a.library()144 volume, serving := held.bySlug[slug]145 if !serving {146 http.NotFound(w, r)147 return148 }149 // A fragment is asked for by a target that wants it, so the card it150 // answers with is the one asked for rather than the one the session151 // happens to have open. The record is not written: fetching a card is152 // reading, and reading is not an arrangement.153 session := a.session(slug)154 session.Selected = r.PathValue("id")155 session.Detail.Open = session.Selected != ""156 view := a.view(held, volume, session)157 view.Feature = session.Selected158 a.writePage(w, "detail", view)159}160161// handleStatic serves the seam's built bundle and the stylesheet system out of162// whatever tree the host mounted. A host that mounted nothing answers 404: the163// application is required to work without the seam present, which is the164// deletability principle standing up in the build order (issue #5 §3.2).165func (a *App) handleStatic(w http.ResponseWriter, r *http.Request) {166 if a.static == nil {167 http.NotFound(w, r)168 return169 }170 name := path.Clean(r.PathValue("path"))171 if name == "." || name == "/" || strings.HasPrefix(name, "..") {172 http.NotFound(w, r)173 return174 }175 file, err := a.static.Open(name)176 if err != nil {177 http.NotFound(w, r)178 return179 }180 defer file.Close()181 info, err := file.Stat()182 if err != nil || info.IsDir() {183 http.NotFound(w, r)184 return185 }186 kind := mime.TypeByExtension(path.Ext(name))187 if kind == "" {188 kind = "application/octet-stream"189 }190 w.Header().Set("Content-Type", kind)191 if seeker, ok := file.(io.ReadSeeker); ok {192 // http.ServeContent handles conditional requests and ranges for the193 // static tree, which is a plain asset server and carries none of the194 // data plane's byte-compatibility duty.195 http.ServeContent(w, r, name, info.ModTime(), seeker)196 return197 }198 _, _ = io.Copy(w, file)199}