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}