chore: initial prototype — UI shell with mock data on vinext/Cloudflare stack
This commit is contained in:
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/.vinext/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
next-env.d.ts
|
||||||
|
/dist/
|
||||||
|
/.wrangler/
|
||||||
|
/outputs/
|
||||||
|
/work/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"project_id": "appgprj_6a5476d23eec8191bb727717ee72b2fd",
|
||||||
|
"d1": null,
|
||||||
|
"r2": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# vinext-starter
|
||||||
|
|
||||||
|
A clean full-stack starter running on
|
||||||
|
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
||||||
|
Drizzle support.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js `>=22.13.0`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This starter does not use `wrangler.jsonc`.
|
||||||
|
|
||||||
|
## Included Shape
|
||||||
|
|
||||||
|
- edit site code under `app/`
|
||||||
|
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
|
||||||
|
- `vite.config.ts` simulates declared bindings for local development
|
||||||
|
- `db/schema.ts` starts intentionally empty
|
||||||
|
- `examples/d1/` contains an optional D1 example surface
|
||||||
|
- `drizzle.config.ts` supports local migration generation when needed
|
||||||
|
|
||||||
|
## Workspace Auth Headers
|
||||||
|
|
||||||
|
OpenAI workspace sites can read the current user's email from
|
||||||
|
`oai-authenticated-user-email`.
|
||||||
|
|
||||||
|
SIWC-authenticated workspace sites may also receive
|
||||||
|
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
|
||||||
|
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
|
||||||
|
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
|
||||||
|
|
||||||
|
Treat the full name as optional and fall back to email when it is absent:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const email = requestHeaders.get("oai-authenticated-user-email");
|
||||||
|
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
|
||||||
|
const fullName =
|
||||||
|
encodedFullName &&
|
||||||
|
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
|
||||||
|
"percent-encoded-utf-8"
|
||||||
|
? decodeURIComponent(encodedFullName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayName = fullName ?? email;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional Dispatch-Owned ChatGPT Sign-In
|
||||||
|
|
||||||
|
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
|
||||||
|
optional or required ChatGPT sign-in:
|
||||||
|
|
||||||
|
- Use `getChatGPTUser()` for optional signed-in UI.
|
||||||
|
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
|
||||||
|
anonymous visitors through Sign in with ChatGPT.
|
||||||
|
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
|
||||||
|
browser links or actions.
|
||||||
|
- Pass a same-origin relative `returnTo` path for the destination after sign-in
|
||||||
|
or sign-out. The helper validates and safely encodes it.
|
||||||
|
- Mark protected pages with `export const dynamic = "force-dynamic"` because
|
||||||
|
they depend on per-request identity headers.
|
||||||
|
|
||||||
|
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
||||||
|
OAuth cookies, and identity header injection. Do not implement app routes for
|
||||||
|
those reserved paths. Routes that do not import and call the helper remain
|
||||||
|
anonymous-compatible.
|
||||||
|
|
||||||
|
SIWC establishes identity only; it does not prove workspace membership. Use the
|
||||||
|
Sites hosting platform's access policy controls for workspace-wide restrictions,
|
||||||
|
or enforce explicit server-side membership or allowlist checks.
|
||||||
|
|
||||||
|
Use SIWC for account pages, user-specific dashboards, saved records, and write
|
||||||
|
actions tied to the current ChatGPT user. Leave public content anonymous.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
- `npm run dev`: start local development
|
||||||
|
- `npm run build`: verify the vinext build output
|
||||||
|
- `npm test`: build the starter and verify its rendered loading skeleton
|
||||||
|
- `npm run db:generate`: generate Drizzle migrations after schema changes
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
- [vinext Documentation](https://github.com/cloudflare/vinext)
|
||||||
|
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export type ChatGPTUser = {
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||||
|
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||||
|
const USER_FULL_NAME_ENCODING_HEADER =
|
||||||
|
"oai-authenticated-user-full-name-encoding";
|
||||||
|
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||||
|
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||||
|
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||||
|
const CALLBACK_PATH = "/callback";
|
||||||
|
|
||||||
|
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||||
|
if (!email) return null;
|
||||||
|
|
||||||
|
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||||
|
const fullName =
|
||||||
|
encodedFullName &&
|
||||||
|
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||||
|
? safeDecodeURIComponent(encodedFullName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: fullName ?? email,
|
||||||
|
email,
|
||||||
|
fullName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireChatGPTUser(
|
||||||
|
returnTo: string,
|
||||||
|
): Promise<ChatGPTUser> {
|
||||||
|
const user = await getChatGPTUser();
|
||||||
|
if (user) return user;
|
||||||
|
|
||||||
|
redirect(chatGPTSignInPath(returnTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatGPTSignInPath(returnTo: string): string {
|
||||||
|
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||||
|
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||||
|
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||||
|
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRelativeReturnPath(value: string): string {
|
||||||
|
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value, "https://app.local");
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
if (url.origin !== "https://app.local") return "/";
|
||||||
|
if (isReservedAuthPath(url.pathname)) return "/";
|
||||||
|
|
||||||
|
return `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReservedAuthPath(pathname: string): boolean {
|
||||||
|
return (
|
||||||
|
pathname === SIGN_IN_PATH ||
|
||||||
|
pathname === SIGN_OUT_PATH ||
|
||||||
|
pathname === CALLBACK_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeURIComponent(value: string): string | null {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Project E — Your personal operating system",
|
||||||
|
description: "Tasks, habits, projects, notes, reports, and agents in one calm workspace.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import ProjectEApp from "./project-e-app";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <ProjectEApp />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Bot,
|
||||||
|
CalendarDays,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Circle,
|
||||||
|
Clock3,
|
||||||
|
Command,
|
||||||
|
FileBarChart,
|
||||||
|
Flame,
|
||||||
|
FolderKanban,
|
||||||
|
GripVertical,
|
||||||
|
LayoutDashboard,
|
||||||
|
ListTodo,
|
||||||
|
Menu,
|
||||||
|
MoreHorizontal,
|
||||||
|
NotebookPen,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Target,
|
||||||
|
TimerReset,
|
||||||
|
TrendingUp,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type View = "Dashboard" | "Tasks" | "Habits" | "Projects" | "Notes" | "Reports" | "Calendar" | "Analytics" | "Agent Activity" | "Settings";
|
||||||
|
type Task = { id: number; title: string; project: string; time: string; priority: "Urgent" | "High" | "Normal"; done: boolean; status: "To do" | "In progress" | "Done"; domain: "Personal" | "Work" | "OTS" };
|
||||||
|
|
||||||
|
const nav: { label: View; icon: LucideIcon }[] = [
|
||||||
|
{ label: "Dashboard", icon: LayoutDashboard }, { label: "Tasks", icon: ListTodo },
|
||||||
|
{ label: "Habits", icon: Flame }, { label: "Projects", icon: FolderKanban },
|
||||||
|
{ label: "Notes", icon: NotebookPen }, { label: "Reports", icon: FileBarChart },
|
||||||
|
{ label: "Calendar", icon: CalendarDays }, { label: "Analytics", icon: BarChart3 },
|
||||||
|
{ label: "Agent Activity", icon: Bot }, { label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const initialTasks: Task[] = [
|
||||||
|
{ id: 1, title: "Finalize Q3 product brief", project: "Project E", time: "9:00 AM", priority: "Urgent", done: false, status: "In progress", domain: "Work" },
|
||||||
|
{ id: 2, title: "Review OTS storefront copy", project: "OTS Growth", time: "11:30 AM", priority: "High", done: false, status: "To do", domain: "OTS" },
|
||||||
|
{ id: 3, title: "30 minute strength session", project: "Personal OS", time: "4:00 PM", priority: "Normal", done: false, status: "To do", domain: "Personal" },
|
||||||
|
{ id: 4, title: "Map MCP permission model", project: "Project E", time: "Yesterday", priority: "High", done: true, status: "Done", domain: "Work" },
|
||||||
|
{ id: 5, title: "Archive June campaign assets", project: "OTS Growth", time: "Jul 15", priority: "Normal", done: false, status: "In progress", domain: "OTS" },
|
||||||
|
{ id: 6, title: "Plan long weekend itinerary", project: "Personal OS", time: "Jul 17", priority: "Normal", done: false, status: "To do", domain: "Personal" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const habits = [
|
||||||
|
{ id: 1, icon: "↟", name: "Morning walk", streak: 18, color: "mint", done: true },
|
||||||
|
{ id: 2, icon: "◒", name: "Read 20 pages", streak: 12, color: "blue", done: false },
|
||||||
|
{ id: 3, icon: "✦", name: "Daily reflection", streak: 7, color: "amber", done: false },
|
||||||
|
{ id: 4, icon: "⌁", name: "No screens after 10", streak: 4, color: "coral", done: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
{ name: "Project E", domain: "Work", progress: 72, due: "Sep 30", tasks: "18 / 25", color: "#356bff", icon: "E" },
|
||||||
|
{ name: "OTS Growth", domain: "OTS", progress: 54, due: "Aug 12", tasks: "13 / 24", color: "#ea7658", icon: "O" },
|
||||||
|
{ name: "Personal OS", domain: "Personal", progress: 81, due: "Ongoing", tasks: "17 / 21", color: "#299667", icon: "P" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const week = [
|
||||||
|
{ day: "M", value: 58 }, { day: "T", value: 78 }, { day: "W", value: 66 },
|
||||||
|
{ day: "T", value: 91 }, { day: "F", value: 74 }, { day: "S", value: 42 }, { day: "S", value: 67 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Logo() {
|
||||||
|
return <div className="brand"><span className="brand-mark">E</span><span>Project E</span></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Progress({ value, color = "var(--green)" }: { value: number; color?: string }) {
|
||||||
|
return <div className="progress" aria-label={`${value}% complete`}><span style={{ width: `${value}%`, background: color }} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dashboard({ tasks, onToggleTask, habitDone, onToggleHabit, onNavigate }: { tasks: Task[]; onToggleTask: (id: number) => void; habitDone: Record<number, boolean>; onToggleHabit: (id: number) => void; onNavigate: (view: View) => void }) {
|
||||||
|
const done = tasks.filter(t => t.done).length;
|
||||||
|
return <>
|
||||||
|
<section className="hero-grid">
|
||||||
|
<article className="focus-card panel">
|
||||||
|
<div><p className="eyebrow"><Sparkles size={14} /> Focus score</p><h2>Good momentum.</h2><p>You’re ahead of last week. One focused block will close your highest-impact task.</p></div>
|
||||||
|
<div className="score-ring" aria-label="Focus score 82 out of 100"><strong>82</strong><span>/ 100</span></div>
|
||||||
|
<button className="primary-button"><Play size={15} fill="currentColor" /> Start focus session</button>
|
||||||
|
</article>
|
||||||
|
<article className="metric panel"><span className="metric-icon blue"><CheckCircle2 /></span><div><small>Tasks complete</small><strong>{done + 11}<span>/ 18</span></strong><em><TrendingUp size={13} /> 18% this week</em></div></article>
|
||||||
|
<article className="metric panel"><span className="metric-icon coral"><Flame /></span><div><small>Best streak</small><strong>18 <span>days</span></strong><em>Morning walk</em></div></article>
|
||||||
|
<article className="metric panel"><span className="metric-icon mint"><Clock3 /></span><div><small>Focused time</small><strong>14.5 <span>hrs</span></strong><em><TrendingUp size={13} /> 2.1h vs last week</em></div></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="main-grid">
|
||||||
|
<article className="panel today-panel">
|
||||||
|
<header className="panel-header"><div><p className="eyebrow">Monday, July 13</p><h2>Today’s plan</h2></div><button className="text-button" onClick={() => onNavigate("Tasks")}>All tasks <ChevronRight size={15} /></button></header>
|
||||||
|
<div className="task-list">
|
||||||
|
{tasks.slice(0, 4).map(task => <div className={`task-row ${task.done ? "is-done" : ""}`} key={task.id}>
|
||||||
|
<button className="check-button" onClick={() => onToggleTask(task.id)} aria-label={`${task.done ? "Reopen" : "Complete"} ${task.title}`}>{task.done ? <Check size={15} /> : <Circle size={17} />}</button>
|
||||||
|
<div className="task-copy"><strong>{task.title}</strong><span><i className={`dot ${task.domain.toLowerCase()}`} />{task.project} · {task.time}</span></div>
|
||||||
|
<span className={`priority ${task.priority.toLowerCase()}`}>{task.priority}</span><button className="icon-button" aria-label={`More options for ${task.title}`}><MoreHorizontal size={17} /></button>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<button className="add-row"><Plus size={16} /> Add a task</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel habits-panel">
|
||||||
|
<header className="panel-header"><div><p className="eyebrow">Daily rhythm</p><h2>Habits</h2></div><span className="completion-badge">{Object.values(habitDone).filter(Boolean).length}/4 done</span></header>
|
||||||
|
<div className="habit-list">
|
||||||
|
{habits.map(habit => { const isDone = habitDone[habit.id]; return <button className={`habit-row ${isDone ? "is-done" : ""}`} key={habit.id} onClick={() => onToggleHabit(habit.id)}>
|
||||||
|
<span className={`habit-icon ${habit.color}`}>{habit.icon}</span><span className="habit-copy"><strong>{habit.name}</strong><small><Flame size={12} fill="currentColor" /> {habit.streak} day streak</small></span><span className="habit-check">{isDone && <Check size={15} />}</span>
|
||||||
|
</button>})}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel week-panel">
|
||||||
|
<header className="panel-header"><div><p className="eyebrow">July 7–13</p><h2>Weekly pulse</h2></div><button className="icon-button" aria-label="Weekly pulse options"><MoreHorizontal size={18} /></button></header>
|
||||||
|
<div className="chart-summary"><div><strong>76%</strong><span>completion rate</span></div><em><TrendingUp size={14} /> +8% vs last week</em></div>
|
||||||
|
<div className="bar-chart" aria-label="Weekly completion chart">{week.map((d, i) => <div className="bar-wrap" key={`${d.day}-${i}`}><span className={i === 3 ? "active" : ""} style={{ height: `${d.value}%` }} /><small>{d.day}</small></div>)}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel projects-panel">
|
||||||
|
<header className="panel-header"><div><p className="eyebrow">Across your life</p><h2>Active projects</h2></div><button className="text-button" onClick={() => onNavigate("Projects")}>View all <ChevronRight size={15} /></button></header>
|
||||||
|
<div className="project-list">{projects.map(p => <button className="project-row" key={p.name} onClick={() => onNavigate("Projects")}><span className="project-icon" style={{ background: p.color }}>{p.icon}</span><span className="project-copy"><span><strong>{p.name}</strong><small>{p.tasks} tasks</small></span><Progress value={p.progress} color={p.color} /></span><strong className="project-percent">{p.progress}%</strong></button>)}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="panel agent-panel">
|
||||||
|
<div className="agent-glow"><Bot size={25} /></div><div><p className="eyebrow">Agent workspace</p><h2>Hermes finished a task</h2><p>“Project health brief” is ready to review, with 3 risks and 5 next actions.</p></div><button className="secondary-button" onClick={() => onNavigate("Agent Activity")}>Review result <ChevronRight size={15} /></button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksView({ tasks, onToggle }: { tasks: Task[]; onToggle: (id: number) => void }) {
|
||||||
|
return <section className="view-stack"><div className="view-toolbar"><div className="segmented"><button className="active">Board</button><button>List</button></div><div className="toolbar-actions"><button className="secondary-button"><Search size={15} /> Filter</button><button className="primary-button"><Plus size={15} /> New task</button></div></div><div className="kanban">{(["To do", "In progress", "Done"] as const).map(status => <div className="kanban-column" key={status}><header><span><i className={`status-dot status-${status.replace(" ", "-").toLowerCase()}`} />{status}</span><small>{tasks.filter(t => t.status === status).length}</small><Plus size={15} /></header><div className="kanban-cards">{tasks.filter(t => t.status === status).map(task => <article className={`kanban-card ${task.done ? "is-done" : ""}`} key={task.id}><div className="kanban-top"><GripVertical size={15} /><span className={`priority ${task.priority.toLowerCase()}`}>{task.priority}</span><MoreHorizontal size={16} /></div><button onClick={() => onToggle(task.id)}><span className="task-check">{task.done && <Check size={13} />}</span><strong>{task.title}</strong></button><p>{task.project}</p><footer><span><CalendarDays size={13} /> {task.time}</span><span className={`domain-pill ${task.domain.toLowerCase()}`}>{task.domain}</span></footer></article>)}</div><button className="add-card"><Plus size={15} /> Add task</button></div>)}</div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HabitsView({ habitDone, onToggle }: { habitDone: Record<number, boolean>; onToggle: (id: number) => void }) {
|
||||||
|
return <section className="view-stack"><div className="habit-score-banner panel"><div><p className="eyebrow">Your rhythm</p><h2>Consistency is compounding</h2><p>You completed 86% of scheduled habits in the past 30 days.</p></div><div className="score-ring small"><strong>86</strong><span>score</span></div></div><div className="habit-grid">{habits.map(h => <article className="habit-card panel" key={h.id}><div className="habit-card-top"><span className={`habit-icon large ${h.color}`}>{h.icon}</span><button className="icon-button"><MoreHorizontal size={18} /></button></div><h3>{h.name}</h3><p>Every day · Personal</p><div className="streak-line"><Flame size={18} fill="currentColor" /><strong>{h.streak}</strong><span>day streak</span></div><div className="mini-heat">{Array.from({ length: 28 }).map((_, i) => <i className={i % 7 === 5 || i > 24 ? "low" : i % 5 === 0 ? "mid" : "high"} key={i} />)}</div><button className={`habit-complete ${habitDone[h.id] ? "done" : ""}`} onClick={() => onToggle(h.id)}>{habitDone[h.id] ? <><Check size={16} /> Completed today</> : <><Circle size={16} /> Mark complete</>}</button></article>)}</div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectsView() {
|
||||||
|
return <section className="view-stack"><div className="view-toolbar"><div className="filter-pills"><button className="active">Active <span>3</span></button><button>Paused <span>1</span></button><button>Archived <span>6</span></button></div><button className="primary-button"><Plus size={15} /> New project</button></div><div className="projects-grid">{projects.map((p, index) => <article className="project-card panel" key={p.name}><header><span className="project-icon large" style={{ background: p.color }}>{p.icon}</span><span className={`domain-pill ${p.domain.toLowerCase()}`}>{p.domain}</span><button className="icon-button"><MoreHorizontal size={18} /></button></header><h2>{p.name}</h2><p>{index === 0 ? "A calm, agent-native system for every commitment." : index === 1 ? "Make the storefront and campaigns work as one growth engine." : "Build routines and systems that create more spacious days."}</p><div className="project-meta"><span><strong>{p.progress}%</strong> complete</span><span>{p.tasks} tasks</span></div><Progress value={p.progress} color={p.color} /><footer><span><CalendarDays size={14} /> {p.due}</span><span className="avatar-stack"><i>M</i><i>H</i></span></footer></article>)}</div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotesView() {
|
||||||
|
const notes = ["Project E — product principles", "July operating review", "OTS campaign concepts", "Reading notes: Slow Productivity"];
|
||||||
|
const [selected, setSelected] = useState(notes[0]);
|
||||||
|
return <section className="notes-shell panel"><aside><header><strong>Notes</strong><button className="icon-button"><Plus size={17} /></button></header><label className="notes-search"><Search size={14} /><input placeholder="Search notes…" /></label>{notes.map((n, i) => <button key={n} className={selected === n ? "active" : ""} onClick={() => setSelected(n)}><strong>{n}</strong><span>{i === 0 ? "Updated 18m ago" : `${i + 1} days ago`}</span></button>)}</aside><article className="note-editor"><header><div className="breadcrumb">Notes <ChevronRight size={13} /> <span>Work</span></div><div><button className="icon-button"><Bot size={17} /></button><button className="icon-button"><MoreHorizontal size={17} /></button></div></header><div className="note-body"><p className="eyebrow"># project-e · work · strategy</p><h1>{selected}</h1><p className="note-lede">A personal operating system should reduce cognitive weight, not become another system to maintain.</p><h2>North star</h2><p>Project E brings tasks, habits, projects, and knowledge into one calm surface. The model stays flexible; views do the organizing.</p><blockquote><Sparkles size={18} /> Design for the moment a person returns after a chaotic week. The system should make the next step obvious.</blockquote><h2>Working principles</h2><ul><li><span>01</span> Three equal pillars, never one buried inside another.</li><li><span>02</span> Capture quickly, organize progressively.</li><li><span>03</span> External agents operate through explicit permissions.</li></ul></div></article></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportsView() {
|
||||||
|
const items = [{ icon: BarChart3, title: "Weekly Summary", sub: "Jul 7–13", color: "blue" }, { icon: CalendarDays, title: "Monthly Review", sub: "June 2026", color: "mint" }, { icon: Target, title: "Project Health", sub: "Project E", color: "coral" }, { icon: Flame, title: "Habit Analysis", sub: "Last 30 days", color: "amber" }];
|
||||||
|
return <section className="view-stack"><div className="report-hero panel"><div><p className="eyebrow">Reports</p><h2>Turn activity into perspective.</h2><p>Generate a structured review from your actual work, habits, and time.</p></div><button className="primary-button"><Sparkles size={15} /> Generate report</button></div><div className="report-grid">{items.map(({ icon: Icon, ...r }) => <article className="report-card panel" key={r.title}><span className={`metric-icon ${r.color}`}><Icon /></span><div><h3>{r.title}</h3><p>{r.sub}</p></div><span className="report-status">Ready</span><button className="text-button">Open <ChevronRight size={14} /></button></article>)}</div><article className="panel recent-table"><header className="panel-header"><h2>Recent reports</h2><button className="text-button">View archive</button></header>{["Project E weekly health", "June time audit", "OTS campaign retrospective"].map((name, i) => <div className="table-row" key={name}><span className="file-icon"><FileBarChart size={17} /></span><strong>{name}</strong><span>{i === 0 ? "Weekly" : i === 1 ? "Time audit" : "Custom"}</span><span>Jul {13 - i * 4}</span><button className="icon-button"><MoreHorizontal size={17} /></button></div>)}</article></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarView() {
|
||||||
|
const days = Array.from({ length: 35 }, (_, i) => i - 1);
|
||||||
|
return <section className="calendar-shell panel"><header><div><button className="secondary-button">Today</button><button className="icon-button">‹</button><button className="icon-button">›</button><h2>July 2026</h2></div><div className="segmented"><button className="active">Month</button><button>Week</button><button>Day</button></div></header><div className="calendar-grid">{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => <strong className="weekday" key={d}>{d}</strong>)}{days.map((d, i) => <div className={`calendar-day ${d === 13 ? "today" : ""} ${d < 1 || d > 31 ? "muted" : ""}`} key={i}><span>{d < 1 ? 29 + d : d > 31 ? d - 31 : d}</span>{d === 13 && <><em className="event work">9:00 Product brief</em><em className="event ots">11:30 OTS copy</em><em className="event personal">4:00 Strength</em></>}{d === 15 && <em className="event work">Project milestone</em>}{d === 17 && <em className="event personal">Trip planning</em>}</div>)}</div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnalyticsView() {
|
||||||
|
return <section className="view-stack"><div className="analytics-metrics"><article className="panel"><small>Completion rate</small><strong>76%</strong><span className="up"><TrendingUp size={14} /> 8.2%</span></article><article className="panel"><small>Habit consistency</small><strong>86%</strong><span className="up"><TrendingUp size={14} /> 4.1%</span></article><article className="panel"><small>Deep work</small><strong>14.5h</strong><span className="up"><TrendingUp size={14} /> 2.1h</span></article><article className="panel"><small>Active streaks</small><strong>4</strong><span>Best: 18 days</span></article></div><div className="analytics-grid"><article className="panel trend-card"><header className="panel-header"><div><p className="eyebrow">12 week view</p><h2>Productivity trend</h2></div><div className="segmented"><button className="active">Tasks</button><button>Habits</button></div></header><div className="area-chart">{[35, 47, 42, 58, 54, 69, 63, 77, 72, 88, 80, 92].map((v, i) => <i key={i} style={{ height: `${v}%` }}><span>{v}</span></i>)}</div></article><article className="panel distribution-card"><p className="eyebrow">Time distribution</p><h2>Where time went</h2><div className="donut"><div><strong>14.5h</strong><span>tracked</span></div></div><ul><li><i className="work" />Work <strong>48%</strong></li><li><i className="ots" />OTS <strong>31%</strong></li><li><i className="personal" />Personal <strong>21%</strong></li></ul></article></div></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentView() {
|
||||||
|
const events = [{ type: "done", icon: Check, title: "Completed “Project health brief”", agent: "Hermes", time: "12 minutes ago", text: "Created a report with 3 risks, 5 actions, and a milestone forecast." }, { type: "working", icon: Activity, title: "Analyzing OTS campaign performance", agent: "Hermes", time: "Started 28 minutes ago", text: "Reviewing 14 tasks and 3 linked notes." }, { type: "created", icon: Plus, title: "Created note “July research synthesis”", agent: "Claude", time: "Yesterday, 4:18 PM", text: "Generated from 8 source notes with backlinks preserved." }];
|
||||||
|
return <section className="agent-layout"><article className="panel agent-summary"><div className="agent-avatar"><Bot size={28} /></div><div><p className="eyebrow">Active agent</p><h2>Hermes</h2><p>Planning and analysis partner · Full access</p></div><span className="online"><i /> Online</span><div className="agent-stats"><span><strong>24</strong>actions</span><span><strong>8</strong>tasks</span><span><strong>4</strong>reports</span></div><button className="secondary-button">Open workspace</button></article><article className="panel activity-feed"><header className="panel-header"><div><p className="eyebrow">Audit trail</p><h2>Agent activity</h2></div><button className="secondary-button">All agents <ChevronDown size={14} /></button></header>{events.map(({ icon: Icon, ...event }) => <div className="activity-row" key={event.title}><span className={`activity-icon ${event.type}`}><Icon size={16} /></span><div><strong>{event.title}</strong><p>{event.text}</p><span>{event.agent} · {event.time}</span></div><button className="text-button">Details</button></div>)}</article></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsView() {
|
||||||
|
return <section className="settings-layout"><aside className="panel"><button className="active">Appearance</button><button>Domains</button><button>Keyboard shortcuts</button><button>Agents & permissions</button><button>Webhooks</button><button>Import & export</button></aside><article className="panel settings-content"><p className="eyebrow">Preferences</p><h2>Appearance</h2><p>Make Project E feel right for the way you work.</p><div className="setting-row"><div><strong>Color mode</strong><span>Use a light or dark interface.</span></div><div className="segmented"><button className="active">Light</button><button>Dark</button><button>System</button></div></div><div className="setting-row"><div><strong>Accent color</strong><span>Used for actions and progress.</span></div><div className="swatches"><button className="active blue" aria-label="Blue" /><button className="green" aria-label="Green" /><button className="coral" aria-label="Coral" /><button className="violet" aria-label="Violet" /></div></div><div className="setting-row"><div><strong>Density</strong><span>Control information spacing.</span></div><div className="segmented"><button>Compact</button><button className="active">Comfortable</button><button>Spacious</button></div></div><div className="setting-row"><div><strong>Reduced motion</strong><span>Minimize interface animation.</span></div><button className="switch" aria-label="Toggle reduced motion"><span /></button></div></article></section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPalette({ onClose, onNavigate }: { onClose: () => void; onNavigate: (v: View) => void }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const results = nav.filter(n => n.label.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
return <div className="modal-backdrop" onMouseDown={onClose}><div className="command-modal" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e => e.stopPropagation()}><label><Search size={19} /><input autoFocus value={query} onChange={e => setQuery(e.target.value)} placeholder="Search Project E or run a command…" /><kbd>esc</kbd></label><div className="command-results"><p>Jump to</p>{results.map(({ label, icon: Icon }) => <button key={label} onClick={() => { onNavigate(label); onClose(); }}><Icon size={17} /><span>{label}</span><kbd>↵</kbd></button>)}</div><footer><span><kbd>↑</kbd><kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> open</span></footer></div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectEApp() {
|
||||||
|
const [view, setView] = useState<View>("Dashboard");
|
||||||
|
const [tasks, setTasks] = useState(initialTasks);
|
||||||
|
const [habitDone, setHabitDone] = useState<Record<number, boolean>>({ 1: true, 2: false, 3: false, 4: false });
|
||||||
|
const [domain, setDomain] = useState("All domains");
|
||||||
|
const [commandOpen, setCommandOpen] = useState(false);
|
||||||
|
const [mobileNav, setMobileNav] = useState(false);
|
||||||
|
const [timerActive, setTimerActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
|
||||||
|
if (event.key === "Escape") { setCommandOpen(false); setMobileNav(false); }
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredTasks = useMemo(() => domain === "All domains" ? tasks : tasks.filter(t => t.domain === domain), [tasks, domain]);
|
||||||
|
const toggleTask = (id: number) => setTasks(current => current.map(t => t.id === id ? { ...t, done: !t.done, status: !t.done ? "Done" : "To do" } : t));
|
||||||
|
const navigate = (next: View) => { setView(next); setMobileNav(false); window.scrollTo({ top: 0, behavior: "smooth" }); };
|
||||||
|
|
||||||
|
let content;
|
||||||
|
if (view === "Dashboard") content = <Dashboard tasks={filteredTasks} onToggleTask={toggleTask} habitDone={habitDone} onToggleHabit={id => setHabitDone(v => ({ ...v, [id]: !v[id] }))} onNavigate={navigate} />;
|
||||||
|
else if (view === "Tasks") content = <TasksView tasks={filteredTasks} onToggle={toggleTask} />;
|
||||||
|
else if (view === "Habits") content = <HabitsView habitDone={habitDone} onToggle={id => setHabitDone(v => ({ ...v, [id]: !v[id] }))} />;
|
||||||
|
else if (view === "Projects") content = <ProjectsView />;
|
||||||
|
else if (view === "Notes") content = <NotesView />;
|
||||||
|
else if (view === "Reports") content = <ReportsView />;
|
||||||
|
else if (view === "Calendar") content = <CalendarView />;
|
||||||
|
else if (view === "Analytics") content = <AnalyticsView />;
|
||||||
|
else if (view === "Agent Activity") content = <AgentView />;
|
||||||
|
else content = <SettingsView />;
|
||||||
|
|
||||||
|
return <div className="app-shell">
|
||||||
|
<a href="#main" className="skip-link">Skip to content</a>
|
||||||
|
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
|
||||||
|
<div className="sidebar-top"><Logo /><button className="close-mobile icon-button" onClick={() => setMobileNav(false)} aria-label="Close navigation"><X size={19} /></button></div>
|
||||||
|
<nav aria-label="Main navigation">{nav.slice(0, 8).map(({ label, icon: Icon }) => <button key={label} className={view === label ? "active" : ""} onClick={() => navigate(label)}><Icon size={18} /><span>{label}</span>{label === "Tasks" && <em>6</em>}</button>)}</nav>
|
||||||
|
<div className="sidebar-section"><p>Workspace</p>{nav.slice(8).map(({ label, icon: Icon }) => <button key={label} className={view === label ? "active" : ""} onClick={() => navigate(label)}><Icon size={18} /><span>{label}</span>{label === "Agent Activity" && <i className="live-dot" />}</button>)}</div>
|
||||||
|
<button className={`focus-mini ${timerActive ? "active" : ""}`} onClick={() => setTimerActive(v => !v)}><span className="focus-mini-icon">{timerActive ? <Pause size={17} fill="currentColor" /> : <TimerReset size={18} />}</span><span><strong>{timerActive ? "24:32" : "Focus timer"}</strong><small>{timerActive ? "Project E brief" : "Ready when you are"}</small></span><ChevronRight size={15} /></button>
|
||||||
|
<div className="profile"><span className="profile-avatar">ME</span><span><strong>Matt</strong><small>Personal workspace</small></span><MoreHorizontal size={17} /></div>
|
||||||
|
</aside>
|
||||||
|
{mobileNav && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileNav(false)} />}
|
||||||
|
<div className="workspace">
|
||||||
|
<header className="topbar"><button className="mobile-menu icon-button" onClick={() => setMobileNav(true)} aria-label="Open navigation"><Menu size={20} /></button><button className="command-trigger" onClick={() => setCommandOpen(true)}><Search size={16} /><span>Search or jump to…</span><kbd><Command size={11} /> K</kbd></button><div className="top-actions"><label className="domain-select"><span className={`dot ${domain === "Work" ? "work" : domain === "OTS" ? "ots" : domain === "Personal" ? "personal" : "all"}`} /><select value={domain} onChange={e => setDomain(e.target.value)} aria-label="Filter by domain"><option>All domains</option><option>Personal</option><option>Work</option><option>OTS</option></select><ChevronDown size={13} /></label><button className="icon-button notification" aria-label="Notifications"><Bell size={18} /><i /></button><button className="quick-add" onClick={() => setCommandOpen(true)}><Plus size={17} /> <span>Quick add</span></button></div></header>
|
||||||
|
<main id="main"><header className="page-heading"><div><p className="eyebrow">{view === "Dashboard" ? "Your day, at a glance" : "Project E"}</p><h1>{view === "Dashboard" ? "Good morning, Matt." : view}</h1><p>{view === "Dashboard" ? "Monday, July 13 · You have 3 priorities and 4 habits today." : view === "Tasks" ? "Move work forward without losing the thread." : view === "Habits" ? "Small actions, visible momentum." : view === "Projects" ? "Every outcome has a home." : view === "Notes" ? "Connect ideas to the work they shape." : view === "Reports" ? "Step back and see what changed." : view === "Calendar" ? "Your commitments, in time." : view === "Analytics" ? "Patterns behind your progress." : view === "Agent Activity" ? "Every agent action, visible and reversible." : "Tune Project E to fit your work."}</p></div>{view !== "Dashboard" && view !== "Settings" && <button className="primary-button"><Plus size={15} /> New {view === "Agent Activity" ? "agent" : view.replace(/s$/, "").toLowerCase()}</button>}</header>{content}</main>
|
||||||
|
</div>
|
||||||
|
{commandOpen && <CommandPalette onClose={() => setCommandOpen(false)} onNavigate={navigate} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { access, cp, mkdir, rm } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
async function exists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages Sites metadata and migrations after Vite finishes compiling.
|
||||||
|
export function sites(): Plugin {
|
||||||
|
let root = process.cwd();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "sites",
|
||||||
|
apply: "build",
|
||||||
|
configResolved(config) {
|
||||||
|
root = config.root;
|
||||||
|
},
|
||||||
|
async closeBundle() {
|
||||||
|
const outputDirectory = resolve(root, "dist", ".openai");
|
||||||
|
const hostingConfig = resolve(root, ".openai", "hosting.json");
|
||||||
|
const drizzleSource = resolve(root, "drizzle");
|
||||||
|
|
||||||
|
await rm(outputDirectory, { recursive: true, force: true });
|
||||||
|
await mkdir(outputDirectory, { recursive: true });
|
||||||
|
|
||||||
|
if (await exists(hostingConfig)) {
|
||||||
|
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
|
||||||
|
}
|
||||||
|
if (await exists(drizzleSource)) {
|
||||||
|
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { drizzle } from "drizzle-orm/d1";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
export function getDb() {
|
||||||
|
if (!env.DB) {
|
||||||
|
throw new Error(
|
||||||
|
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return drizzle(env.DB, { schema });
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Intentionally empty by default.
|
||||||
|
// Add Drizzle tables here when the site actually needs a database.
|
||||||
|
// See examples/d1/db/schema.ts for an opt-in example.
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: "./drizzle",
|
||||||
|
schema: "./db/schema.ts",
|
||||||
|
dialect: "sqlite",
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
import { getDb } from "../../../../../db";
|
||||||
|
import { notes } from "../../../db/schema";
|
||||||
|
|
||||||
|
function toRouteErrorMessage(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unexpected error";
|
||||||
|
const detail =
|
||||||
|
error instanceof Error && error.cause instanceof Error ? error.cause.message : "";
|
||||||
|
const combined = `${message}\n${detail}`;
|
||||||
|
|
||||||
|
if (combined.includes("no such table") || combined.includes('from "notes"')) {
|
||||||
|
return "The notes table is unavailable. Generate the migration locally with `npm run db:generate`, then deploy so the platform can apply the generated SQL to the real D1 database.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.orderBy(desc(notes.createdAt), desc(notes.id))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return Response.json({ notes: rows });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: toRouteErrorMessage(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const payload = (await request.json()) as {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
const title = payload.title?.trim() ?? "";
|
||||||
|
const content = payload.content?.trim() ?? "";
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return Response.json({ error: "title is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const [note] = await db.insert(notes).values({ title, content }).returning();
|
||||||
|
return Response.json({ note }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: toRouteErrorMessage(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const notes = sqliteTable("notes", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
content: text("content").notNull().default(""),
|
||||||
|
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+11179
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "site-creator-vinext-starter",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
|
||||||
|
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
|
||||||
|
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
|
||||||
|
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||||
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
|
||||||
|
"db:generate": "drizzle-kit generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"drizzle-orm": "0.45.2",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "1.37.1",
|
||||||
|
"@tailwindcss/postcss": "4.2.1",
|
||||||
|
"@types/node": "22.19.19",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"@vitejs/plugin-rsc": "0.5.26",
|
||||||
|
"drizzle-kit": "0.31.10",
|
||||||
|
"eslint": "9.39.4",
|
||||||
|
"eslint-config-next": "16.2.6",
|
||||||
|
"react-server-dom-webpack": "19.2.6",
|
||||||
|
"tailwindcss": "4.2.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vinext": "0.0.50",
|
||||||
|
"vite": "8.0.13",
|
||||||
|
"wrangler": "4.92.0"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
|
||||||
|
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
|
||||||
|
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
|
||||||
|
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 712 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { access, readFile, readdir } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const developmentPreviewMeta =
|
||||||
|
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
|
||||||
|
const templateRoot = new URL("../", import.meta.url);
|
||||||
|
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||||
|
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||||
|
const { default: worker } = await import(workerUrl.href);
|
||||||
|
|
||||||
|
return worker.fetch(
|
||||||
|
new Request("http://localhost/", {
|
||||||
|
headers: { accept: "text/html" },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
ASSETS: {
|
||||||
|
fetch: async () => new Response("Not found", { status: 404 }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
waitUntil() {},
|
||||||
|
passThroughOnException() {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("server-renders the starter loading skeleton", async () => {
|
||||||
|
const response = await render();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
assert.match(html, developmentPreviewMeta);
|
||||||
|
assert.match(html, /<title>Your site is taking shape<\/title>/i);
|
||||||
|
assert.match(html, /Codex is working/);
|
||||||
|
assert.match(html, /Your site is taking shape/);
|
||||||
|
assert.match(html, /Codex is building the first version/);
|
||||||
|
assert.match(html, /react-loading-skeleton/);
|
||||||
|
assert.match(html, /role="status"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the loading skeleton scoped and disposable", async () => {
|
||||||
|
const [preview, css, page, layout, packageJson, files] = await Promise.all([
|
||||||
|
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
|
||||||
|
readFile(new URL("preview.css", previewRoot), "utf8"),
|
||||||
|
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||||
|
readdir(previewRoot),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]);
|
||||||
|
assert.match(preview, /from "react-loading-skeleton"/);
|
||||||
|
assert.match(preview, /baseColor="#eceae7"/);
|
||||||
|
assert.match(preview, /highlightColor="#f9f8f6"/);
|
||||||
|
assert.match(preview, /duration=\{2\.8\}/);
|
||||||
|
assert.match(preview, /sites-skeleton-search-placeholder/);
|
||||||
|
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/);
|
||||||
|
|
||||||
|
const shellIndex = preview.indexOf('className="sites-skeleton-shell"');
|
||||||
|
const statusIndex = preview.indexOf('className="sites-skeleton-status"');
|
||||||
|
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
|
||||||
|
assert.match(css, /position:\s*fixed/);
|
||||||
|
assert.match(css, /inset:\s*0/);
|
||||||
|
assert.match(css, /opacity:\s*0\.52/);
|
||||||
|
assert.match(css, /prefers-reduced-motion:\s*reduce/);
|
||||||
|
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
preview,
|
||||||
|
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(page, /export const metadata:\s*Metadata/);
|
||||||
|
assert.match(page, /"codex-preview": "development"/);
|
||||||
|
assert.match(page, /<SkeletonPreview \/>/);
|
||||||
|
assert.match(layout, /title:\s*"Starter Project"/);
|
||||||
|
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
|
||||||
|
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
access(new URL("public/_sites-preview", templateRoot)),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import vinext from "vinext";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import hostingConfig from "./.openai/hosting.json";
|
||||||
|
import { sites } from "./build/sites-vite-plugin";
|
||||||
|
|
||||||
|
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||||
|
"00000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
|
const { d1, r2 } = hostingConfig;
|
||||||
|
|
||||||
|
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||||
|
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||||
|
|
||||||
|
const localBindingConfig = {
|
||||||
|
main: "./worker/index.ts",
|
||||||
|
compatibility_flags: ["nodejs_compat"],
|
||||||
|
d1_databases: d1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
binding: d1,
|
||||||
|
database_name: "site-creator-d1",
|
||||||
|
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
r2_buckets: r2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
binding: r2,
|
||||||
|
bucket_name: "site-creator-r2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig(async () => {
|
||||||
|
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||||
|
// settings; application environment belongs in ignored `.env*` files.
|
||||||
|
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||||
|
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||||
|
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||||
|
|
||||||
|
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||||
|
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||||
|
|
||||||
|
return {
|
||||||
|
server: isCodexSeatbeltSandbox
|
||||||
|
? { watch: { useFsEvents: false, usePolling: true } }
|
||||||
|
: undefined,
|
||||||
|
plugins: [
|
||||||
|
vinext(),
|
||||||
|
sites(),
|
||||||
|
cloudflare({
|
||||||
|
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||||
|
config: localBindingConfig,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** Cloudflare Worker entry point for the vinext-starter template. */
|
||||||
|
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
|
||||||
|
import handler from "vinext/server/app-router-entry";
|
||||||
|
|
||||||
|
interface Env {
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
DB: D1Database;
|
||||||
|
IMAGES: {
|
||||||
|
input(stream: ReadableStream): {
|
||||||
|
transform(options: Record<string, unknown>): {
|
||||||
|
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionContext {
|
||||||
|
waitUntil(promise: Promise<unknown>): void;
|
||||||
|
passThroughOnException(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image security config. SVG sources with .svg extension auto-skip the
|
||||||
|
// optimization endpoint on the client side (served directly, no proxy).
|
||||||
|
// To route SVGs through the optimizer (with security headers), set
|
||||||
|
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
|
||||||
|
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
|
||||||
|
|
||||||
|
const worker = {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/_vinext/image") {
|
||||||
|
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||||
|
return handleImageOptimization(request, {
|
||||||
|
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||||
|
transformImage: async (body, { width, format, quality }) => {
|
||||||
|
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
|
||||||
|
return result.response();
|
||||||
|
},
|
||||||
|
}, allowedWidths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.fetch(request, env, ctx);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default worker;
|
||||||
Reference in New Issue
Block a user