extremely basic and unstyled modals, settings provider and context

This commit is contained in:
jjspace 2025-08-08 15:01:47 -04:00
parent 2307ecbd74
commit 43b11d8066
No known key found for this signature in database
GPG Key ID: F2EE53A25EF6F396
10 changed files with 439 additions and 42 deletions

View File

@ -15,6 +15,7 @@
"build-gallery": "node scripts/buildGallery.js"
},
"dependencies": {
"@ariakit/react": "^0.4.17",
"@monaco-editor/react": "^4.7.0",
"@stratakit/bricks": "^0.3.4",
"@stratakit/foundations": "^0.2.4",
@ -27,7 +28,8 @@
"prettier": "^3.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-stay-scrolled": "^9.0.0"
"react-stay-scrolled": "^9.0.0",
"react-use": "^17.6.0"
},
"devDependencies": {
"@types/pako": "^2.0.3",

View File

@ -20,6 +20,7 @@
.metadata {
margin-left: var(--stratakit-space-x2);
color: var(--stratakit-color-text-accent-strong);
cursor: pointer;
}
.version {

View File

@ -4,6 +4,7 @@ import {
ReactNode,
RefObject,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useReducer,
@ -26,7 +27,7 @@ import {
moon,
share as shareIcon,
script,
settings,
settings as settingsIcon,
sun,
windowPopout,
} from "./icons.ts";
@ -35,8 +36,18 @@ import {
ConsoleMessageType,
ConsoleMirror,
} from "./ConsoleMirror.tsx";
import { useLocalStorage } from "./util/useLocalStorage.ts";
import { getBaseUrl } from "./util/getBaseUrl.ts";
import { SettingsModal } from "./SettingsModal.tsx";
import {
Popover,
PopoverArrow,
PopoverDescription,
PopoverDisclosure,
PopoverHeading,
PopoverProvider,
} from "@ariakit/react";
import { SettingsContext } from "./SettingsContext.ts";
import "./Popover.css";
const defaultJsCode = `import * as Cesium from "cesium";
@ -170,10 +181,7 @@ export type SandcastleAction =
| { type: "setAndRun"; code?: string; html?: string };
function App() {
const [theme, setTheme] = useLocalStorage<"dark" | "light">(
"sandcastle/theme",
"dark",
);
const { settings, updateSettings } = useContext(SettingsContext);
const rightSideRef = useRef<RightSideRef>(null);
const consoleCollapsedHeight = 26;
const [consoleExpanded, setConsoleExpanded] = useState(false);
@ -185,7 +193,10 @@ function App() {
const [leftPanel, setLeftPanel] = useState<"editor" | "gallery">(
startOnEditor ? "editor" : "gallery",
);
const [settingsOpen, setSettingsOpen] = useState(false);
const [title, setTitle] = useState("New Sandcastle");
const [description, setDescription] = useState("");
// This is used to avoid a "double render" when loading from the URL
const [readyForViewer, setReadyForViewer] = useState(false);
@ -282,6 +293,7 @@ function App() {
window.history.pushState({}, "", getBaseUrl());
setTitle("New Sandcastle");
setDescription("");
}
function share() {
@ -335,6 +347,7 @@ function App() {
html: html,
});
setTitle(galleryItem.title);
setDescription(galleryItem.description);
setReadyForViewer(true);
},
[galleryItems],
@ -419,21 +432,31 @@ function App() {
id="root"
className="sandcastle-root"
density="dense"
colorScheme={theme}
colorScheme={settings.theme}
synchronizeColorScheme
>
<header className="header">
<a className="logo" href={getBaseUrl()}>
<img
src={
theme === "dark"
settings.theme === "dark"
? "./images/Cesium_Logo_overlay.png"
: "./images/Cesium_Logo_Color_Overlay.png"
}
style={{ width: "118px" }}
/>
</a>
<div className="metadata">{title}</div>
<PopoverProvider>
<PopoverDisclosure
render={<div className="metadata">{title}</div>}
disabled={false}
/>
<Popover className="popover">
<PopoverArrow className="arrow" />
<PopoverHeading className="heading">{title}</PopoverHeading>
<PopoverDescription>{description}</PopoverDescription>
</Popover>
</PopoverProvider>
<Button tone="accent" onClick={() => share()}>
<Icon href={shareIcon} /> Share
</Button>
@ -475,20 +498,30 @@ function App() {
<div className="flex-spacer"></div>
<Divider />
<AppBarButton
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
onClick={() =>
updateSettings({
theme: settings.theme === "dark" ? "light" : "dark",
})
}
label="Toggle Theme"
>
<Icon href={theme === "dark" ? moon : sun} size="large" />
<Icon href={settings.theme === "dark" ? moon : sun} size="large" />
</AppBarButton>
<AppBarButton label="Settings" onClick={() => {}}>
<Icon href={settings} size="large" />
<AppBarButton
label="Settings"
onClick={() => {
setSettingsOpen(true);
}}
>
<Icon href={settingsIcon} size="large" />
</AppBarButton>
<SettingsModal open={settingsOpen} setOpen={setSettingsOpen} />
</div>
<Allotment defaultSizes={[40, 60]}>
<Allotment.Pane minSize={400} className="left-panel">
{leftPanel === "editor" && (
<SandcastleEditor
darkTheme={theme === "dark"}
darkTheme={settings.theme === "dark"}
onJsChange={(value: string = "") =>
dispatch({ type: "setCode", code: value })
}

View File

@ -0,0 +1,129 @@
.button {
--border: rgb(0 0 0/13%);
--highlight: rgb(255 255 255/20%);
--shadow: rgb(0 0 0/10%);
display: flex;
height: 2.5rem;
user-select: none;
align-items: center;
justify-content: center;
gap: 0.25rem;
white-space: nowrap;
border-radius: 0.5rem;
border-style: none;
background-color: white;
padding-left: 1rem;
padding-right: 1rem;
font-size: 1rem;
line-height: 1.5rem;
color: black;
text-decoration-line: none;
outline-width: 2px;
outline-offset: 2px;
outline-color: hsl(204 100% 40%);
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 2px 0 var(--highlight),
inset 0 -1px 0 var(--shadow),
0 1px 1px var(--shadow);
font-weight: 500;
}
.button:where(.dark, .dark *) {
--border: rgb(255 255 255/10%);
--highlight: rgb(255 255 255/5%);
--shadow: rgb(0 0 0/25%);
background-color: rgb(255 255 255 / 0.05);
color: white;
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 -1px 0 1px var(--shadow),
inset 0 1px 0 var(--highlight);
}
.button:not(:active):hover {
--border: rgb(0 0 0/33%);
}
.button:where(.dark, .dark *):not(:active):hover {
--border: rgb(255 255 255/25%);
}
.button[aria-disabled="true"] {
opacity: 0.5;
}
.button[data-focus-visible] {
outline-style: solid;
}
.button:active,
.button[data-active] {
padding-top: 0.125rem;
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 2px 0 var(--border);
}
@media (min-width: 640px) {
.button {
gap: 0.5rem;
}
}
.button:active:where(.dark, .dark *),
.button[data-active]:where(.dark, .dark *) {
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 1px 1px 1px var(--shadow);
}
.popover {
z-index: 50;
display: flex;
max-width: min(calc(100vw - 16px), 320px);
flex-direction: column;
gap: 1rem;
border-radius: 0.5rem;
border-width: 1px;
border-style: solid;
border-color: hsl(204 20% 88%);
background-color: white;
padding: 1rem;
color: black;
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.popover:focus-visible,
.popover[data-focus-visible] {
outline: 2px solid hsl(204 100% 40%);
outline-offset: 2px;
}
.popover:where(.dark, .dark *) {
border-color: hsl(204 4% 24%);
background-color: hsl(204 4% 16%);
color: white;
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.25),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.arrow > svg {
fill: white;
stroke: hsl(204 20% 88%);
}
.arrow:where(.dark, .dark *) > svg {
fill: hsl(204 4% 16%);
stroke: hsl(204 4% 24%);
}
.heading {
margin: 0px;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 500;
}

View File

@ -0,0 +1,19 @@
import { createContext } from "react";
export type Settings = {
theme: "dark" | "light";
fontSize: "large" | "small";
};
export const initialSettings: Settings = {
theme: "dark",
fontSize: "large",
};
export const SettingsContext = createContext<{
settings: Settings;
updateSettings: (newSettings: Partial<Settings>) => void;
}>({
settings: initialSettings,
updateSettings: () => {},
});

View File

@ -0,0 +1,131 @@
.button {
--border: rgb(0 0 0/13%);
--highlight: rgb(255 255 255/20%);
--shadow: rgb(0 0 0/10%);
display: flex;
height: 2.5rem;
user-select: none;
align-items: center;
justify-content: center;
gap: 0.25rem;
white-space: nowrap;
border-radius: 0.5rem;
border-style: none;
background-color: white;
padding-left: 1rem;
padding-right: 1rem;
font-size: 1rem;
line-height: 1.5rem;
color: black;
text-decoration-line: none;
outline-width: 2px;
outline-offset: 2px;
outline-color: hsl(204 100% 40%);
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 2px 0 var(--highlight),
inset 0 -1px 0 var(--shadow),
0 1px 1px var(--shadow);
font-weight: 500;
}
.button:where(.dark, .dark *) {
--border: rgb(255 255 255/10%);
--highlight: rgb(255 255 255/5%);
--shadow: rgb(0 0 0/25%);
background-color: rgb(255 255 255 / 0.05);
color: white;
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 -1px 0 1px var(--shadow),
inset 0 1px 0 var(--highlight);
}
.button:not(:active):hover {
--border: rgb(0 0 0/33%);
}
.button:where(.dark, .dark *):not(:active):hover {
--border: rgb(255 255 255/25%);
}
.button[aria-disabled="true"] {
opacity: 0.5;
}
.button[data-focus-visible] {
outline-style: solid;
}
.button:active,
.button[data-active] {
padding-top: 0.125rem;
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 2px 0 var(--border);
}
@media (min-width: 640px) {
.button {
gap: 0.5rem;
}
}
.button:active:where(.dark, .dark *),
.button[data-active]:where(.dark, .dark *) {
box-shadow:
inset 0 0 0 1px var(--border),
inset 0 1px 1px 1px var(--shadow);
}
.dialog {
position: fixed;
inset: var(--inset);
z-index: 50;
margin: auto;
display: flex;
height: fit-content;
max-height: calc(100dvh - var(--inset) * 2);
flex-direction: column;
gap: 1rem;
overflow: auto;
border-radius: 0.75rem;
background-color: white;
padding: 1rem;
color: black;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
--inset: 0.75rem;
}
@media (min-width: 640px) {
.dialog {
top: 10vh;
bottom: 10vh;
margin-top: 0px;
max-height: 80vh;
width: 420px;
border-radius: 1rem;
padding: 1.5rem;
}
}
.dialog:where(.dark, .dark *) {
border-width: 1px;
border-style: solid;
border-color: hsl(204 4% 24%);
background-color: hsl(204 4% 16%);
color: white;
}
.heading {
margin: 0px;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
}
[data-backdrop] {
background: color(
from var(--stratakit-color-bg-elevation-emphasis) xyz x y z / 80%
);
}

View File

@ -0,0 +1,52 @@
import { Dialog, DialogDismiss, DialogHeading } from "@ariakit/react";
import "./SettingsModal.css";
import { useContext } from "react";
import { SettingsContext } from "./SettingsContext";
import { Button } from "@stratakit/bricks";
export function SettingsModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
const { settings, updateSettings } = useContext(SettingsContext);
return (
<Dialog open={open} onClose={() => setOpen(false)} className="dialog">
<DialogHeading className="heading">Success</DialogHeading>
<p className="description">
Your payment has been successfully processed. We have emailed your
receipt.
</p>
<p>Theme: {settings.theme}</p>
<Button
onClick={() => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
}}
>
Change Theme
</Button>
<p>Font size: {settings.fontSize}</p>
<Button
onClick={() => {
if (settings.fontSize === "large") {
updateSettings({ fontSize: "small" });
} else {
updateSettings({ fontSize: "large" });
}
}}
>
Change Font Size
</Button>
<div>
<DialogDismiss className="button">OK</DialogDismiss>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,53 @@
import { ReactNode, useCallback } from "react";
import { initialSettings, Settings, SettingsContext } from "./SettingsContext";
import { useLocalStorage } from "react-use";
export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, updateSettings] = useLocalStorage<Settings>(
"sandcastle/settings",
initialSettings,
{
raw: false,
serializer: (value) => {
// Build a new object so we always set only the settings we know about
return JSON.stringify({
theme: value.theme ?? initialSettings.theme,
fontSize: value.fontSize ?? initialSettings.fontSize,
});
},
deserializer: (value) => {
// This allows us to guarantee all expected values are set AND any unknown
// values are removed from the settings object
const parsedValue = JSON.parse(value);
return {
theme: parsedValue.theme ?? initialSettings.theme,
fontSize: parsedValue.fontSize ?? initialSettings.fontSize,
};
},
},
);
const mergeSettings = useCallback(
(newSettings: Partial<Settings>) => {
// allow partial updates but make sure we don't lose settings keys
// won't currently work with nested settings
updateSettings({
...initialSettings,
...settings,
...newSettings,
});
},
[updateSettings, settings],
);
return (
<SettingsContext
value={{
settings: settings ?? initialSettings,
updateSettings: mergeSettings,
}}
>
{children}
</SettingsContext>
);
}

View File

@ -1,6 +1,7 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { SettingsProvider } from "./SettingsProvider.tsx";
const host = window.location.host;
if (host.includes("localhost")) {
@ -11,6 +12,8 @@ if (host.includes("localhost")) {
createRoot(document.getElementById("app-container")!).render(
<StrictMode>
<App />
<SettingsProvider>
<App />
</SettingsProvider>
</StrictMode>,
);

View File

@ -1,26 +0,0 @@
import { useDebugValue, useEffect, useState } from "react";
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, (newValue: T) => void] {
useDebugValue(key);
const [value, setValue] = useState<T>(initialValue);
useEffect(() => {
const storedValue = window.localStorage.getItem(key);
if (storedValue) {
setValue(JSON.parse(storedValue) as T);
} else {
window.localStorage.setItem(key, JSON.stringify(initialValue));
}
}, [key, initialValue]);
function updateStorage(newValue: T) {
window.localStorage.setItem(key, JSON.stringify(newValue));
setValue(newValue);
}
return [value, updateStorage];
}