1package app23import (4 "io"5 "log/slog"6 "mime"7 "net/http"8 "path"9 "strings"1011 "github.com/FelineStateMachine/atlas/format/vnext"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 info := volume.Info()35 session := a.session(info.Slug)36 world := session.World37 if _, ok := worldEntry(info, world); !ok {38 world = info.Worlds[0].Slug39 }40 http.Redirect(w, r, "/v/"+info.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 info := volume.Info()63 world := r.URL.Query().Get("world")64 if _, ok := worldEntry(info, world); !ok {65 world = a.session(info.Slug).World66 }67 if _, ok := worldEntry(info, world); !ok {68 world = info.Worlds[0].Slug69 }70 http.Redirect(w, r, "/v/"+info.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 vnext.ValidSlug(slug) != nil || vnext.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 info := volume.Info()88 if _, ok := worldEntry(info, 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 = vnext.ShortStamp(info.Stamp)121 if volume.DemandAddressedFeatures() {122 a.arrangePageZero(volume, &session)123 } else {124 a.arrange(volume, &session)125 }126 if err := a.saveSession(&session); err != nil {127 slog.Warn("the session could not be written", logging.Op("session"),128 logging.Volume(slug), slog.Any("error", err))129 }130 if err := a.rememberVolume(slug); err != nil {131 slog.Warn("the last volume could not be remembered", logging.Op("session"),132 logging.Volume(slug), slog.Any("error", err))133 }134135 var view View136 if volume.DemandAddressedFeatures() {137 view = a.viewPageZero(held, volume, session)138 } else {139 view = a.view(held, volume, session)140 }141 a.writePage(w, "shell", view)142}143144// handleDetail answers with one feature's card. The card is a fragment rather145// than a partial set: it is fetched for a target that asked for it, not pushed146// at regions an interaction moved.147func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) {148 slug := r.URL.Query().Get("volume")149 if vnext.ValidSlug(slug) != nil {150 http.Error(w, "the fragment names no volume", http.StatusBadRequest)151 return152 }153 held := a.library()154 volume, serving := held.bySlug[slug]155 if !serving {156 http.NotFound(w, r)157 return158 }159 // A fragment is asked for by a target that wants it, so the card it160 // answers with is the one asked for rather than the one the session161 // happens to have open. The record is not written: fetching a card is162 // reading, and reading is not an arrangement.163 session := a.session(slug)164 session.Selected = r.PathValue("id")165 session.Detail.Open = session.Selected != ""166 view := a.view(held, volume, session)167 view.Feature = session.Selected168 a.writePage(w, "detail", view)169}170171// handleStatic serves the seam's built bundle and the stylesheet system out of172// whatever tree the host mounted. A host that mounted nothing answers 404: the173// application is required to work without the seam present, which is the174// deletability principle standing up in the build order (issue #5 §3.2).175func (a *App) handleStatic(w http.ResponseWriter, r *http.Request) {176 if a.static == nil {177 http.NotFound(w, r)178 return179 }180 name := path.Clean(r.PathValue("path"))181 if name == "." || name == "/" || strings.HasPrefix(name, "..") {182 http.NotFound(w, r)183 return184 }185 file, err := a.static.Open(name)186 if err != nil {187 http.NotFound(w, r)188 return189 }190 defer file.Close()191 info, err := file.Stat()192 if err != nil || info.IsDir() {193 http.NotFound(w, r)194 return195 }196 kind := mime.TypeByExtension(path.Ext(name))197 if kind == "" {198 kind = "application/octet-stream"199 }200 w.Header().Set("Content-Type", kind)201 if seeker, ok := file.(io.ReadSeeker); ok {202 // http.ServeContent handles conditional requests and ranges for the203 // static tree, which is a plain asset server and carries none of the204 // data plane's byte-compatibility duty.205 http.ServeContent(w, r, name, info.ModTime(), seeker)206 return207 }208 _, _ = io.Copy(w, file)209}