jevstrudel.git / website / src / jev / UpdateModal.jsx
UpdateModal.jsx239 lines · 8.0 KB · raw
1// Release notes, in three moments (releases.mjs). Each lists every release
2// up to the one it is about, as headings that expand, with the ones it is
3// about expanded:
4//
5// - A new deploy is waiting (src/pwa.ts fires 'jev:update-ready'): the notes
6//   newer than this page, read live from /release-notes.json, and "update
7//   now", which switches to it and reloads. "Later" leaves a pill in the
8//   corner to come back to it.
9// - The site changed since this browser last had it open (a refresh, or a
10//   return days later): the notes it has not seen, and "dismiss". The last
11//   version seen is kept in localStorage; a first visit shows nothing.
12// - The visitor asks: the page's version in the jev panel (ReleaseButton) opens
13//   every note, the newest expanded.
14//
15// Pressing "update now" records the version it updates to as seen, so the
16// reload does not show the same notes a second time.
17import { useEffect, useState } from 'react';
18import { newerThan, releaseList } from './releases.mjs';
19
20import { themeColor as ACCENT } from './site.mjs';
21const SEEN = 'jevstrudel.release-seen';
22const OPEN_NOTES = 'jev:release-notes';
23const pageRelease = () => document.querySelector('meta[name="jev-release"]')?.content ?? '';
24const isLocal = () => /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
25
26const readSeen = () => {
27  try {
28    return localStorage.getItem(SEEN);
29  } catch {
30    return null;
31  }
32};
33const writeSeen = (version) => {
34  try {
35    if (version) localStorage.setItem(SEEN, version);
36  } catch {
37    // no storage: the notes may show again next time
38  }
39};
40
41async function readReleases() {
42  try {
43    const res = await fetch('/release-notes.json', { cache: 'no-store' });
44    return res.ok ? await res.json() : [];
45  } catch {
46    return [];
47  }
48}
49
50// The releases after `seen` up to and including `current`, newest first.
51function between(all, seen, current) {
52  const to = all.findIndex((r) => r.version === current);
53  return newerThan(to === -1 ? all : all.slice(to), seen);
54}
55
56function Notes({ releases }) {
57  if (releases === null) return <p className="opacity-70">Reading the release notes…</p>;
58  if (!releases.length) return <p className="opacity-70">No release notes this time.</p>;
59  return releases.map((r) => (
60    <details key={r.version} open={r.open} className="group">
61      <summary className="cursor-pointer list-none flex items-baseline gap-2 hover:opacity-80">
62        <span className="opacity-60 w-3 inline-block group-open:rotate-90 transition-transform">▸</span>
63        <span className="font-bold">{r.title || r.version}</span>
64        <span className="opacity-50 text-xs whitespace-nowrap">{r.version}</span>
65      </summary>
66      <ul className="list-disc pl-10 mt-1 mb-2 space-y-1">
67        {r.notes.map((n, i) => (
68          <li key={i}>{n}</li>
69        ))}
70      </ul>
71    </details>
72  ));
73}
74
75// `onClose`, when given, also closes it on Escape or a click outside it.
76function Dialog({ title, subtitle, releases, onClose, children }) {
77  useEffect(() => {
78    if (!onClose) return;
79    const onKey = (e) => e.key === 'Escape' && onClose();
80    window.addEventListener('keydown', onKey);
81    return () => window.removeEventListener('keydown', onKey);
82  }, [onClose]);
83  return (
84    <div
85      className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4"
86      role="dialog"
87      aria-modal="true"
88      onClick={(e) => onClose && e.target === e.currentTarget && onClose()}
89    >
90      <div
91        className="w-full max-w-lg max-h-[80vh] overflow-auto bg-background text-foreground border rounded-lg shadow-2xl font-mono text-sm"
92        style={{ borderColor: ACCENT }}
93      >
94        <div className="px-5 pt-4 pb-2">
95          <h2 className="text-lg font-bold" style={{ color: ACCENT }}>
96            {title}
97          </h2>
98          <p className="opacity-70 text-xs mt-1">{subtitle}</p>
99        </div>
100        <div className="px-5 py-2 space-y-2">
101          <Notes releases={releases} />
102        </div>
103        <div className="flex justify-end gap-3 px-5 py-4 border-t border-muted">{children}</div>
104      </div>
105    </div>
106  );
107}
108
109const primary = 'rounded px-4 py-2 font-bold text-black disabled:opacity-60';
110
111export function UpdateModal() {
112  // a waiting deploy
113  const [ready, setReady] = useState(() => typeof window !== 'undefined' && !!window.jevUpdateReady);
114  const [open, setOpen] = useState(ready);
115  const [waiting, setWaiting] = useState(null);
116  const [updating, setUpdating] = useState(false);
117  // what changed since this browser's last visit
118  const [news, setNews] = useState(null);
119  // every note, asked for from the header (ReleaseButton)
120  const [browsing, setBrowsing] = useState(null);
121
122  useEffect(() => {
123    const current = pageRelease();
124    const seen = readSeen();
125    writeSeen(current);
126    if (!seen || !current || seen === current) return;
127    readReleases().then((all) => {
128      const unseen = between(all, seen, current);
129      if (unseen.length) setNews(releaseList(all, current, unseen));
130    });
131  }, []);
132
133  useEffect(() => {
134    const onOpen = () => {
135      setBrowsing([]);
136      readReleases().then((all) => setBrowsing(releaseList(all, pageRelease(), [])));
137    };
138    window.addEventListener(OPEN_NOTES, onOpen);
139    return () => window.removeEventListener(OPEN_NOTES, onOpen);
140  }, []);
141
142  useEffect(() => {
143    const onReady = () => {
144      setReady(true);
145      setOpen(true);
146    };
147    window.addEventListener('jev:update-ready', onReady);
148    return () => window.removeEventListener('jev:update-ready', onReady);
149  }, []);
150
151  useEffect(() => {
152    if (ready) readReleases().then((all) => setWaiting(releaseList(all, null, newerThan(all, pageRelease()))));
153  }, [ready]);
154
155  if (ready && open) {
156    const update = () => {
157      setUpdating(true);
158      writeSeen(waiting?.find((r) => r.open)?.version);
159      window.jevUpdate?.();
160    };
161    return (
162      <Dialog
163        title="A new jevstrudel is out"
164        subtitle="Update now to get it. Your code and settings stay as they are."
165        releases={waiting}
166      >
167        <button className="opacity-70 hover:opacity-100" onClick={() => setOpen(false)}>
168          later
169        </button>
170        <button autoFocus className={primary} style={{ background: ACCENT }} onClick={update} disabled={updating}>
171          {updating ? 'updating…' : 'update now'}
172        </button>
173      </Dialog>
174    );
175  }
176
177  if (news) {
178    return (
179      <Dialog
180        title="What's new in jevstrudel"
181        subtitle="Since you were last here."
182        releases={news}
183        onClose={() => setNews(null)}
184      >
185        <button autoFocus className={primary} style={{ background: ACCENT }} onClick={() => setNews(null)}>
186          dismiss
187        </button>
188      </Dialog>
189    );
190  }
191
192  if (browsing) {
193    const current = pageRelease();
194    return (
195      <Dialog
196        title="Release notes"
197        subtitle={current ? `This page is jevstrudel ${current}${isLocal() ? ', running locally' : ''}.` : 'Every release so far.'}
198        releases={browsing.length ? browsing : null}
199        onClose={() => setBrowsing(null)}
200      >
201        <button autoFocus className={primary} style={{ background: ACCENT }} onClick={() => setBrowsing(null)}>
202          close
203        </button>
204      </Dialog>
205    );
206  }
207
208  if (ready) {
209    return (
210      <button
211        className="fixed bottom-4 right-4 z-50 font-mono text-sm rounded px-3 py-2 shadow-lg text-black"
212        style={{ background: ACCENT }}
213        onClick={() => setOpen(true)}
214      >
215        ⬆ update ready
216      </button>
217    );
218  }
219  return null;
220}
221
222// The page's version, at the foot of the jev panel: it opens the release notes. Locally
223// it says so, since the page may be ahead of its newest note.
224export function ReleaseButton() {
225  const [version, setVersion] = useState('');
226  useEffect(() => setVersion(pageRelease() + (isLocal() ? ' dev' : '')), []);
227  if (!version) return null;
228  return (
229    <button
230      type="button"
231      className="hover:opacity-50 whitespace-nowrap"
232      title="Release notes"
233      data-testid="jev-release-button"
234      onClick={() => window.dispatchEvent(new Event(OPEN_NOTES))}
235    >
236      ({version})
237    </button>
238  );
239}