From 5c63f9f120fe19a9923727385846aad1d92aa57c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 23:47:24 +0000 Subject: [PATCH] Create CET-6 90-day check-in site --- .gitignore | 46 + .npmrc | 3 + .openai/hosting.json | 5 + README.md | 126 + app/api/progress/route.ts | 6 + app/chatgpt-auth.ts | 90 + app/checkin-client.tsx | 13 + app/globals.css | 139 + app/layout.tsx | 23 + app/page.tsx | 2 + app/plan.ts | 14 + build/sites-vite-plugin.LICENSE | 21 + build/sites-vite-plugin.ts | 230 + cloudflare-env.d.ts | 6 + components.json | 21 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 196 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/attachment.tsx | 204 + components/ui/avatar.tsx | 109 + components/ui/badge.tsx | 48 + components/ui/breadcrumb.tsx | 109 + components/ui/bubble.tsx | 125 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 64 + components/ui/calendar.tsx | 220 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 + components/ui/chart.tsx | 377 + components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/combobox.tsx | 310 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 + components/ui/dialog.tsx | 158 + components/ui/direction.tsx | 22 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 + components/ui/empty.tsx | 104 + components/ui/field.tsx | 248 + components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 170 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/marker.tsx | 69 + components/ui/menubar.tsx | 276 + components/ui/message-scroller.tsx | 130 + components/ui/message.tsx | 92 + components/ui/native-select.tsx | 62 + components/ui/navigation-menu.tsx | 168 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 89 + components/ui/progress.tsx | 32 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 53 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 190 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 143 + components/ui/sidebar.tsx | 723 ++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 40 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 35 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 91 + components/ui/textarea.tsx | 18 + components/ui/toggle-group.tsx | 83 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 57 + db/index.ts | 13 + db/schema.ts | 10 + drizzle.config.ts | 7 + drizzle/0000_curly_gamma_corps.sql | 9 + drizzle/meta/0000_snapshot.json | 82 + drizzle/meta/_journal.json | 13 + eslint.config.mjs | 28 + examples/d1/app/api/notes/route.ts | 58 + examples/d1/db/schema.ts | 9 + hooks/use-mobile.ts | 19 + lib/utils.ts | 6 + next.config.ts | 7 + package.json | 69 + pnpm-lock.yaml | 9201 ++++++++++++++++++++++ pnpm-workspace.yaml | 21 + postcss.config.mjs | 7 + public/favicon.svg | 6 + public/file.svg | 1 + public/globe.svg | 1 + public/window.svg | 1 + scripts/build-verified.sh | 26 + scripts/execution-profile.mjs | 16 + scripts/install-ci.mjs | 51 + scripts/install-ci.sh | 211 + scripts/install-pnpm.sh | 216 + scripts/pnpm-install.mjs | 261 + scripts/run-framework.mjs | 23 + scripts/sites-env.mjs | 22 + scripts/sites-env.sh | 52 + tsconfig.json | 47 + vendor/shadcn-tailwind-4.13.0.LICENSE.md | 21 + vendor/shadcn-tailwind-4.13.0.css | 629 ++ vite.config.ts | 68 + 109 files changed, 19290 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .openai/hosting.json create mode 100644 README.md create mode 100644 app/api/progress/route.ts create mode 100644 app/chatgpt-auth.ts create mode 100644 app/checkin-client.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/plan.ts create mode 100644 build/sites-vite-plugin.LICENSE create mode 100644 build/sites-vite-plugin.ts create mode 100644 cloudflare-env.d.ts create mode 100644 components.json create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/attachment.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/bubble.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/combobox.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/direction.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/marker.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/message-scroller.tsx create mode 100644 components/ui/message.tsx create mode 100644 components/ui/native-select.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_curly_gamma_corps.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 eslint.config.mjs create mode 100644 examples/d1/app/api/notes/route.ts create mode 100644 examples/d1/db/schema.ts create mode 100644 hooks/use-mobile.ts create mode 100644 lib/utils.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 public/favicon.svg create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/window.svg create mode 100755 scripts/build-verified.sh create mode 100644 scripts/execution-profile.mjs create mode 100644 scripts/install-ci.mjs create mode 100755 scripts/install-ci.sh create mode 100755 scripts/install-pnpm.sh create mode 100644 scripts/pnpm-install.mjs create mode 100644 scripts/run-framework.mjs create mode 100644 scripts/sites-env.mjs create mode 100755 scripts/sites-env.sh create mode 100644 tsconfig.json create mode 100644 vendor/shadcn-tailwind-4.13.0.LICENSE.md create mode 100644 vendor/shadcn-tailwind-4.13.0.css create mode 100644 vite.config.ts 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=>)}