Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
997f7e7a87 | ||
|
|
ecb6864ff9 |
@@ -80,8 +80,7 @@ deliberate — it is the owner's own machine. Do not add a jail without being as
|
|||||||
src/
|
src/
|
||||||
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
│ └── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
||||||
│ └── landing/ # marketing landing page
|
|
||||||
├── servers/
|
├── servers/
|
||||||
│ ├── hono.ts # router composition; everything under /api
|
│ ├── hono.ts # router composition; everything under /api
|
||||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"prebuild": "bun run ./scripts/prebuild.ts",
|
"prebuild": "bun run ./scripts/prebuild.ts",
|
||||||
"build:web": "bun run ./scripts/build/web.ts",
|
"build:web": "bun run ./scripts/build/web.ts",
|
||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
import plugin from 'bun-plugin-tailwind';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { rm } from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const outdir = path.join(process.cwd(), 'dist_landing');
|
|
||||||
|
|
||||||
if (existsSync(outdir)) {
|
|
||||||
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
|
||||||
await rm(outdir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n🚀 Starting landing page build...\n');
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
|
|
||||||
const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/landing')]
|
|
||||||
.map((a) => path.resolve('src/apps/landing', a))
|
|
||||||
.filter((dir) => !dir.includes('node_modules'));
|
|
||||||
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`);
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let size = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
size /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
|
||||||
entrypoints,
|
|
||||||
outdir,
|
|
||||||
plugins: [plugin],
|
|
||||||
minify: true,
|
|
||||||
target: 'browser',
|
|
||||||
sourcemap: 'linked',
|
|
||||||
define: {
|
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const end = performance.now();
|
|
||||||
|
|
||||||
const outputTable = result.outputs.map((output) => ({
|
|
||||||
File: path.relative(process.cwd(), output.path),
|
|
||||||
Type: output.kind,
|
|
||||||
Size: formatFileSize(output.size),
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.table(outputTable);
|
|
||||||
console.log(`\n✅ Build completed in ${(end - start).toFixed(2)}ms\n`);
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { PixelGrid } from '../../workspaces/components/PixelGrid';
|
|
||||||
import { DuckAvatar } from './DuckAvatar';
|
|
||||||
import { WaitlistForm } from './WaitlistForm';
|
|
||||||
|
|
||||||
const landscapeBg = '/landscape1.webp';
|
|
||||||
const logoSrc = '/officer-logo.svg';
|
|
||||||
|
|
||||||
export const App = () => {
|
|
||||||
return (
|
|
||||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
|
||||||
{/* Background */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${landscapeBg})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PixelGrid />
|
|
||||||
<DuckAvatar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Logo */}
|
|
||||||
<header className="pt-12 md:pt-20 lg:pt-24 xl:pt-0 z-20 flex justify-center">
|
|
||||||
<div className="hidden md:block md:pt-28">
|
|
||||||
<img
|
|
||||||
src={logoSrc}
|
|
||||||
alt="Officer Logo"
|
|
||||||
style={{ width: '1500px', height: 'auto', transform: 'skew(-15deg, -2deg)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="block md:hidden">
|
|
||||||
<img
|
|
||||||
src={logoSrc}
|
|
||||||
alt="Officer Logo"
|
|
||||||
style={{ width: '900px', height: 'auto', transform: 'skew(-15deg, -2deg)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Waitlist form */}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 z-20 flex justify-center pb-18 md:pb-30">
|
|
||||||
<WaitlistForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { Canvas, useThree } from '@react-three/fiber';
|
|
||||||
import { OrbitControls, useGLTF, useAnimations } from '@react-three/drei';
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
|
|
||||||
function DuckModel({ onLoaded }: { onLoaded: () => void }) {
|
|
||||||
const character = useGLTF('/duck3D/Character_output.glb');
|
|
||||||
const animations = useGLTF('/duck3D/Meshy_Merged_Animations.glb');
|
|
||||||
const meshRef = useRef<any>(null);
|
|
||||||
const { actions } = useAnimations(animations.animations, meshRef);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onLoaded();
|
|
||||||
}, [character, animations, onLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!actions) return;
|
|
||||||
const action = actions['Walking'];
|
|
||||||
if (!action) return;
|
|
||||||
action.reset();
|
|
||||||
action.setLoop(2201, Infinity);
|
|
||||||
action.clampWhenFinished = false;
|
|
||||||
action.fadeIn(0.4);
|
|
||||||
action.play();
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
return <primitive ref={meshRef} object={character.scene} position={[0, -0.2, 0]} scale={4.5} rotation={[-0.1, (10 * Math.PI) / 180, 0]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Camera() {
|
|
||||||
const { camera } = useThree();
|
|
||||||
const controlsRef = useRef<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (controlsRef.current) {
|
|
||||||
controlsRef.current.target.set(0.3, 3.29, -0.4);
|
|
||||||
camera.position.set(2.89, 5.24, 7.38);
|
|
||||||
controlsRef.current.update();
|
|
||||||
}
|
|
||||||
}, [camera]);
|
|
||||||
|
|
||||||
return <OrbitControls ref={controlsRef} enabled={false} target={[0.3, 3.29, -0.4]} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DuckAvatar() {
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed top-12 md:top-auto md:bottom-0 left-1/2 -translate-x-1/2 w-[60vh] h-[75vh] overflow-visible z-1 pointer-events-none"
|
|
||||||
style={{ background: 'transparent', opacity: isLoaded ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
<div className="w-full h-full pointer-events-none overflow-visible" style={{ background: 'transparent' }}>
|
|
||||||
<Canvas camera={{ position: [2.89, 5.24, 7.38], fov: 65, near: 0.1, far: 1000 }} style={{ background: 'transparent' }}>
|
|
||||||
<ambientLight intensity={1.5} />
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={2} />
|
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={1} />
|
|
||||||
<pointLight position={[0, 5, 0]} intensity={1.5} />
|
|
||||||
<Camera />
|
|
||||||
<DuckModel onLoaded={() => setIsLoaded(true)} />
|
|
||||||
</Canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import type { CSSProperties, FormEvent } from 'react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
const cardStyle: CSSProperties = {
|
|
||||||
backgroundColor: 'var(--card-bg)',
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(to right, var(--card-grid-color) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, var(--card-grid-color) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: '20px 20px',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function WaitlistForm() {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
const [focused, setFocused] = useState(false);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isValid = email.trim().length > 0 && email.includes('@');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const vv = window.visualViewport;
|
|
||||||
if (!vv) return;
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
document.documentElement.scrollTop = 0;
|
|
||||||
document.body.scrollTop = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
vv.addEventListener('resize', reset);
|
|
||||||
vv.addEventListener('scroll', reset);
|
|
||||||
return () => {
|
|
||||||
vv.removeEventListener('resize', reset);
|
|
||||||
vv.removeEventListener('scroll', reset);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFocus = () => setFocused(true);
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
setFocused(false);
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (ev: FormEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
inputRef.current?.blur();
|
|
||||||
const trimmed = email.trim();
|
|
||||||
if (!trimmed || isSubmitting) return;
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await fetch('https://alpha.officer.dev/api/waitlist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email: trimmed }),
|
|
||||||
});
|
|
||||||
setDone(true);
|
|
||||||
} catch {
|
|
||||||
setDone(true);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={wrapperRef}
|
|
||||||
className="rounded-xl border-2 border-duck-dark/30 shadow-lg p-5 transition-all duration-300 ease-out"
|
|
||||||
style={{
|
|
||||||
...cardStyle,
|
|
||||||
...(focused && window.innerWidth < 768 ? { position: 'fixed', bottom: '40%', top: 'auto', left: '50%', transform: 'translateX(-50%)', zIndex: 50, width: wrapperRef.current?.offsetWidth } : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{done ? (
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">You're on the list!</h2>
|
|
||||||
<div className="text-sm text-duck-dark/60">
|
|
||||||
<p>
|
|
||||||
We'll notify <strong>{email}</strong> when Officer is ready.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2 className="text-2xl font-bold text-duck-dark mb-2">Join the Waitlist</h2>
|
|
||||||
<p className="text-sm text-duck-dark/60 mb-6">
|
|
||||||
Be the first to know when Officer launches.
|
|
||||||
</p>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(ev) => setEmail(ev.target.value)}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="flex-1 h-10 rounded-md border border-input bg-background px-4 py-2.5 text-base text-duck-dark placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid || isSubmitting}
|
|
||||||
className="w-full bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold px-6 py-2.5 rounded-lg transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Joining...' : 'Join'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=overlays-content" />
|
|
||||||
<title>Officer Dev (Alpha)</title>
|
|
||||||
<meta name="description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
|
||||||
|
|
||||||
<!-- OpenGraph -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:title" content="Officer Dev (Alpha)" />
|
|
||||||
<meta property="og:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
|
||||||
<meta property="og:image" content="https://officer.dev/og-image-v3.jpg" />
|
|
||||||
<meta property="og:image:secure_url" content="https://officer.dev/og-image-v3.jpg" />
|
|
||||||
<meta property="og:image:width" content="1200" />
|
|
||||||
<meta property="og:image:height" content="630" />
|
|
||||||
<meta property="og:image:type" content="image/jpeg" />
|
|
||||||
<meta property="og:url" content="https://officer.dev" />
|
|
||||||
<meta property="og:site_name" content="Officer Dev" />
|
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="Officer Dev (Alpha)" />
|
|
||||||
<meta name="twitter:description" content="Your all-purpose AI operating system. Personal AI assistant, terminal, file browser, code editor, and customizable dashboards — all self-hosted." />
|
|
||||||
<meta name="twitter:image" content="https://officer.dev/og-image-v3.jpg" />
|
|
||||||
|
|
||||||
<!-- Favicons & App Icons -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="https://officer.dev/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="https://officer.dev/favicon-96x96.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="https://officer.dev/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="https://officer.dev/site.webmanifest" />
|
|
||||||
<meta name="theme-color" content="#1F2620" />
|
|
||||||
|
|
||||||
<script type="module" src="./main.tsx" async></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { App } from './App';
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin 'tailwindcss-animate';
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-duck-yellow: var(--duck-yellow);
|
|
||||||
--color-duck-orange: var(--duck-orange);
|
|
||||||
--color-duck-teal: var(--duck-teal);
|
|
||||||
--color-duck-forest: var(--duck-forest);
|
|
||||||
--color-duck-dark: var(--duck-dark);
|
|
||||||
--color-duck-beige: var(--duck-beige);
|
|
||||||
|
|
||||||
--color-background: hsl(var(--background));
|
|
||||||
--color-foreground: hsl(var(--foreground));
|
|
||||||
|
|
||||||
--color-primary: hsl(var(--primary));
|
|
||||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
|
||||||
|
|
||||||
--color-muted: hsl(var(--muted));
|
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
|
||||||
|
|
||||||
--color-input: hsl(var(--input));
|
|
||||||
--color-ring: hsl(var(--ring));
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--duck-yellow: #F4C430;
|
|
||||||
--duck-orange: #EA580C;
|
|
||||||
--duck-teal: #0891B2;
|
|
||||||
--duck-forest: #166534;
|
|
||||||
--duck-dark: #14532D;
|
|
||||||
--duck-beige: #E7D4B5;
|
|
||||||
|
|
||||||
--pixel-grid-color: rgba(20, 83, 45, 0.1);
|
|
||||||
|
|
||||||
--card-bg: rgba(255, 255, 255, 0.9);
|
|
||||||
--card-grid-color: rgba(20, 83, 45, 0.08);
|
|
||||||
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
|
||||||
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--duck-teal: #22d3ee;
|
|
||||||
--duck-dark: #f1f5f9;
|
|
||||||
--duck-forest: #4ade80;
|
|
||||||
--duck-beige: #292524;
|
|
||||||
|
|
||||||
--pixel-grid-color: rgba(200, 230, 210, 0.08);
|
|
||||||
|
|
||||||
--card-bg: rgba(10, 20, 15, 0.9);
|
|
||||||
--card-grid-color: rgba(200, 230, 210, 0.06);
|
|
||||||
|
|
||||||
--background: 222.2 84% 4.9%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 212.7 26.8% 83.9%;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
margin: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import * as Dropdown from '@/components/ui/dropdown-menu';
|
import * as Dropdown from '@/components/ui/dropdown-menu';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { User, LogOut, Settings, Puzzle, Sun, Moon, Bot, UserCog, Palette } from 'lucide-react';
|
import { User, LogOut, Settings, Puzzle, Bot, UserCog, Palette } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
|
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||||
import { useSettings } from 'state/useSettings';
|
|
||||||
|
|
||||||
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
const { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } = Dropdown;
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu() {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { colorMode, setColorMode } = useColorMode();
|
|
||||||
const { themeId, setTheme, themes } = useTheme();
|
const { themeId, setTheme, themes } = useTheme();
|
||||||
const { settings, saveSettings } = useSettings();
|
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
const toggleColorMode = () => {
|
|
||||||
const next = colorMode === 'dark' ? 'light' : 'dark';
|
|
||||||
setColorMode(next);
|
|
||||||
saveSettings({ ...settings, appearance: { ...settings.appearance, colorMode: next } });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -37,9 +28,12 @@ export function UserMenu() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="z-10 w-48">
|
<DropdownMenuContent align="end" className="z-10 w-48">
|
||||||
{/*
|
{/*
|
||||||
Every theme this build ships, listed. Deliberately the crudest possible control: the point tonight
|
Every theme this build ships. This replaced a Light/Dark toggle, which had become a second way to
|
||||||
is proving the app can wear a different skin at all, not designing a picker. A real one belongs in
|
do the same thing — light and dark are two themes now, both already in this list, and the toggle
|
||||||
an appearance settings screen, which does not exist yet.
|
was the last thing writing the legacy `appearance.colorMode`.
|
||||||
|
|
||||||
|
Deliberately the crudest possible control: proving the app can wear another skin, not designing a
|
||||||
|
picker. A real one belongs in an appearance settings screen, which does not exist yet.
|
||||||
*/}
|
*/}
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer">
|
<DropdownMenuItem key={theme.id} onClick={() => setTheme(theme.id)} className="cursor-pointer">
|
||||||
@@ -87,11 +81,6 @@ export function UserMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={toggleColorMode} className="cursor-pointer">
|
|
||||||
{colorMode === 'dark' ? <Sun className="mr-2 h-4 w-4" /> : <Moon className="mr-2 h-4 w-4" />}
|
|
||||||
{colorMode === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link to="/auth/signout">
|
<Link to="/auth/signout">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -1,46 +1,63 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useColorMode, useTheme } from '@/components/ui/ThemeProvider';
|
import { useTheme } from '@/components/ui/ThemeProvider';
|
||||||
|
import { themeFromLegacyColorMode, isKnownTheme } from 'theme';
|
||||||
import { useSettings } from 'state/useSettings';
|
import { useSettings } from 'state/useSettings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bring the account's saved appearance down from the server, once.
|
* Keep the account's theme and this browser's theme agreed, in both directions.
|
||||||
*
|
*
|
||||||
* ── Why "once" is the whole point ──
|
* ── Why it is two effects and not one ──
|
||||||
*
|
*
|
||||||
* This used to re-apply `settings.appearance.colorMode` whenever the settings query resolved, which was
|
* They answer different questions. HYDRATE asks "this browser has never chosen — what does the account
|
||||||
* correct while colour mode was the ONLY appearance state — there was nothing else for it to overwrite.
|
* say?" and runs once. PERSIST asks "the person just chose — tell the account", and runs on every change.
|
||||||
* With themes there is: picking Plain and reloading put Plain in localStorage, and then this hook stamped
|
* Collapsing them produces the bug this file used to have: the server's value re-applied on every settings
|
||||||
* `colorMode: 'dark'` back over it a moment later. The theme changed and then silently changed back, which
|
* fetch, so choosing Plain and reloading silently reverted it.
|
||||||
* looks like the picker not working.
|
|
||||||
*
|
*
|
||||||
* So the server's value is treated as what it is — a STARTING POINT for a browser that has never chosen —
|
* A local choice wins on load because it is the more recent statement of intent AND it is already on the
|
||||||
* rather than as a continuous instruction. A local choice wins, because it is the more recent statement of
|
* screen — re-applying the server's value would be a visible flip a second after the page settles.
|
||||||
* what this person wants, and it is already persisted.
|
|
||||||
*
|
*
|
||||||
* The proper fix is for the server to store a theme id rather than a colour mode, at which point this hook
|
* ── Migration ──
|
||||||
* becomes a one-line sync of the same field in both places. That needs a settings migration and is
|
*
|
||||||
* deliberately not in the extraction commit.
|
* Accounts predating themes stored `appearance.colorMode`. It is read once, mapped to the matching Duck
|
||||||
|
* theme, and never written again. Somebody who chose dark a year ago keeps dark.
|
||||||
|
*
|
||||||
|
* Worth knowing: the server's default was `appearance: { theme: 'light' }` while the client read
|
||||||
|
* `appearance.colorMode`, so that default had never been read by anything. Both sides say `theme` now.
|
||||||
*/
|
*/
|
||||||
const LOCAL_CHOICE_KEY = 'officer-theme';
|
const LOCAL_KEY = 'officer-theme';
|
||||||
|
|
||||||
export const useColorModeSync = () => {
|
export const useColorModeSync = () => {
|
||||||
const { settings } = useSettings();
|
const { settings, settingsLoaded, saveSettings } = useSettings();
|
||||||
const { setColorMode } = useColorMode();
|
const { themeId, setTheme } = useTheme();
|
||||||
const { themeId } = useTheme();
|
const hydrated = useRef(false);
|
||||||
const applied = useRef(false);
|
|
||||||
|
|
||||||
|
// HYDRATE — once, and only into a browser that has not chosen for itself.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings || applied.current) return;
|
// `settingsLoaded`, not `settings` — see its note. `settings` is the DEFAULTS until the request
|
||||||
applied.current = true;
|
// lands, and acting on those is how this hook overwrote the account's real preference with the
|
||||||
|
// default one and then saved it back.
|
||||||
|
if (!settingsLoaded || hydrated.current) return;
|
||||||
|
hydrated.current = true;
|
||||||
|
|
||||||
// This browser has already been told what to wear. The server's colour mode is older information.
|
|
||||||
try {
|
try {
|
||||||
if (window.localStorage.getItem(LOCAL_CHOICE_KEY)) return;
|
if (window.localStorage.getItem(LOCAL_KEY)) return;
|
||||||
} catch {
|
} catch {
|
||||||
// Storage unavailable — fall through and honour the server, which is the best guess left.
|
// Storage unavailable. Honouring the account is the best guess left.
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = settings.appearance?.colorMode;
|
const saved = settings.appearance?.theme;
|
||||||
if (saved === 'light' || saved === 'dark') setColorMode(saved);
|
if (isKnownTheme(saved)) {
|
||||||
}, [settings, setColorMode, themeId]);
|
setTheme(saved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const migrated = themeFromLegacyColorMode(settings.appearance?.colorMode);
|
||||||
|
if (migrated) setTheme(migrated);
|
||||||
|
}, [settingsLoaded, settings, setTheme]);
|
||||||
|
|
||||||
|
// PERSIST — so the choice follows the account to another browser rather than living in one localStorage.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingsLoaded || !hydrated.current) return;
|
||||||
|
if (settings.appearance?.theme === themeId) return;
|
||||||
|
saveSettings({ ...settings, appearance: { ...settings.appearance, theme: themeId } });
|
||||||
|
}, [themeId, settingsLoaded, settings, saveSettings]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
defaultPwd: '~',
|
defaultPwd: '~',
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
theme: 'light',
|
// A theme id, not a colour mode. `'light'` sat here for a long time while the client read
|
||||||
|
// `appearance.colorMode`, so this default was never once read by anything.
|
||||||
|
theme: 'duck-light',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +24,33 @@ export const settingsRouter = createRouter();
|
|||||||
settingsRouter.route('/', selfPermissionsRouter);
|
settingsRouter.route('/', selfPermissionsRouter);
|
||||||
|
|
||||||
// GET /settings — return user settings from DB, default if empty
|
// GET /settings — return user settings from DB, default if empty
|
||||||
|
/**
|
||||||
|
* An account from before themes existed, expressed in the vocabulary that replaced it.
|
||||||
|
*
|
||||||
|
* Themes superseded `appearance.colorMode`, and an existing account still has one. The migration has to
|
||||||
|
* happen HERE rather than in the client, and that is the whole point of this function: the client merges
|
||||||
|
* `DEFAULT_SETTINGS` over whatever arrives, so by the time it sees the data, a missing `theme` has already
|
||||||
|
* been filled in with the default — and a default is indistinguishable from a choice. Verified the wrong
|
||||||
|
* way round first: an account holding only `colorMode: 'dark'` came up light, because the client's own
|
||||||
|
* default `theme` masked it.
|
||||||
|
*
|
||||||
|
* The server sees the stored object before anything is merged over it, so it is the only place that can
|
||||||
|
* tell "never chose a theme" from "chose the default one".
|
||||||
|
*
|
||||||
|
* Read-only: nothing is written back here. A GET that rewrites the row on every call is a surprise, and
|
||||||
|
* the client persists the resolved theme on its next change anyway.
|
||||||
|
*/
|
||||||
|
function withThemeMigrated(settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const appearance = settings.appearance as { theme?: unknown; colorMode?: unknown } | undefined;
|
||||||
|
if (!appearance || typeof appearance.theme === 'string') return settings;
|
||||||
|
|
||||||
|
const migrated =
|
||||||
|
appearance.colorMode === 'dark' ? 'duck-dark' : appearance.colorMode === 'light' ? 'duck-light' : undefined;
|
||||||
|
if (!migrated) return settings;
|
||||||
|
|
||||||
|
return { ...settings, appearance: { ...appearance, theme: migrated } };
|
||||||
|
}
|
||||||
|
|
||||||
settingsRouter.get('/settings', async (ctx) => {
|
settingsRouter.get('/settings', async (ctx) => {
|
||||||
const userId = ctx.get('user').id;
|
const userId = ctx.get('user').id;
|
||||||
const settings = await getUserSettings(userId);
|
const settings = await getUserSettings(userId);
|
||||||
@@ -31,7 +60,7 @@ settingsRouter.get('/settings', async (ctx) => {
|
|||||||
return ctx.json(DEFAULT_SETTINGS);
|
return ctx.json(DEFAULT_SETTINGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.json(settings);
|
return ctx.json(withThemeMigrated(settings as Record<string, unknown>));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /settings — full replacement
|
// PUT /settings — full replacement
|
||||||
|
|||||||
@@ -23,7 +23,19 @@ export const useSettings = () => {
|
|||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: settings = DEFAULT_SETTINGS } = useQuery<UserSettings>({
|
const {
|
||||||
|
data: settings = DEFAULT_SETTINGS,
|
||||||
|
/**
|
||||||
|
* Whether the ACCOUNT's settings have arrived, as opposed to the defaults standing in for them.
|
||||||
|
*
|
||||||
|
* `settings` is never null — it falls back to `DEFAULT_SETTINGS`, which is a complete and entirely
|
||||||
|
* valid object. So a consumer that wants to act once on the saved value cannot tell "not loaded yet"
|
||||||
|
* from "loaded, and it happens to equal the defaults". `useColorModeSync` could not, and applied the
|
||||||
|
* default theme on first render, marked itself done, and never saw the real one — then persisted that
|
||||||
|
* default over the account's actual preference.
|
||||||
|
*/
|
||||||
|
isSuccess: settingsLoaded,
|
||||||
|
} = useQuery<UserSettings>({
|
||||||
queryKey: QUERY_KEY,
|
queryKey: QUERY_KEY,
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -41,7 +53,7 @@ export const useSettings = () => {
|
|||||||
[client, queryClient],
|
[client, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { settings, saveSettings };
|
return { settings, settingsLoaded, saveSettings };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseSettingsType = ReturnType<typeof useSettings>;
|
export type UseSettingsType = ReturnType<typeof useSettings>;
|
||||||
@@ -66,7 +78,13 @@ export type UserSettings = {
|
|||||||
defaultModel: string | null;
|
defaultModel: string | null;
|
||||||
};
|
};
|
||||||
appearance: {
|
appearance: {
|
||||||
colorMode: 'light' | 'dark';
|
/** The active theme's id — `duck-light`, `duck-dark`, `plain`, … */
|
||||||
|
theme: string;
|
||||||
|
/**
|
||||||
|
* The pre-themes preference, read once to migrate an existing account and never written again.
|
||||||
|
* Optional because a new account has only `theme`.
|
||||||
|
*/
|
||||||
|
colorMode?: 'light' | 'dark';
|
||||||
};
|
};
|
||||||
languages: {
|
languages: {
|
||||||
spoken: string[];
|
spoken: string[];
|
||||||
@@ -99,7 +117,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
defaultModel: 'claude-code',
|
defaultModel: 'claude-code',
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
colorMode: 'light',
|
theme: 'duck-light',
|
||||||
},
|
},
|
||||||
languages: {
|
languages: {
|
||||||
spoken: ['en'],
|
spoken: ['en'],
|
||||||
|
|||||||
Reference in New Issue
Block a user