website-xp-phone 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.astro/content-assets.mjs +1 -0
  2. package/.astro/content-modules.mjs +1 -0
  3. package/.astro/content.d.ts +199 -0
  4. package/.astro/data-store.json +1 -0
  5. package/.astro/settings.json +8 -0
  6. package/.astro/types.d.ts +1 -0
  7. package/.devcontainer/devcontainer.json +23 -0
  8. package/.env.firebase.example +8 -0
  9. package/.firebaserc +5 -0
  10. package/.gitattributes +2 -0
  11. package/.github/copilot-instructions.md +131 -0
  12. package/.github/dependabot.yml +11 -0
  13. package/.github/workflows/ci.yml +45 -0
  14. package/.github/workflows/deploy-admin.yml +48 -0
  15. package/.github/workflows/static.yml +43 -0
  16. package/.gitmodules +5 -0
  17. package/FIREBASE_SETUP.md +69 -0
  18. package/README.md +63 -0
  19. package/SECURITY.md +11 -0
  20. package/admin/Admin.csproj +7 -0
  21. package/admin/Dockerfile +14 -0
  22. package/admin/Program.cs +8 -0
  23. package/deploy-admin-cloud-run.md +229 -0
  24. package/eslint.config.js +28 -0
  25. package/firebase.json +5 -0
  26. package/firestore.rules +29 -0
  27. package/index.html +52 -0
  28. package/package.json +48 -0
  29. package/pagerts_output.json +1 -0
  30. package/public/5.html +967 -0
  31. package/public/BAHNSCHRIFT.TTF +0 -0
  32. package/public/Beep.ogg +0 -0
  33. package/public/Clippy.png +0 -0
  34. package/public/Layered Network Security Model for Home Networks (slides).pdf +0 -0
  35. package/public/Layered Network Security Model for Home Networks.pdf +0 -0
  36. package/public/TODO.pdf +0 -0
  37. package/public/WoW_Config.zip +3 -0
  38. package/public/addons/energy-swing.txt +1 -0
  39. package/public/addons/lego-yoda-death-readme.txt +11 -0
  40. package/public/addons/lego-yoda-death.mp3 +0 -0
  41. package/public/addons/mana-blast.txt +1 -0
  42. package/public/addons/rage-volley.txt +1 -0
  43. package/public/addons/rueg-cell.txt +1 -0
  44. package/public/addons/rueg-elvui-profile.txt +1 -0
  45. package/public/addons/rueg-grid2.txt +214 -0
  46. package/public/addons/rueg-plater-smol.txt +1 -0
  47. package/public/addons/rueg-plater.txt +1 -0
  48. package/public/addons/rueg-wa-druid.txt +1 -0
  49. package/public/addons/rueg-wa-priest.txt +1 -0
  50. package/public/addons/rueg-wa-rogue.txt +1 -0
  51. package/public/addons/rueg-wa-shaman.txt +1 -0
  52. package/public/addons/rueg-wa-warrior.txt +1 -0
  53. package/public/addons/spirit-smash.txt +1 -0
  54. package/public/avatar.jpg +0 -0
  55. package/public/avatar.png +0 -0
  56. package/public/crunchy_kick.ogg +0 -0
  57. package/public/documents/resume.html +312 -0
  58. package/public/favicon.ico +0 -0
  59. package/public/images/Ateric1.png +0 -0
  60. package/public/images/Ateric2.png +0 -0
  61. package/public/images/equal1.png +0 -0
  62. package/public/images/hyperawareofwhatacatis.png +0 -0
  63. package/public/images/kogg1.png +0 -0
  64. package/public/images/kogg2.png +0 -0
  65. package/public/images/rueg1.png +0 -0
  66. package/public/images/rueg2.png +0 -0
  67. package/public/incorrect_responses.txt +126 -0
  68. package/public/loading.css +51 -0
  69. package/public/resume.pdf +0 -0
  70. package/public/robots.txt +9 -0
  71. package/public/soundcloud.json +57 -0
  72. package/public/spinner.svg +12 -0
  73. package/public/tada.wav +0 -0
  74. package/public/yooh.mp3 +0 -0
  75. package/render.yaml +5 -0
  76. package/scripts/ensure-blog-worktree.mjs +24 -0
  77. package/scripts/generate-soundcloud-json.mjs +198 -0
  78. package/scripts/git-worktree-helper.mjs +122 -0
  79. package/scripts/hoist-dev-blog-local.mjs +149 -0
  80. package/scripts/music-schema.mjs +56 -0
  81. package/scripts/publish-soundcloud-json.mjs +32 -0
  82. package/scripts/sync-music-links-from-worktree.mjs +32 -0
  83. package/src/App.tsx +1500 -0
  84. package/src/addons.json +76 -0
  85. package/src/components/Addon.tsx +223 -0
  86. package/src/components/BlogContent.tsx +103 -0
  87. package/src/components/CopyToClipboardButton.tsx +21 -0
  88. package/src/components/MenuBar.tsx +151 -0
  89. package/src/components/MenuBarWithContext.tsx +6 -0
  90. package/src/components/Modal.tsx +17 -0
  91. package/src/components/MusicContent.tsx +309 -0
  92. package/src/components/NavBarController.tsx +55 -0
  93. package/src/components/NavBarControllerWrapper.tsx +13 -0
  94. package/src/components/Page.tsx +56 -0
  95. package/src/components/SitemapContent.tsx +125 -0
  96. package/src/contacts.json +32 -0
  97. package/src/env.d.ts +13 -0
  98. package/src/lib/assistantStateMachine.ts +80 -0
  99. package/src/lib/audioOverlap.ts +99 -0
  100. package/src/lib/keyboardInputUtils.ts +182 -0
  101. package/src/lib/musicSchema.ts +85 -0
  102. package/src/lib/naggingAssistantClient.ts +241 -0
  103. package/src/lib/resumeAnalytics.ts +163 -0
  104. package/src/main.tsx +35 -0
  105. package/src/pages.json +50 -0
  106. package/src/sections.json +243 -0
  107. package/src/src+addons.zip +3 -0
  108. package/src/styles/main.css +465 -0
  109. package/src/utils/blogSecurity.ts +87 -0
  110. package/src/utils/menuItems.ts +33 -0
  111. package/src/windowing/MinimizedSections.tsx +86 -0
  112. package/src/windowing/Section.tsx +586 -0
  113. package/src/windowing/context.tsx +13 -0
  114. package/src/windowing/hooks.ts +10 -0
  115. package/src/windowing/index.ts +7 -0
  116. package/src/windowing/provider.tsx +74 -0
  117. package/src/windowing/server.ts +3 -0
  118. package/src/windowing/types.ts +33 -0
  119. package/src/windowing/utils.ts +135 -0
  120. package/tests/generate-soundcloud-json.test.mjs +63 -0
  121. package/tests/music-schema.test.mjs +53 -0
  122. package/tsconfig.json +26 -0
  123. package/vite.config.ts +304 -0
@@ -0,0 +1,586 @@
1
+ import type React from "react";
2
+ import { useState, useRef, useEffect } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import Markdown, { type Options as ReactMarkdownOptions } from "react-markdown";
5
+ import rehypeRaw from "rehype-raw";
6
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
7
+ import { playLayeredAudio } from "../lib/audioOverlap";
8
+ import { useSectionContext } from "./hooks";
9
+ import type { Content, HttpUrl, SectionProps } from "./types";
10
+ import {
11
+ getRuntimeBlogPostsHost,
12
+ resolveTrustedBlogAssetUrl,
13
+ } from "../utils/blogSecurity";
14
+
15
+ const BLOG_PATH = "/blog";
16
+ const BLOG_POSTS_HOST = getRuntimeBlogPostsHost(
17
+ Boolean(import.meta.env.DEV),
18
+ typeof window !== "undefined" ? window.location.origin : undefined,
19
+ );
20
+
21
+ const isBlogPath = (pathname: string) => pathname.replace(/\/+$/, "") === BLOG_PATH;
22
+
23
+ const toPostSlug = (heading: string) =>
24
+ heading
25
+ .toLowerCase()
26
+ .trim()
27
+ .replace(/[^a-z0-9\s-]/g, "")
28
+ .replace(/\s+/g, "-")
29
+ .replace(/-+/g, "-");
30
+
31
+ const contentContainsPostSlug = (
32
+ content: Content,
33
+ targetPostSlug: string,
34
+ ): boolean => {
35
+ if (typeof content === "string") {
36
+ return false;
37
+ }
38
+
39
+ return content.some((item): boolean => {
40
+ if (typeof item === "string") {
41
+ return false;
42
+ }
43
+
44
+ if (typeof item.heading === "string" && toPostSlug(item.heading) === targetPostSlug) {
45
+ return true;
46
+ }
47
+
48
+ return !!item.content && contentContainsPostSlug(item.content as Content, targetPostSlug);
49
+ });
50
+ };
51
+
52
+ const markdownSanitizeSchema: unknown = {
53
+ ...defaultSchema,
54
+ tagNames: [...(defaultSchema.tagNames || []), "iframe"],
55
+ attributes: {
56
+ ...defaultSchema.attributes,
57
+ a: [...(defaultSchema.attributes?.a || []), ["target"], ["rel"]],
58
+ img: [...(defaultSchema.attributes?.img || []), ["loading"], ["decoding"]],
59
+ iframe: [
60
+ ["title"],
61
+ ["src"],
62
+ ["width"],
63
+ ["height"],
64
+ ["style"],
65
+ ["scrolling"],
66
+ ["loading"],
67
+ ["allow"],
68
+ ["allowfullscreen"],
69
+ ["referrerpolicy"],
70
+ ["frameborder"],
71
+ ],
72
+ },
73
+ };
74
+
75
+ const markdownRehypePlugins = [
76
+ rehypeRaw,
77
+ [rehypeSanitize, markdownSanitizeSchema],
78
+ ] as ReactMarkdownOptions["rehypePlugins"];
79
+
80
+ const markdownComponents = {
81
+ img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
82
+ <img
83
+ {...props}
84
+ style={{ maxWidth: "100%", height: "auto", maxHeight: "24rem", ...(props.style ?? {}) }}
85
+ />
86
+ ),
87
+ };
88
+
89
+ function normalizePrintoutText(printout: string | string[]): string {
90
+ return Array.isArray(printout) ? printout.join("\n") : printout;
91
+ }
92
+
93
+ function toFencedCodeBlock(content: string): string {
94
+ return `\`\`\`\n${content}\n\`\`\``;
95
+ }
96
+
97
+ function PrintoutContent({ printout }: { printout: string | string[] }) {
98
+ const [markdownContent, setMarkdownContent] = useState(() =>
99
+ toFencedCodeBlock(normalizePrintoutText(printout)),
100
+ );
101
+ const [error, setError] = useState<string | null>(null);
102
+
103
+ useEffect(() => {
104
+ let cancelled = false;
105
+
106
+ const loadPrintout = async () => {
107
+ if (typeof printout !== "string") {
108
+ if (!cancelled) {
109
+ setError(null);
110
+ setMarkdownContent(toFencedCodeBlock(normalizePrintoutText(printout)));
111
+ }
112
+ return;
113
+ }
114
+
115
+ let printoutUrl: string;
116
+ try {
117
+ printoutUrl = resolveTrustedBlogAssetUrl(printout, BLOG_POSTS_HOST);
118
+ } catch (urlError) {
119
+ const message =
120
+ urlError instanceof Error ? urlError.message : "Unknown error";
121
+ if (!cancelled) {
122
+ setError(message);
123
+ setMarkdownContent(toFencedCodeBlock(printout));
124
+ }
125
+ return;
126
+ }
127
+
128
+ try {
129
+ const response = await fetch(printoutUrl, {
130
+ method: "GET",
131
+ cache: "no-store",
132
+ headers: {
133
+ Accept: "text/plain, text/markdown, application/json",
134
+ },
135
+ });
136
+
137
+ if (!response.ok) {
138
+ throw new Error(`HTTP ${response.status}`);
139
+ }
140
+
141
+ const fileContent = await response.text();
142
+ if (!cancelled) {
143
+ setError(null);
144
+ setMarkdownContent(toFencedCodeBlock(fileContent));
145
+ }
146
+ } catch (fetchError) {
147
+ const message =
148
+ fetchError instanceof Error ? fetchError.message : "Unknown error";
149
+ if (!cancelled) {
150
+ setError(
151
+ `Failed to fetch printout '${printout}' from trusted blog host (${message}).`,
152
+ );
153
+ setMarkdownContent(toFencedCodeBlock(printout));
154
+ }
155
+ }
156
+ };
157
+
158
+ void loadPrintout();
159
+
160
+ return () => {
161
+ cancelled = true;
162
+ };
163
+ }, [printout]);
164
+
165
+ return (
166
+ <div className="debug-printout-scroll" data-debug="printout-scroll">
167
+ <Markdown>{markdownContent}</Markdown>
168
+ {error ? <p className="status-bar">{error}</p> : null}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ function renderPrintout(printout: string | string[]) {
174
+ return <PrintoutContent printout={printout} />;
175
+ }
176
+
177
+ function renderContent(content: Content, depth: number) {
178
+ if (typeof content === "string")
179
+ return (
180
+ <ul>
181
+ <li>
182
+ <Markdown
183
+ rehypePlugins={markdownRehypePlugins}
184
+ components={markdownComponents}
185
+ >
186
+ {content}
187
+ </Markdown>
188
+ </li>
189
+ </ul>
190
+ );
191
+
192
+ const groupedContent: Array<
193
+ | { type: "markdown"; key: number; text: string }
194
+ | { type: "section"; key: number; section: SectionProps }
195
+ > = [];
196
+ let bufferedLines: string[] = [];
197
+ let bufferStartIndex = 0;
198
+
199
+ const flushBufferedLines = () => {
200
+ if (bufferedLines.length === 0) {
201
+ return;
202
+ }
203
+
204
+ groupedContent.push({
205
+ type: "markdown",
206
+ key: bufferStartIndex,
207
+ text: bufferedLines.join(" \n"),
208
+ });
209
+ bufferedLines = [];
210
+ };
211
+
212
+ content.forEach((item, index) => {
213
+ if (typeof item === "string") {
214
+ if (bufferedLines.length === 0) {
215
+ bufferStartIndex = index;
216
+ }
217
+ bufferedLines.push(item);
218
+ return;
219
+ }
220
+
221
+ flushBufferedLines();
222
+ groupedContent.push({ type: "section", key: index, section: item });
223
+ });
224
+
225
+ flushBufferedLines();
226
+
227
+ return (
228
+ <ul>
229
+ {groupedContent.map((item) =>
230
+ item.type === "markdown" ? (
231
+ <li key={`markdown-${item.key}`}>
232
+ <Markdown
233
+ rehypePlugins={markdownRehypePlugins}
234
+ components={markdownComponents}
235
+ >
236
+ {item.text}
237
+ </Markdown>
238
+ </li>
239
+ ) : (
240
+ <Section key={`section-${item.key}`} {...item.section} depth={depth + 1} />
241
+ ),
242
+ )}
243
+ </ul>
244
+ );
245
+ }
246
+
247
+ function isValidHttpUrl(link: string): link is HttpUrl {
248
+ try {
249
+ const parsed = new URL(link);
250
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
251
+ } catch {
252
+ return false;
253
+ }
254
+ }
255
+
256
+ const playSound = () => {
257
+ playLayeredAudio("/crunchy_kick.ogg");
258
+ window.dispatchEvent(new CustomEvent("crunchy-kick-played"));
259
+ };
260
+
261
+ export const Section = (props: SectionProps) => {
262
+ const {
263
+ heading,
264
+ content,
265
+ link,
266
+ printout,
267
+ className,
268
+ children,
269
+ depth = 0,
270
+ uuid,
271
+ } = props;
272
+ const hasHeading = !!heading;
273
+ const hasContent = !!content;
274
+ const hasLink = typeof link === "string" && link.length > 0;
275
+ const hasPrintout =
276
+ printout !== undefined &&
277
+ ((typeof printout === "string" && printout.length > 0) ||
278
+ (Array.isArray(printout) && printout.length > 0));
279
+ const isOnBlogPage =
280
+ typeof window !== "undefined" && isBlogPath(window.location.pathname);
281
+ const rawTargetPostSlug =
282
+ typeof window !== "undefined" && isOnBlogPage
283
+ ? new URLSearchParams(window.location.search).get("post") || ""
284
+ : "";
285
+ const targetPostSlug = rawTargetPostSlug ? toPostSlug(rawTargetPostSlug) : "";
286
+ const isBlogPost = depth > 0 && typeof heading === "string";
287
+ const isLinkableBlogPost = isOnBlogPage && isBlogPost;
288
+ const postSlug = isLinkableBlogPost ? toPostSlug(heading) : "";
289
+ const permalink = isLinkableBlogPost
290
+ ? `${BLOG_PATH}/?post=${encodeURIComponent(postSlug)}`
291
+ : "";
292
+ const shouldOpenFromLink =
293
+ typeof window !== "undefined" &&
294
+ isLinkableBlogPost &&
295
+ targetPostSlug === postSlug;
296
+ const shouldRevealLinkedPost =
297
+ isOnBlogPage &&
298
+ !!targetPostSlug &&
299
+ !!content &&
300
+ contentContainsPostSlug(content as Content, targetPostSlug);
301
+ const isForcedExpanded = shouldOpenFromLink || shouldRevealLinkedPost || hasPrintout;
302
+
303
+ const [isMaximized, setIsMaximized] = useState(false);
304
+ const [isCollapsed, setIsCollapsed] = useState(!isForcedExpanded);
305
+ const isCollapsedResolved = isForcedExpanded ? false : isCollapsed;
306
+ const [position, setPosition] = useState({ x: 0, y: 0 });
307
+ const [isDragging, setIsDragging] = useState(false);
308
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
309
+ const windowRef = useRef<HTMLDivElement>(null);
310
+ const inlineWindowRef = useRef<HTMLDivElement>(null);
311
+ const { markAsExpanded, minimizeSection, minimizedSections, restoreSection } =
312
+ useSectionContext();
313
+
314
+ // UUID must be provided from server-side processing
315
+ if (!uuid) {
316
+ // UUID must be provided from server-side processing; fall back silently.
317
+ }
318
+ const sectionUUID = uuid || `fallback-${heading}-${depth}`;
319
+ const isMinimized = minimizedSections.has(sectionUUID);
320
+
321
+ const clearPostSlugFromUrl = () => {
322
+ if (typeof window === "undefined" || !isOnBlogPage || !isLinkableBlogPost) {
323
+ return;
324
+ }
325
+
326
+ const params = new URLSearchParams(window.location.search);
327
+ if (params.get("post") !== postSlug) {
328
+ return;
329
+ }
330
+
331
+ params.delete("post");
332
+ const search = params.toString();
333
+ const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
334
+ window.history.replaceState({}, "", nextUrl);
335
+ };
336
+
337
+ const minimizePoppedOutWindow = () => {
338
+ setIsMaximized(false);
339
+
340
+ if (heading && typeof heading === "string") {
341
+ minimizeSection(sectionUUID, heading);
342
+ }
343
+ };
344
+
345
+ const closePoppedOutWindow = () => {
346
+ setIsMaximized(false);
347
+ clearPostSlugFromUrl();
348
+ // Closing should not leave an entry in the minimized windows menu.
349
+ restoreSection(sectionUUID);
350
+ };
351
+
352
+
353
+
354
+ const handleMinimize = () => {
355
+ if (heading && typeof heading === "string") {
356
+ // Close maximized window before minimizing
357
+ if (isMaximized) {
358
+ minimizePoppedOutWindow();
359
+ return;
360
+ }
361
+
362
+ minimizeSection(sectionUUID, heading);
363
+ }
364
+ };
365
+
366
+ const handleMaximize = () => {
367
+ setIsMaximized(!isMaximized);
368
+ };
369
+
370
+ const handleClose = () => {
371
+ if (isMaximized) {
372
+ closePoppedOutWindow();
373
+ } else {
374
+ playSound();
375
+ }
376
+ };
377
+
378
+ const handleExpand = () => {
379
+ setIsCollapsed(false);
380
+ if (heading && typeof heading === "string") {
381
+ markAsExpanded(heading);
382
+ }
383
+ };
384
+
385
+ const handlePrimaryAction = () => {
386
+ if (hasLink && isValidHttpUrl(link)) {
387
+ window.location.assign(link);
388
+ return;
389
+ }
390
+
391
+ handleExpand();
392
+ };
393
+
394
+ const handleMouseDown = (e: React.MouseEvent) => {
395
+ if ((e.target as HTMLElement).closest(".title-bar-controls")) {
396
+ return; // Don't drag when clicking window controls
397
+ }
398
+ setIsDragging(true);
399
+ setDragOffset({
400
+ x: e.clientX - position.x,
401
+ y: e.clientY - position.y,
402
+ });
403
+ };
404
+
405
+ useEffect(() => {
406
+ const handleMouseMove = (e: MouseEvent) => {
407
+ if (isDragging && isMaximized) {
408
+ setPosition({
409
+ x: e.clientX - dragOffset.x,
410
+ y: e.clientY - dragOffset.y,
411
+ });
412
+ }
413
+ };
414
+
415
+ const handleMouseUp = () => {
416
+ setIsDragging(false);
417
+ };
418
+
419
+ if (isDragging) {
420
+ window.addEventListener("mousemove", handleMouseMove);
421
+ window.addEventListener("mouseup", handleMouseUp);
422
+ }
423
+
424
+ return () => {
425
+ window.removeEventListener("mousemove", handleMouseMove);
426
+ window.removeEventListener("mouseup", handleMouseUp);
427
+ };
428
+ }, [isDragging, dragOffset, isMaximized]);
429
+
430
+ // Center the window when first maximized
431
+ useEffect(() => {
432
+ if (
433
+ isMaximized &&
434
+ windowRef.current &&
435
+ position.x === 0 &&
436
+ position.y === 0
437
+ ) {
438
+ const rect = windowRef.current.getBoundingClientRect();
439
+ setPosition({
440
+ x: (window.innerWidth - rect.width) / 2,
441
+ y: (window.innerHeight - rect.height) / 2,
442
+ });
443
+ }
444
+ }, [isMaximized, position.x, position.y]);
445
+
446
+ useEffect(() => {
447
+ if (!shouldOpenFromLink || typeof window === "undefined") {
448
+ return;
449
+ }
450
+
451
+ inlineWindowRef.current?.scrollIntoView({
452
+ behavior: "smooth",
453
+ block: "start",
454
+ });
455
+ }, [shouldOpenFromLink]);
456
+
457
+ const windowContent = (
458
+ <div className={`window ${className || ""}`}>
459
+ {hasHeading ? (
460
+ <div className="title-bar">
461
+ <div className="title-bar-text">{heading}</div>
462
+ <div className="title-bar-controls">
463
+ <button aria-label="Minimize" onClick={handleMinimize}></button>
464
+ {depth !== 0 && (
465
+ <button aria-label="Maximize" onClick={handleMaximize}></button>
466
+ )}
467
+ <button aria-label="Close" onClick={handleClose}></button>
468
+ </div>
469
+ </div>
470
+ ) : null}
471
+ <div className="window-body">
472
+ {isCollapsedResolved ? (
473
+ <div style={{ display: "flex", justifyContent: "flex-end" }}>
474
+ <button onClick={handlePrimaryAction}>OK</button>
475
+ </div>
476
+ ) : (
477
+ <>
478
+ {hasPrintout ? renderPrintout(printout) : null}
479
+ {hasContent ? renderContent(content, depth) : null}
480
+ {children}
481
+ </>
482
+ )}
483
+ </div>
484
+ </div>
485
+ );
486
+
487
+ return (
488
+ <>
489
+ {!isMinimized && !isMaximized && (
490
+ <div ref={inlineWindowRef}>{windowContent}</div>
491
+ )}
492
+ {!isMinimized &&
493
+ isMaximized &&
494
+ typeof document !== "undefined" &&
495
+ createPortal(
496
+ <div
497
+ style={{
498
+ position: "fixed",
499
+ top: "var(--menu-bar-height, 24px)",
500
+ left: 0,
501
+ right: 0,
502
+ bottom: 0,
503
+ background: "rgba(0, 0, 0, 0.3)",
504
+ zIndex: 9998,
505
+ display: "flex",
506
+ alignItems: "center",
507
+ justifyContent: "center",
508
+ }}
509
+ onClick={(e) => {
510
+ // Close when clicking backdrop
511
+ if (e.target === e.currentTarget) {
512
+ setIsMaximized(false);
513
+ }
514
+ }}
515
+ >
516
+ <div
517
+ ref={windowRef}
518
+ style={{
519
+ position: "fixed",
520
+ left: `${position.x}px`,
521
+ top: `${position.y}px`,
522
+ zIndex: 9999,
523
+ maxWidth: "90vw",
524
+ maxHeight: "90vh",
525
+ cursor: isDragging ? "grabbing" : "default",
526
+ }}
527
+ >
528
+ <div
529
+ className={`window ${className || ""}`}
530
+ style={{
531
+ cursor: "default",
532
+ maxWidth: "100%",
533
+ maxHeight: "100%",
534
+ boxSizing: "border-box",
535
+ }}
536
+ >
537
+ {hasHeading ? (
538
+ <div
539
+ className="title-bar"
540
+ style={{ cursor: "grab" }}
541
+ onMouseDown={handleMouseDown}
542
+ >
543
+ <div className="title-bar-text">{heading}</div>
544
+ <div className="title-bar-controls">
545
+ <button
546
+ aria-label="Minimize"
547
+ onClick={handleMinimize}
548
+ ></button>
549
+ <button
550
+ aria-label="Maximize"
551
+ onClick={handleMaximize}
552
+ ></button>
553
+ <button aria-label="Close" onClick={handleClose}></button>
554
+ </div>
555
+ </div>
556
+ ) : null}
557
+ <div className="window-body" style={{ overflow: "auto" }}>
558
+ {isLinkableBlogPost ? (
559
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "8px" }}>
560
+ <a href={permalink}>
561
+ <button>Permalink</button>
562
+ </a>
563
+ </div>
564
+ ) : null}
565
+ {isCollapsedResolved ? (
566
+ <div
567
+ style={{ display: "flex", justifyContent: "flex-end" }}
568
+ >
569
+ <button onClick={handlePrimaryAction}>OK</button>
570
+ </div>
571
+ ) : (
572
+ <>
573
+ {hasPrintout ? renderPrintout(printout) : null}
574
+ {hasContent ? renderContent(content, depth) : null}
575
+ {children}
576
+ </>
577
+ )}
578
+ </div>
579
+ </div>
580
+ </div>
581
+ </div>,
582
+ document.body,
583
+ )}
584
+ </>
585
+ );
586
+ };
@@ -0,0 +1,13 @@
1
+ import { createContext } from "react";
2
+ import type { PageMetadata } from "./types";
3
+
4
+ export type SectionContextType = {
5
+ expandedSections: Set<string>;
6
+ markAsExpanded: (heading: string) => void;
7
+ minimizedSections: Map<string, string>; // Map<uuid, heading>
8
+ minimizeSection: (uuid: string, heading: string) => void;
9
+ restoreSection: (uuid: string) => void;
10
+ pageMetadata: PageMetadata;
11
+ };
12
+
13
+ export const SectionContext = createContext<SectionContextType | undefined>(undefined);
@@ -0,0 +1,10 @@
1
+ import { useContext } from "react";
2
+ import { SectionContext } from "./context";
3
+
4
+ export const useSectionContext = () => {
5
+ const context = useContext(SectionContext);
6
+ if (!context) {
7
+ throw new Error("useSectionContext must be used within a SectionProvider");
8
+ }
9
+ return context;
10
+ };
@@ -0,0 +1,7 @@
1
+ // Main exports for the windowing subsystem (client-safe)
2
+ export { Section } from './Section';
3
+ export { SectionProvider } from './provider';
4
+ export { SectionContext, type SectionContextType } from './context';
5
+ export { useSectionContext } from './hooks';
6
+ export { MinimizedSections } from './MinimizedSections';
7
+ export type { SectionProps, Content, Heading, PageMetadata, SectionMetadata, ContentWithUUID } from './types';
@@ -0,0 +1,74 @@
1
+ import {
2
+ useState,
3
+ type ReactNode,
4
+ } from "react";
5
+ import { SectionContext } from "./context";
6
+ import type { PageMetadata, SectionMetadata } from "./types";
7
+
8
+ const getAncestorSectionUuids = (
9
+ uuid: string,
10
+ sectionMetadata: SectionMetadata[],
11
+ ): string[] => {
12
+ const sectionIndex = sectionMetadata.findIndex(
13
+ (section) => section.uuid === uuid,
14
+ );
15
+
16
+ if (sectionIndex === -1) {
17
+ return [];
18
+ }
19
+
20
+ const currentSection = sectionMetadata[sectionIndex];
21
+ if (!currentSection) {
22
+ return [];
23
+ }
24
+
25
+ let expectedDepth = currentSection.depth - 1;
26
+ const ancestors: string[] = [];
27
+
28
+ for (let i = sectionIndex - 1; i >= 0 && expectedDepth >= 0; i--) {
29
+ const candidate = sectionMetadata[i];
30
+ if (candidate && candidate.depth === expectedDepth) {
31
+ ancestors.push(candidate.uuid);
32
+ expectedDepth -= 1;
33
+ }
34
+ }
35
+
36
+ return ancestors;
37
+ };
38
+
39
+ export const SectionProvider = ({ children, pageMetadata }: { children: ReactNode; pageMetadata: PageMetadata }) => {
40
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(
41
+ new Set()
42
+ );
43
+ const [minimizedSections, setMinimizedSections] = useState<Map<string, string>>(
44
+ new Map()
45
+ );
46
+
47
+ const markAsExpanded = (heading: string) => {
48
+ setExpandedSections((prev) => new Set(prev).add(heading));
49
+ };
50
+
51
+ const minimizeSection = (uuid: string, heading: string) => {
52
+ setMinimizedSections((prev) => new Map(prev).set(uuid, heading));
53
+ };
54
+
55
+ const restoreSection = (uuid: string) => {
56
+ setMinimizedSections((prev) => {
57
+ const newMap = new Map(prev);
58
+ const ancestorUuids = getAncestorSectionUuids(uuid, pageMetadata.sections);
59
+
60
+ newMap.delete(uuid);
61
+ ancestorUuids.forEach((ancestorUuid) => {
62
+ newMap.delete(ancestorUuid);
63
+ });
64
+
65
+ return newMap;
66
+ });
67
+ };
68
+
69
+ return (
70
+ <SectionContext.Provider value={{ expandedSections, markAsExpanded, minimizedSections, minimizeSection, restoreSection, pageMetadata }}>
71
+ {children}
72
+ </SectionContext.Provider>
73
+ );
74
+ };
@@ -0,0 +1,3 @@
1
+ // Server-only exports (for .astro files)
2
+ // DO NOT import this in React components
3
+ export { processContent } from './utils';