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.
- package/.astro/content-assets.mjs +1 -0
- package/.astro/content-modules.mjs +1 -0
- package/.astro/content.d.ts +199 -0
- package/.astro/data-store.json +1 -0
- package/.astro/settings.json +8 -0
- package/.astro/types.d.ts +1 -0
- package/.devcontainer/devcontainer.json +23 -0
- package/.env.firebase.example +8 -0
- package/.firebaserc +5 -0
- package/.gitattributes +2 -0
- package/.github/copilot-instructions.md +131 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/deploy-admin.yml +48 -0
- package/.github/workflows/static.yml +43 -0
- package/.gitmodules +5 -0
- package/FIREBASE_SETUP.md +69 -0
- package/README.md +63 -0
- package/SECURITY.md +11 -0
- package/admin/Admin.csproj +7 -0
- package/admin/Dockerfile +14 -0
- package/admin/Program.cs +8 -0
- package/deploy-admin-cloud-run.md +229 -0
- package/eslint.config.js +28 -0
- package/firebase.json +5 -0
- package/firestore.rules +29 -0
- package/index.html +52 -0
- package/package.json +48 -0
- package/pagerts_output.json +1 -0
- package/public/5.html +967 -0
- package/public/BAHNSCHRIFT.TTF +0 -0
- package/public/Beep.ogg +0 -0
- package/public/Clippy.png +0 -0
- package/public/Layered Network Security Model for Home Networks (slides).pdf +0 -0
- package/public/Layered Network Security Model for Home Networks.pdf +0 -0
- package/public/TODO.pdf +0 -0
- package/public/WoW_Config.zip +3 -0
- package/public/addons/energy-swing.txt +1 -0
- package/public/addons/lego-yoda-death-readme.txt +11 -0
- package/public/addons/lego-yoda-death.mp3 +0 -0
- package/public/addons/mana-blast.txt +1 -0
- package/public/addons/rage-volley.txt +1 -0
- package/public/addons/rueg-cell.txt +1 -0
- package/public/addons/rueg-elvui-profile.txt +1 -0
- package/public/addons/rueg-grid2.txt +214 -0
- package/public/addons/rueg-plater-smol.txt +1 -0
- package/public/addons/rueg-plater.txt +1 -0
- package/public/addons/rueg-wa-druid.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -0
- package/public/addons/rueg-wa-rogue.txt +1 -0
- package/public/addons/rueg-wa-shaman.txt +1 -0
- package/public/addons/rueg-wa-warrior.txt +1 -0
- package/public/addons/spirit-smash.txt +1 -0
- package/public/avatar.jpg +0 -0
- package/public/avatar.png +0 -0
- package/public/crunchy_kick.ogg +0 -0
- package/public/documents/resume.html +312 -0
- package/public/favicon.ico +0 -0
- package/public/images/Ateric1.png +0 -0
- package/public/images/Ateric2.png +0 -0
- package/public/images/equal1.png +0 -0
- package/public/images/hyperawareofwhatacatis.png +0 -0
- package/public/images/kogg1.png +0 -0
- package/public/images/kogg2.png +0 -0
- package/public/images/rueg1.png +0 -0
- package/public/images/rueg2.png +0 -0
- package/public/incorrect_responses.txt +126 -0
- package/public/loading.css +51 -0
- package/public/resume.pdf +0 -0
- package/public/robots.txt +9 -0
- package/public/soundcloud.json +57 -0
- package/public/spinner.svg +12 -0
- package/public/tada.wav +0 -0
- package/public/yooh.mp3 +0 -0
- package/render.yaml +5 -0
- package/scripts/ensure-blog-worktree.mjs +24 -0
- package/scripts/generate-soundcloud-json.mjs +198 -0
- package/scripts/git-worktree-helper.mjs +122 -0
- package/scripts/hoist-dev-blog-local.mjs +149 -0
- package/scripts/music-schema.mjs +56 -0
- package/scripts/publish-soundcloud-json.mjs +32 -0
- package/scripts/sync-music-links-from-worktree.mjs +32 -0
- package/src/App.tsx +1500 -0
- package/src/addons.json +76 -0
- package/src/components/Addon.tsx +223 -0
- package/src/components/BlogContent.tsx +103 -0
- package/src/components/CopyToClipboardButton.tsx +21 -0
- package/src/components/MenuBar.tsx +151 -0
- package/src/components/MenuBarWithContext.tsx +6 -0
- package/src/components/Modal.tsx +17 -0
- package/src/components/MusicContent.tsx +309 -0
- package/src/components/NavBarController.tsx +55 -0
- package/src/components/NavBarControllerWrapper.tsx +13 -0
- package/src/components/Page.tsx +56 -0
- package/src/components/SitemapContent.tsx +125 -0
- package/src/contacts.json +32 -0
- package/src/env.d.ts +13 -0
- package/src/lib/assistantStateMachine.ts +80 -0
- package/src/lib/audioOverlap.ts +99 -0
- package/src/lib/keyboardInputUtils.ts +182 -0
- package/src/lib/musicSchema.ts +85 -0
- package/src/lib/naggingAssistantClient.ts +241 -0
- package/src/lib/resumeAnalytics.ts +163 -0
- package/src/main.tsx +35 -0
- package/src/pages.json +50 -0
- package/src/sections.json +243 -0
- package/src/src+addons.zip +3 -0
- package/src/styles/main.css +465 -0
- package/src/utils/blogSecurity.ts +87 -0
- package/src/utils/menuItems.ts +33 -0
- package/src/windowing/MinimizedSections.tsx +86 -0
- package/src/windowing/Section.tsx +586 -0
- package/src/windowing/context.tsx +13 -0
- package/src/windowing/hooks.ts +10 -0
- package/src/windowing/index.ts +7 -0
- package/src/windowing/provider.tsx +74 -0
- package/src/windowing/server.ts +3 -0
- package/src/windowing/types.ts +33 -0
- package/src/windowing/utils.ts +135 -0
- package/tests/generate-soundcloud-json.test.mjs +63 -0
- package/tests/music-schema.test.mjs +53 -0
- package/tsconfig.json +26 -0
- 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
|
+
};
|