landing
This commit is contained in:
@@ -5,6 +5,7 @@ node_modules
|
|||||||
out
|
out
|
||||||
src/videos/**/out
|
src/videos/**/out
|
||||||
dist
|
dist
|
||||||
|
dist_*
|
||||||
.dist
|
.dist
|
||||||
runtime-scripts
|
runtime-scripts
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"build:editor:app": "bun run ./scripts/build/editor.ts --app",
|
"build:editor:app": "bun run ./scripts/build/editor.ts --app",
|
||||||
"build:editor:extension": "bun run ./scripts/build/editor.ts --extension",
|
"build:editor:extension": "bun run ./scripts/build/editor.ts --extension",
|
||||||
"build:editor:runtime": "bun run ./scripts/build/editor.ts --runtime",
|
"build:editor:runtime": "bun run ./scripts/build/editor.ts --runtime",
|
||||||
|
"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": "cd src/databases/officer_db && bun run push",
|
"db:push": "cd src/databases/officer_db && bun run push",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import plugin from 'bun-plugin-tailwind';
|
import plugin from 'bun-plugin-tailwind';
|
||||||
import { config as dotenv } from 'dotenv';
|
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { rm } from 'fs/promises';
|
import { rm } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { buildConfig } from './helpers';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const outdir = path.join(process.cwd(), 'dist_landing');
|
||||||
const envPath = path.resolve(__dirname, '../../.env');
|
|
||||||
dotenv({ path: envPath });
|
|
||||||
|
|
||||||
const outdir = path.join(process.cwd(), 'build/landing');
|
|
||||||
|
|
||||||
if (existsSync(outdir)) {
|
if (existsSync(outdir)) {
|
||||||
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
console.log(`🗑️ Cleaning previous build at ${outdir}`);
|
||||||
await rm(outdir, { recursive: true, force: true });
|
await rm(outdir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('\n🚀 Starting landing page build...\n');
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
const configPath = 'src/workspaces/config/src/index.ts';
|
const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/landing')]
|
||||||
if (existsSync(configPath)) {
|
.map((a) => path.resolve('src/apps/landing', a))
|
||||||
console.log('🔧 Generating config with environment values...');
|
|
||||||
buildConfig(configPath, 'landing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const entrypoints = [...new Bun.Glob('**.html').scanSync('src/apps/officer-web')]
|
|
||||||
.map((a) => path.resolve('src/apps/officer-web', a))
|
|
||||||
.filter((dir) => !dir.includes('node_modules'));
|
.filter((dir) => !dir.includes('node_modules'));
|
||||||
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`);
|
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`);
|
||||||
|
|
||||||
@@ -35,12 +24,10 @@ const formatFileSize = (bytes: number): string => {
|
|||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
let size = bytes;
|
let size = bytes;
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
|
|
||||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
size /= 1024;
|
size /= 1024;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,6 +52,4 @@ const outputTable = result.outputs.map((output) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
console.table(outputTable);
|
console.table(outputTable);
|
||||||
const buildTime = (end - start).toFixed(2);
|
console.log(`\n✅ Build completed in ${(end - start).toFixed(2)}ms\n`);
|
||||||
|
|
||||||
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { PixelGrid } from '../../workspaces/components/PixelGrid';
|
||||||
|
import { DuckAvatar } from './DuckAvatar';
|
||||||
|
import { WaitlistForm } from './WaitlistForm';
|
||||||
|
|
||||||
|
const landscapeBg = 'https://static.officer.dev/landscape1.webp';
|
||||||
|
const logoSrc = 'https://static.officer.dev/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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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('https://static.officer.dev/duck3D/Character_output.glb');
|
||||||
|
const animations = useGLTF('https://static.officer.dev/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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 ? { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!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 workspaces — 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 workspaces — all self-hosted." />
|
||||||
|
<meta property="og:image" content="https://officer.dev/og-image-v3.jpg" />
|
||||||
|
<meta property="og:image:secure_url" content="https://static.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 workspaces — 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://static.officer.dev/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="96x96" href="https://static.officer.dev/favicon-96x96.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="https://static.officer.dev/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="https://static.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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { existsSync, readFileSync, appendFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { DATA_PATH } from '../../data-path';
|
||||||
|
|
||||||
|
export const waitlistRouter = createRouter();
|
||||||
|
|
||||||
|
const WAITLIST_FILE = join(DATA_PATH, 'waitlist.txt');
|
||||||
|
|
||||||
|
waitlistRouter.post('/', async (ctx) => {
|
||||||
|
const body = await ctx.req.json<{ email?: string }>();
|
||||||
|
const email = body.email?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
return ctx.json({ error: 'Invalid email' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = DATA_PATH;
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(WAITLIST_FILE)) {
|
||||||
|
const existing = readFileSync(WAITLIST_FILE, 'utf-8');
|
||||||
|
const emails = existing.split('\n').map((l) => l.trim().toLowerCase());
|
||||||
|
if (emails.includes(email)) {
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFileSync(WAITLIST_FILE, email + '\n', 'utf-8');
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import type { HonoVariables } from './create-router';
|
|||||||
import { authRouter } from './api/auth';
|
import { authRouter } from './api/auth';
|
||||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||||
|
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||||
import { usersRouter } from './api/users/users-router';
|
import { usersRouter } from './api/users/users-router';
|
||||||
import { plansRouter } from './api/plans/plans';
|
import { plansRouter } from './api/plans/plans';
|
||||||
import { skillsRouter } from './api/skills/skills';
|
import { skillsRouter } from './api/skills/skills';
|
||||||
@@ -44,6 +45,7 @@ honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
|
|||||||
honoServer.route('/api/auth', authRouter);
|
honoServer.route('/api/auth', authRouter);
|
||||||
honoServer.route('/api/server-settings', serverSettingsRouter);
|
honoServer.route('/api/server-settings', serverSettingsRouter);
|
||||||
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
honoServer.route('/api/landing-page-data', landingPageDataRouter);
|
||||||
|
honoServer.route('/api/waitlist', waitlistRouter);
|
||||||
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
|
||||||
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user