diff --git a/web/package-lock.json b/web/package-lock.json
index 7372409e9..ab6144ec1 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -421,6 +421,17 @@
"typed-colors": "^1.0.0",
"typed-figures": "^1.0.0",
"yargs": "^16.2.0"
+ },
+ "dependencies": {
+ "enhanced-resolve": {
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz",
+ "integrity": "sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==",
+ "requires": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ }
+ }
}
},
"@typed/fp": {
@@ -444,6 +455,13 @@
"newtype-ts": "^0.3.4",
"ts-toolbelt": "^8.0.7",
"tslib": "^2.0.3"
+ },
+ "dependencies": {
+ "json-schema": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz",
+ "integrity": "sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ=="
+ }
}
},
"@types/json-schema": {
@@ -836,15 +854,6 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
- "enhanced-resolve": {
- "version": "5.7.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz",
- "integrity": "sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==",
- "requires": {
- "graceful-fs": "^4.2.4",
- "tapable": "^2.2.0"
- }
- },
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -1047,6 +1056,11 @@
"resolved": "https://registry.npmjs.org/hyperhtml-style/-/hyperhtml-style-0.1.2.tgz",
"integrity": "sha512-ZDRYNClEaqUS0a8RAED0nQRqWmZk7ctdyij3Iw/PqUUef6xhYO87nx9vJNuxg7Yc6J2FdJjXRKbB0iud2ZyzwQ=="
},
+ "idb-keyval": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-5.0.2.tgz",
+ "integrity": "sha512-1DYjY/nX2U9pkTkwFoAmKcK1ZWmkNgO32Oon9tp/9+HURizxUQ4fZRxMJZs093SldP7q6dotVj03kIkiqOILyA=="
+ },
"ignore": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
@@ -1201,11 +1215,6 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
},
- "json-schema": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.5.tgz",
- "integrity": "sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ=="
- },
"json5": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
diff --git a/web/package.json b/web/package.json
index e5ec8e7c6..18b3cfc54 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,6 +11,7 @@
"@snowpack/plugin-postcss": "^1.1.0",
"autoprefixer": "^10.2.1",
"cross-env": "^7.0.3",
+ "idb-keyval": "^5.0.2",
"immer": "^8.0.1",
"postcss": "^8.2.2",
"postcss-cli": "^8.3.1",
diff --git a/web/src/App.jsx b/web/src/App.jsx
index aba1a4357..01923ff36 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -4,6 +4,7 @@ import AppBar from './components/AppBar';
import Camera from './Camera';
import CameraMap from './CameraMap';
import Cameras from './Cameras';
+import { DarkModeProvider } from './context';
import Debug from './Debug';
import Event from './Event';
import Events from './Events';
@@ -15,28 +16,30 @@ import Api, { FetchStatus, useConfig } from './api';
export default function App() {
const { data, status } = useConfig();
return (
-
-
- {status !== FetchStatus.LOADED ? (
-
- ) : (
-
-
-
-
-
-
-
-
-
- {import.meta.env.SNOWPACK_MODE !== 'development' ? : null}
-
-
+
+
+
+ {status !== FetchStatus.LOADED ? (
+
-
- )}
-
+ ) : (
+
+
+
+
+
+
+
+
+
+ {import.meta.env.SNOWPACK_MODE !== 'development' ? : null}
+
+
+
+
+ )}
+
+
);
}
diff --git a/web/src/Settings.jsx b/web/src/Settings.jsx
new file mode 100644
index 000000000..ef47a55aa
--- /dev/null
+++ b/web/src/Settings.jsx
@@ -0,0 +1,34 @@
+import { h } from 'preact';
+import { useDarkMode } from './context';
+import { useCallback } from 'preact/hooks';
+
+export default function Settings() {
+ const { currentMode, persistedMode, setDarkMode } = useDarkMode();
+
+ const handleSelect = useCallback(
+ (event) => {
+ const mode = event.target.value;
+ setDarkMode(mode);
+ },
+ [setDarkMode]
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/Sidebar.jsx b/web/src/Sidebar.jsx
index 61f01c70e..cd7b313fe 100644
--- a/web/src/Sidebar.jsx
+++ b/web/src/Sidebar.jsx
@@ -4,30 +4,6 @@ import LinkedLogo from './components/LinkedLogo';
import { Link as RouterLink } from 'preact-router/match';
import { useCallback, useState } from 'preact/hooks';
-function HamburgerIcon() {
- return (
-
- );
-}
-
-function CloseIcon() {
- return (
-
- );
-}
-
function NavLink({ className = '', href, text }) {
const external = href.startsWith('http');
const El = external ? Link : RouterLink;
@@ -45,30 +21,14 @@ function NavLink({ className = '', href, text }) {
}
export default function Sidebar() {
- const [open, setOpen] = useState(false);
-
- const handleToggle = useCallback(() => {
- setOpen(!open);
- }, [open, setOpen]);
-
return (
-
+
-
);
}
diff --git a/web/src/components/RelativeModal.jsx b/web/src/components/RelativeModal.jsx
index d82490650..31412cb33 100644
--- a/web/src/components/RelativeModal.jsx
+++ b/web/src/components/RelativeModal.jsx
@@ -76,13 +76,15 @@ export default function RelativeModal({ className, role = 'dialog', children, on
0 ? `width: ${position.width}px; top: ${position.top}px; left: ${position.left}px` : ''}
+ style={
+ position.width > 0 ? `min-width: ${position.width}px; top: ${position.top}px; left: ${position.left}px` : ''
+ }
>
{children}
diff --git a/web/src/context/index.jsx b/web/src/context/index.jsx
new file mode 100644
index 000000000..213177084
--- /dev/null
+++ b/web/src/context/index.jsx
@@ -0,0 +1,66 @@
+import { h, createContext } from 'preact';
+import { get as getData, set as setData } from 'idb-keyval';
+import produce from 'immer';
+import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
+
+const DarkMode = createContext(null);
+
+export function DarkModeProvider({ children }) {
+ const [persistedMode, setPersistedMode] = useState(null);
+ const [currentMode, setCurrentMode] = useState(persistedMode !== 'media' ? persistedMode : null);
+
+ const setDarkMode = useCallback(
+ (value) => {
+ setPersistedMode(value);
+ setData('darkmode', value);
+ if (value !== 'media') {
+ setCurrentMode(value);
+ }
+ },
+ [setPersistedMode]
+ );
+
+ useEffect(() => {
+ async function load() {
+ const darkmode = await getData('darkmode');
+ setDarkMode(darkmode || 'media');
+ }
+
+ load();
+ }, []);
+
+ if (persistedMode === null) {
+ return null;
+ }
+
+ const handleMediaMatch = useCallback(
+ ({ matches }) => {
+ if (matches) {
+ setCurrentMode('dark');
+ } else {
+ setCurrentMode('light');
+ }
+ },
+ [setCurrentMode]
+ );
+
+ useEffect(() => {
+ if (persistedMode !== 'media') {
+ return;
+ }
+
+ const query = window.matchMedia('(prefers-color-scheme: dark)');
+ query.addEventListener('change', handleMediaMatch);
+ handleMediaMatch(query);
+ }, [persistedMode]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useDarkMode() {
+ return useContext(DarkMode);
+}
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 455a205ca..da77e7270 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -2,7 +2,7 @@
module.exports = {
purge: ['./public/**/*.html', './src/**/*.jsx'],
- darkMode: 'media',
+ darkMode: 'class',
theme: {
extend: {},
},