oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package slides
2
3// The shared chrome (estilo chrome, round 2): every page's bar, account
4// menu and <title> are built here, once, so no page can drift from
5// another. Before this the bar was rastrillo's topbar layout: 45px
6// signed out and 51px signed in (the Account summary was taller than the
7// bar and existed only once signed in), an account menu of an address,
8// Agents and Sign out, a separate one-item Help dropdown, and a <title>
9// that never named the app.
10//
11// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
12// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
13// §10 (titles). Slides is the third adopter, after Docs and Sheets.
14//
15// Every label is a catalog key (AGENTS.md: a hardcoded English string is
16// a bug); estilo's templates carry no strings of their own.
17
18import (
19 "net/http"
20 "net/url"
21
22 "amadan.net/oficina/estilo.git/brand"
23 "amadan.net/oficina/estilo.git/chrome"
24 estilomenu "amadan.net/oficina/estilo.git/menu"
25 "amadan.net/oficina/home/connect"
26 "amadan.net/rastrillo/rastrillo"
27 "amadan.net/rastrillo/rastrillo/sessions"
28)
29
30// appName is the brand and the last part of every <title>. A proper
31// noun, the same in every locale, so not a catalog key.
32const appName = "Slides"
33
34// Frame is what layout.html reads from every page: the bar, the parts of
35// the <title> before the app's name, and the Appearance preference for
36// <html data-theme>.
37type Frame struct {
38 Chrome chrome.Bar
39 TitleParts []string
40 Theme string
41 // Bare pages have no bar at all: the presenting screens, which are
42 // capability-addressed and shown on a projector, a lectern or a
43 // phone in somebody's hand (spec §12.1, Slides). A signed-in
44 // presenter used to see "Account → Sign in" there.
45 Bare bool
46}
47
48// shell is embedded in every page's view data, so .Frame resolves on
49// every page the layout renders.
50type shell struct {
51 Frame Frame
52}
53
54// frame describes one page's chrome. The zero value is a plain page whose
55// <title> is just "Slides".
56type frame struct {
57 // parts precede "Slides" in <title>, most specific first. The first
58 // is the page's h1 (spec §10).
59 parts []string
60 // context is the open deck's title, shown in the bar on the deck's
61 // sub-pages.
62 context string
63 // public is a read-only page a stranger reaches (the share link):
64 // the brand is plain text, and "Sign in" carries no return path,
65 // because the path is a capability and must not be copied into
66 // another URL.
67 public bool
68 // signinPage is one of the sign-in, sent and signed-out pages: the
69 // corner is empty, and the brand is plain text (every page it could
70 // link to is behind the session gate).
71 signinPage bool
72 // bare drops the bar (the presenting screens).
73 bare bool
74 // ret, when set, is where Appearance returns to instead of this
75 // request's own path and query.
76 ret string
77}
78
79// titled is the frame for a page whose <title> is these parts.
80func titled(parts ...string) frame { return frame{parts: parts} }
81
82// onDeck is the frame for one of a deck's sub-pages: the deck's title is
83// the bar's context and the second title part.
84func onDeck(page, deck string) frame {
85 return frame{parts: []string{page, deck}, context: deck}
86}
87
88// viewerState is who the chrome is for: nobody, or a person and how
89// they signed in.
90type viewerState struct {
91 user User
92 method string
93 ok bool
94}
95
96// optionalViewer resolves whoever is signed in on r without requiring
97// anybody, and without side effects: an error page, the Open source page
98// and the share-link viewer show the viewer's own chrome, but must never
99// sign somebody out or create a row. An address that is no longer
100// admitted is treated as signed out here; the next guarded page is where
101// currentUser ends that session.
102func (a *app) optionalViewer(r *http.Request) viewerState {
103 s, ok := sessions.Current(r)
104 if !ok && a.sessions != nil {
105 s, ok = a.sessions.From(r)
106 }
107 if !ok || s.Subject == "" {
108 return viewerState{}
109 }
110 if s.Method == connect.HomeMethod {
111 u, err := a.homeUser(r, s)
112 if err != nil {
113 return viewerState{}
114 }
115 return viewerState{user: u, method: s.Method, ok: true}
116 }
117 if !a.allowed.allows(s.Subject) {
118 return viewerState{}
119 }
120 var u User
121 if err := a.db.G.WithContext(r.Context()).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil {
122 return viewerState{}
123 }
124 return viewerState{user: u, method: s.Method, ok: true}
125}
126
127// shellFor is the shell of a page behind the session gate: u is the
128// person currentUser resolved.
129func (a *app) shellFor(r *http.Request, u User, f frame) shell {
130 method := ""
131 if s, ok := sessions.Current(r); ok {
132 method = s.Method
133 }
134 return shell{Frame: a.frameFor(r, viewerState{user: u, method: method, ok: true}, f)}
135}
136
137// publicShell is the shell of a page reachable signed out: the viewer is
138// read optionally.
139func (a *app) publicShell(r *http.Request, f frame) shell {
140 var v viewerState
141 if !f.signinPage {
142 v = a.optionalViewer(r)
143 }
144 return shell{Frame: a.frameFor(r, v, f)}
145}
146
147// frameFor builds a page's Frame for viewer v.
148func (a *app) frameFor(r *http.Request, v viewerState, f frame) Frame {
149 bar := chrome.Bar{
150 App: chrome.App{
151 Name: appName,
152 Slug: "slides",
153 Home: "/",
154 Icon: brand.Path("slides"),
155 },
156 Context: f.context,
157 Labels: chrome.BarLabels{Skip: rastrillo.T(r, "slides.chrome.skip"), Menu: rastrillo.T(r, "slides.chrome.menu")},
158 }
159 switch {
160 case f.signinPage:
161 // The corner is empty on the pages that are themselves sign-in,
162 // and the brand is plain text: everything it could link to is
163 // behind the session gate and would bounce straight back here.
164 bar.App.Home = ""
165 case v.ok:
166 bar.Account = a.account(r, v, f.ret)
167 bar.Switcher = a.switcher(r)
168 case f.public:
169 bar.SignIn = &chrome.Link{Href: signinPath, Label: rastrillo.T(r, "slides.chrome.signin")}
170 default:
171 bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: rastrillo.T(r, "slides.chrome.signin")}
172 }
173 if f.public {
174 bar.App.Home = ""
175 }
176 return Frame{
177 Chrome: bar,
178 TitleParts: f.parts,
179 Theme: chrome.ThemeAttr(chrome.Appearance(r)),
180 Bare: f.bare,
181 }
182}
183
184// returnTarget is where a person should come back to: this page, if it
185// is a page (a GET), otherwise the deck list.
186func returnTarget(r *http.Request) string {
187 if r.Method != http.MethodGet && r.Method != http.MethodHead {
188 return "/"
189 }
190 return r.URL.RequestURI()
191}
192
193// account is the account menu (spec §8.2): who is signed in, Agents and
194// "Connect your Oficina account" when that means something, Appearance,
195// Help (Open source, last), and the one Sign out in the app.
196func (a *app) account(r *http.Request, v viewerState, ret string) *chrome.Account {
197 if ret == "" {
198 ret = returnTarget(r)
199 }
200 u := v.user
201 acc := &chrome.Account{
202 TriggerLabel: rastrillo.T(r, "slides.chrome.account"),
203 Initials: chrome.Initials(u.Name, u.Email),
204 Address: u.Email,
205 Labels: chrome.AccountLabels{
206 SignedInAs: rastrillo.T(r, "slides.chrome.signed_in_as"),
207 Appearance: rastrillo.T(r, "slides.chrome.appearance"),
208 Help: rastrillo.T(r, "slides.credits.help"),
209 },
210 Appearance: &chrome.AppearanceMenu{
211 Action: "/appearance",
212 Return: ret,
213 Options: chrome.AppearanceOptions(chrome.Appearance(r),
214 rastrillo.T(r, "slides.chrome.appearance.light"),
215 rastrillo.T(r, "slides.chrome.appearance.dark"),
216 rastrillo.T(r, "slides.chrome.appearance.system")),
217 },
218 Help: []estilomenu.Item{{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")}},
219 SignOut: chrome.SignOut{Action: "/signout", Label: rastrillo.T(r, "slides.account.signout")},
220 }
221 if u.Name != "" {
222 acc.Name = u.Name
223 }
224 if u.Email != "" {
225 acc.TriggerLabel = rastrillo.Tf(r, "slides.chrome.account.who", "address", u.Email)
226 }
227 // Agents (agents.go): the tokens that let a program drive this
228 // person's decks. A page, so a link and no ellipsis.
229 acc.Items = []estilomenu.Item{{Href: "/agents", Label: rastrillo.T(r, "slides.account.agents")}}
230 if a.offersHomeLink(v) {
231 acc.Items = append(acc.Items, estilomenu.Item{Href: "/account/home", Label: rastrillo.T(r, "slides.home.menu_action")})
232 }
233 return acc
234}
235
236// offersHomeLink is whether the account menu offers to connect the
237// person's Oficina account: only when Home is configured, and never to
238// somebody who signed in through Home, whose account already is the
239// connection (spec §12.1 item 4; before this Slides offered it to Home
240// sessions too).
241func (a *app) offersHomeLink(v viewerState) bool {
242 return a.home != nil && v.ok && v.method != connect.HomeMethod
243}
244
245// switcher is the app switcher, or nil when no hub directory is
246// configured. It shows "All apps" alone and no tiles (Paul's §13 answer
247// 2): a tile today still costs an assertion exchange through the
248// directory session.
249func (a *app) switcher(r *http.Request) *chrome.Switcher {
250 if a.directory == "" {
251 return nil
252 }
253 return &chrome.Switcher{
254 Label: rastrillo.T(r, "slides.chrome.switcher"),
255 CurrentLabel: rastrillo.T(r, "slides.chrome.switcher.current"),
256 AllApps: estilomenu.Item{Href: a.directory + "/", Label: rastrillo.T(r, "slides.chrome.switcher.all")},
257 }
258}
259