commit 5c63f9f120fe19a9923727385846aad1d92aa57c Author: Codex Date: Sun Sep 13 23:47:24 2026 +0000 Create CET-6 90-day check-in site diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8f3962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# 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/ +# Checkout-local execution profile and tool state; never publish them. +/.sites-runtime/ +/.agents/ +/.codex/ +/outputs/ +/work/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9f274d6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..bdb0d31 --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6aa7343054c481919c320f9bcb1ac798", + "d1": "DB", + "r2": null +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3caca1b --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# 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` +- Portable: Windows, macOS, or Linux; no Bash required +- Managed Linux: managed Linux runtime with Bash, `flock`, `curl`, `sha256sum`, and GNU `timeout` +- Git is required only for publishing + +## Sites Lifecycle + +The Sites initializer copies the shared starter and selects managed-linux only when `SITES_MANAGED_LINUX_CONTAINER=1`; otherwise it selects portable. It saves the selection only in ignored `.sites-runtime/execution-profile.json`. Both profiles copy/configure first, then use the plugin's separate `install-dependencies.mjs` step to measure installation independently. Edit source under `app/` and follow the Sites skill for installation, preview, builds, and publishing. + +Whenever reopening or moving a checkout, run `node /scripts/configure-execution-profile.mjs` before project commands. Profile changes do not alter tracked source or require reinstalling otherwise-valid dependencies; restart an existing preview to use the new selection. Do not commit or upload `.sites-runtime/`. + +This starter does not use `wrangler.jsonc`. + +`install:ci` runs `npm ci` once against the shared lockfile, disables parent-workspace discovery, and includes required dev/optional dependencies despite production/omit settings. Sharp defaults to prebuilt binaries unless explicitly configured otherwise. Do not overlap installers. + +- **Portable:** Preserve host HOME, npm cache, registry, proxy, temporary paths, retry/concurrency settings, and lifecycle-script policy. Use `--prefer-offline --no-audit --no-fund`. +- **Managed Linux:** Use the existing project-local HOME/cache/tmp setup and Linux install lock, tarball preflight, and timeout. Restore the image-seeded npm cache only when its lockfile hash matches; retain network fallback. Builds keep their existing timeout. These helpers are not invoked by the portable profile. + +`scripts/sites-env.mjs` preserves the caller's HOME, npm cache, proxy, XDG, and temporary-directory configuration while defaulting Wrangler and Miniflare state to the checkout. If npm reports an unwritable cache, select a writable path with `npm_config_cache` for that install. The `dev` and `start` scripts also keep Wrangler logs inside the checkout. Generated `.sites-runtime/` and `.wrangler/` directories are disposable and ignored by Git. + +On portable, `npm run dev` uses `vinext dev` with HMR, starting at port 5173. Vinext records the running server in ignored `.vinext/` state, rejects an ordinary duplicate launch, and recovers stale state after a stopped process; exactly simultaneous starts can race. Pass `--port ` or `--hostname ` after `npm run dev --` when needed; keep portable previews on loopback. + +On managed Linux, use `sites-preview start` only for requested browser QA. The project's dev script runs Vite and accepts the supervisor's `--host 0.0.0.0 --port 4173 --strictPort` arguments. The internal browser uses `http://terminal.local:4173/`; it is not a user-facing URL. The supervisor owns the preview lifecycle. The ignored local profile survives the supervisor's cleared process environment. + +The portable profile simulates ChatGPT sign-in only for loopback development requests. Visit `/signin-with-chatgpt?return_to=/` to sign in as `local_seedy` (`seedy@sites.test`, display name `Seedy`) and `/signout-with-chatgpt?return_to=/` to sign out. The development cookie preserves that identity across server restarts. Mock auth is disabled in the managed-linux profile and is not included in production builds; hosted authentication remains dispatch-owned. + +The Worker uses `vinext/server/fetch-handler`, including Vinext's config-aware image handling. After building, `npm start` runs that Worker locally through Wrangler on `127.0.0.1`, sharing `.wrangler/state` with dev preview and local D1 migrations; it does not deploy the site or simulate sign-in. Use the URL printed by the server. Pass `npm start -- --port ` to select a different built-preview port. + +Local previews use Miniflare's placeholder `Request.cf` metadata without a network lookup. Set `CLOUDFLARE_CF_FETCH_ENABLED=true` to opt into fetching preview metadata; this setting does not change hosted request metadata. + +Local tool usage metrics are disabled by default. Set `WRANGLER_SEND_METRICS=true` to opt in. + +## Included Shape + +- edit site code under `app/` +- `app/chatgpt-auth.ts` provides optional dispatch-owned ChatGPT sign-in helpers +- `.openai/hosting.json` declares optional Sites D1 and R2 bindings +- `vite.config.ts` simulates declared bindings for local development +- `db/index.ts` reads the D1 binding from the Cloudflare Worker environment +- `db/schema.ts` starts intentionally empty +- `@cloudflare/workers-types` provides Worker types; `cloudflare-env.d.ts` declares optional `DB`/`BUCKET` bindings—update these declarations if binding names change +- `examples/d1/` contains an optional D1 example surface +- `drizzle.config.ts` supports local migration generation when needed + +## Workspace Auth Headers + +Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present. + +The user ID is stable for the same user on the same Site and different across Sites. Use it as the durable user key; use email and name for display or contact purposes. + +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 userId = requestHeaders.get("oai-authenticated-user-id"); + 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 the returned `userId` as the stable user key for user-owned records; do not use email as a durable identifier. +- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send anonymous visitors through Sign in with ChatGPT. +- In a Server Component, start sign-in with ``. The auth helper module is server-only; do not import it into a Client Component. +- Do not use `fetch`, XHR, a client-side router, or a framework link that can prefetch the sign-in route. SIWC must start as a top-level navigation. +- Never request the AuthAPI authorization endpoint directly. The dispatch-owned `/signin-with-chatgpt` route must start the SIWC flow. +- Use `chatGPTSignOutPath(returnTo)` for browser sign-out 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. + +## Local D1 migrations + +For a D1-backed local preview, generate SQL with `npm run db:generate`. Build once through the Sites skill's build entrypoint (or `npm run build` for standalone use) to generate `dist/server/wrangler.json`, rebuilding if bindings change. From the project root, apply each pending migration in order: + +```sh +node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js d1 execute DB --local --config dist/server/wrangler.json --persist-to .wrangler/state --file drizzle/0000_example.sql +``` + +Replace the filename with the pending migration and `DB` with your D1 binding name if different. Use `.wrangler/state`, not `.wrangler/state/v3`; Wrangler adds the versioned directories. Do not replay migrations already applied locally. This updates only the preview database; publishing applies production migrations separately. + +## Diagnostic Commands + +- `npm run install:ci`: perform the one locked dependency install +- `npm run dev`: start the Vite/Vinext development server +- `npm run build`: build the deployable Sites artifact +- `npm run start`: preview the built Worker locally with D1/R2 support +- `npm run db:generate`: generate Drizzle migrations after schema changes + +When using the Sites plugin, follow its skill instructions for installation, builds, and publishing. These npm commands remain available for standalone use. + +The portable build runs Vinext directly without a host `timeout` command. The managed-linux build uses `scripts/build-verified.sh` and its existing `SITES_BUILD_TIMEOUT` setting. + +## Learn More + +- [vinext Documentation](https://github.com/cloudflare/vinext) +- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts new file mode 100644 index 0000000..b067302 --- /dev/null +++ b/app/api/progress/route.ts @@ -0,0 +1,6 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "../../../db"; +import { progress } from "../../../db/schema"; +const user=(r:Request)=>r.headers.get("oai-authenticated-user-id"); +export async function GET(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再使用打卡功能。"},{status:401});try{return Response.json({progress:await getDb().select().from(progress).where(eq(progress.userId,id))});}catch{return Response.json({error:"暂时无法读取云端进度。"},{status:500});}} +export async function PUT(r:Request){const id=user(r);if(!id)return Response.json({error:"请登录后再保存打卡。"},{status:401});const b=await r.json() as {date?:string;doneTasks?:string[];mood?:string;note?:string};if(!/^2026-(09|10|11|12)-\d{2}$/.test(b.date||"")||!Array.isArray(b.doneTasks))return Response.json({error:"提交的数据格式不正确。"},{status:400});try{const db=getDb(), values={userId:id,planDate:b.date!,doneTasks:JSON.stringify(b.doneTasks),mood:(b.mood||"").slice(0,20),note:(b.note||"").slice(0,1000),updatedAt:new Date().toISOString()};await db.insert(progress).values(values).onConflictDoUpdate({target:[progress.userId,progress.planDate],set:values});const row=await db.select().from(progress).where(and(eq(progress.userId,id),eq(progress.planDate,b.date!)));return Response.json({progress:row[0]});}catch{return Response.json({error:"暂时无法保存到云端,请稍后重试。"},{status:500});}} diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts new file mode 100644 index 0000000..a0ae2ed --- /dev/null +++ b/app/chatgpt-auth.ts @@ -0,0 +1,90 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + userId: string; + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_ID_HEADER = "oai-authenticated-user-id"; +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 { + const requestHeaders = await headers(); + const userId = requestHeaders.get(USER_ID_HEADER); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!userId || !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 { + userId, + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + 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; + } +} diff --git a/app/checkin-client.tsx b/app/checkin-client.tsx new file mode 100644 index 0000000..778af6b --- /dev/null +++ b/app/checkin-client.tsx @@ -0,0 +1,13 @@ +"use client"; +import {useEffect,useMemo,useState} from "react"; +import {CalendarDays,CheckCircle2,Cloud,Flame,ChevronLeft,ChevronRight} from "lucide-react"; +import {Checkbox} from "@/components/ui/checkbox"; +import {Progress} from "@/components/ui/progress"; +import {daysBetween,plan} from "./plan"; +type Record={doneTasks:string;mood:string;note:string}; +const fmt=(s:string)=>new Intl.DateTimeFormat("zh-CN",{month:"long",day:"numeric",weekday:"short",timeZone:"UTC"}).format(new Date(s+"T00:00:00Z")); +export default function CheckinClient(){const [selected,setSelected]=useState(Math.max(0,Math.min(89,daysBetween("2026-09-13"))));const [records,setRecords]=useState>({});const [status,setStatus]=useState("正在读取云端进度…");const [saving,setSaving]=useState(false);const day=plan[selected],record=records[day.date]||{doneTasks:"[]",mood:"",note:""};const done=useMemo(()=>{try{return JSON.parse(record.doneTasks)as string[]}catch{return[]}},[record.doneTasks]);const total=plan.reduce((n,p)=>n+p.tasks.length,0);const finished=Object.values(records).reduce((n,r)=>{try{return n+JSON.parse(r.doneTasks).length}catch{return n}},0);const completeDays=plan.filter(p=>{try{return JSON.parse(records[p.date]?.doneTasks||"[]").length===p.tasks.length}catch{return false}}).length;useEffect(()=>{fetch("/api/progress").then(async r=>{const x=await r.json();if(!r.ok)throw Error(x.error);const next:Record={};x.progress.forEach((p:Record&{planDate:string})=>next[p.planDate]=p);setRecords(next);setStatus("云端同步已开启");}).catch(e=>setStatus(e.message));},[]); +async function save(next:Record){setRecords(x=>({...x,[day.date]:next}));setSaving(true);try{const r=await fetch("/api/progress",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({date:day.date,doneTasks:JSON.parse(next.doneTasks),mood:next.mood,note:next.note})});const x=await r.json();if(!r.ok)throw Error(x.error);setRecords(v=>({...v,[day.date]:x.progress}));setStatus("已保存到云端");}catch(e){setStatus(e instanceof Error?e.message:"保存失败");}finally{setSaving(false);}} +const toggle=(key:string,on:boolean)=>void save({...record,doneTasks:JSON.stringify(on?[...done,key]:done.filter(x=>x!==key))}); +return

CET-6 · 2026

大学英语六级 · 90天打卡

从今天的一件事开始,完成比完美重要。

{saving?"正在云端保存…":status}

} value={Math.round(finished/total*100)+"%"} label="总体完成度" detail={finished+" / "+total+" 项任务"}/>} value={completeDays+" 天"} label="完整完成" detail="目标共 90 天"/>} value={completeDays?"保持中":"0 天"} label="连续打卡" detail="按自己的节奏来"/>} value={day.phase} label="当前阶段" detail="12 月 12 日笔试"/>

{day.phase} · {day.week}

{fmt(day.date)}

已完成 {done.length} / {day.tasks.length} 项
{day.tasks.map(t=>)}

今日状态

{["专注","平稳","有点累","需要调整"].map(m=>)}