diff --git a/apps/app/eslint.config.js b/apps/app/eslint.config.js
index 758dc53..03d6c30 100644
--- a/apps/app/eslint.config.js
+++ b/apps/app/eslint.config.js
@@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config(
- {ignores: ['dist']},
+ {ignores: ['dist', 'public']},
js.configs.recommended,
...tseslint.configs.recommended,
jsxA11y.flatConfigs.recommended,
diff --git a/apps/app/index.html b/apps/app/index.html
index fdc3238..70103b9 100644
--- a/apps/app/index.html
+++ b/apps/app/index.html
@@ -9,6 +9,7 @@
kwatch
+
diff --git a/apps/app/public/theme-init.js b/apps/app/public/theme-init.js
new file mode 100644
index 0000000..4e5b504
--- /dev/null
+++ b/apps/app/public/theme-init.js
@@ -0,0 +1,5 @@
+// Sets data-theme before first paint so a saved choice never flashes.
+try {
+ var t = localStorage.getItem('kwatch-theme');
+ if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
+} catch (e) {}
diff --git a/apps/app/src/components.tsx b/apps/app/src/components.tsx
index d25ab17..5564f9f 100644
--- a/apps/app/src/components.tsx
+++ b/apps/app/src/components.tsx
@@ -3,8 +3,10 @@ import type {ReactNode, RefObject} from 'react';
import {NavLink, Outlet, useNavigate} from 'react-router-dom';
import {logout, logoutAll} from './api';
import type {ApiError} from './api';
-import {DOCS_URL, SITE_URL} from './config';
+import {BLOG_URL, DOCS_URL, GITHUB_URL, SITE_URL} from './config';
import {useSession} from './session';
+import {useTheme} from './theme';
+import type {Theme} from './theme';
export function Mark({size = 34}: {size?: number}) {
return (
@@ -23,12 +25,116 @@ export function usePageTitle(title: string) {
}, [title]);
}
+const THEME_LABEL: Record = {system: 'System', light: 'Light', dark: 'Dark'};
+
+function ThemeIcon({theme}: {theme: Theme}) {
+ const common = {
+ width: 20,
+ height: 20,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 2,
+ strokeLinecap: 'round' as const,
+ strokeLinejoin: 'round' as const,
+ 'aria-hidden': true,
+ };
+ if (theme === 'light') {
+ return (
+
+ );
+ }
+ if (theme === 'dark') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function ThemeToggle() {
+ const [theme, next] = useTheme();
+ return (
+
+ );
+}
+
+export function SkipLink() {
+ return (
+
+ Skip to content
+
+ );
+}
+
+/** The one kwatch header: the same links on web, docs, blog and app. */
+export function SiteHeader({signedIn = false}: {signedIn?: boolean}) {
+ return (
+
+ );
+}
+
+export function SiteFooter() {
+ return (
+
+ );
+}
+
/** Centered card used by the sign-in screens and the 404. */
export function Card({children}: {children: ReactNode}) {
return (
-
- {children}
-
+ <>
+
+
+
+ {children}
+
+ >
);
}
@@ -57,36 +163,30 @@ export function Shell() {
}
return (
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+
+
-
+ >
);
}
diff --git a/apps/app/src/config.ts b/apps/app/src/config.ts
index b8417d9..84f7be7 100644
--- a/apps/app/src/config.ts
+++ b/apps/app/src/config.ts
@@ -7,6 +7,8 @@ export const TURNSTILE_SITEKEY: string = env.VITE_TURNSTILE_SITEKEY ?? '0x4AAAAA
export const SITE_URL = 'https://kwatch.dev';
export const DOCS_URL = 'https://docs.kwatch.dev';
+export const BLOG_URL = 'https://blog.kwatch.dev';
+export const GITHUB_URL = 'https://github.com/abahmed/kwatch';
export const CONTACT_URL = 'mailto:hello@kwatch.dev';
export const SUPPORT_EMAIL = 'support@kwatch.dev';
export const SUPPORT_MAILTO = `mailto:${SUPPORT_EMAIL}`;
diff --git a/apps/app/src/pages/Licenses.tsx b/apps/app/src/pages/Licenses.tsx
index 992ef1c..cc68491 100644
--- a/apps/app/src/pages/Licenses.tsx
+++ b/apps/app/src/pages/Licenses.tsx
@@ -354,7 +354,7 @@ export default function Licenses() {
}
const button = (
-