From 142e64bd93185456a184a166a821a53a05253108 Mon Sep 17 00:00:00 2001 From: Coinnich <> Date: Mon, 24 Aug 2026 14:39:45 +1200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=F0=9F=8E=A8=20Mobile=20navbar=20po?= =?UTF-8?q?pover=20menu=20with=20avatar=20or=20hamburger=20trigger=20?= =?UTF-8?q?=F0=9F=93=A1=20Restyled=20offline=20indicator=20with=20pulse=20?= =?UTF-8?q?offline=20and=20solid=20online=20states=20=F0=9F=94=A7=20Varloc?= =?UTF-8?q?k=20test=20env=20via=20.env.test=20and=20preload=20shim,=20no?= =?UTF-8?q?=20Bitwarden=20fetch=20=F0=9F=90=9B=20Disabled=20NavLink=20pref?= =?UTF-8?q?etch=20by=20default=20to=20reduce=20network=20noise=20?= =?UTF-8?q?=F0=9F=A7=AA=20Expanded=20sign-in=20component=20and=20e2e=20aut?= =?UTF-8?q?h=20test=20coverage=20=F0=9F=93=9D=20Synced=20README=20badges,?= =?UTF-8?q?=20nav/offline=20docs,=20and=20Varlock=20test=20notes=20?= =?UTF-8?q?=F0=9F=93=A6=20Regenerated=20bun.lock=20and=20bumped=20Next,=20?= =?UTF-8?q?Varlock,=20Panda,=20nuqs=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.schema | 7 +- .env.test | 9 + .gitignore | 1 + README.md | 45 +- bun.lock | 624 ++++++++---------- bunfig.toml | 8 +- doctor.config.ts | 3 + next.config.ts | 1 - package.json | 58 +- panda.config.ts | 2 +- src/components/form/field-errors.tsx | 5 +- src/components/form/form-error.tsx | 1 + src/components/form/submit-button.tsx | 4 +- src/components/nav-link/nav-link.tsx | 2 +- src/components/navbar/navbar.tsx | 434 ++++++++++-- src/components/offline-indicator.tsx | 74 ++- src/components/search-input/search-input.tsx | 4 +- src/env.d.ts | 19 +- .../auth/components/sign-in-nav-link.tsx | 1 + .../models/back-link/model-back-link.tsx | 2 +- src/features/models/dal/search-models.ts | 3 +- .../pagination-offset-transition.tsx | 4 +- tests/components/auth/signin-page.spec.tsx | 41 +- tests/e2e/auth/login-logout.spec.ts | 2 - tests/e2e/auth/signin-errors.spec.ts | 31 + tests/setup/varlock-test-env.ts | 2 + 26 files changed, 908 insertions(+), 479 deletions(-) create mode 100644 .env.test create mode 100644 tests/e2e/auth/signin-errors.spec.ts create mode 100644 tests/setup/varlock-test-env.ts diff --git a/.env.schema b/.env.schema index e53e45b..96f9514 100644 --- a/.env.schema +++ b/.env.schema @@ -1,5 +1,6 @@ -# @generateTypes(lang='ts', path='src/env.d.ts') -# @encryptInjectedEnv +# @generateTsTypes(path='src/env.d.ts') +# @currentEnv=$VARLOCK_ENV +# @encryptInjectedEnv=forEnv(production, preview) # @plugin(@varlock/bitwarden-plugin) # @initBitwarden(accessToken=$BITWARDEN_ACCESS_TOKEN) # @defaultSensitive=inferFromPrefix('NEXT_PUBLIC_') @@ -42,4 +43,4 @@ TURSO_DATABASE_AUTH=bitwarden("50b36813-35ae-4b68-bcfb-b4900011b5aa") # @docs(https://docs.turso.tech/quickstart) # @docs(https://orm.drizzle.team/docs/connect-turso) # @sensitive -TURSO_DATABASE_URL=bitwarden("c71a565f-a617-4dda-a3a1-b49000113ac5") \ No newline at end of file +TURSO_DATABASE_URL=bitwarden("c71a565f-a617-4dda-a3a1-b49000113ac5") diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..9088b18 --- /dev/null +++ b/.env.test @@ -0,0 +1,9 @@ +# Static test fixtures — no Bitwarden/network. Loaded when VARLOCK_ENV=test. +# @disable=not(forEnv(test)) +# --- +GITHUB_CLIENT_ID=test-github-client-id +GITHUB_CLIENT_SECRET=test-github-client-secret +BETTER_AUTH_SECRET=test-better-auth-secret-at-least-32-chars-long +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +TURSO_DATABASE_AUTH=test-turso-auth-token +TURSO_DATABASE_URL=libsql://test.turso.io diff --git a/.gitignore b/.gitignore index 55ed96b..f00c6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules .env* !.env.schema +!.env.test .next .vercel .fallow diff --git a/README.md b/README.md index d29d5d8..cafce23 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,29 @@ A modern web application for browsing and discovering 3D models, built with Next ## 🛠️ Tech Stack -![Next.js](https://img.shields.io/badge/Next.js-16.3.0-black?logo=next.js) +![Next.js](https://img.shields.io/badge/Next.js-16.4.0--canary.2-black?logo=next.js) ![React](https://img.shields.io/badge/React-19.3_canary-61DAFB?logo=react) ![TypeScript](https://img.shields.io/badge/TypeScript-7.0.2-3178C6?logo=typescript) -![Panda CSS](https://img.shields.io/badge/Panda_CSS-2.0.0--beta.12-000000) +![Panda CSS](https://img.shields.io/badge/Panda_CSS-2.0.0--beta.14-000000) ![Drizzle ORM](https://img.shields.io/badge/Drizzle-1.0.0--rc.4-FFE66D?logo=sqlite) -[![Better Auth](https://img.shields.io/badge/Better%20Auth-1.7.0--rc.4-000000?logo=better-auth&logoColor=white)](https://better-auth.com/) -![Biome](https://img.shields.io/badge/Biome-2.5.6-60A5FA?logo=biome) -[![Ultracite](https://img.shields.io/badge/Ultracite-7.10.1-000000?logo=biome&logoColor=60A5FA)](https://github.com/ultracite/ultracite) +[![Better Auth](https://img.shields.io/badge/Better%20Auth-1.7.1-000000?logo=better-auth&logoColor=white)](https://better-auth.com/) +![Biome](https://img.shields.io/badge/Biome-2.5.9-60A5FA?logo=biome) +[![Ultracite](https://img.shields.io/badge/Ultracite-7.10.6-000000?logo=biome&logoColor=60A5FA)](https://github.com/ultracite/ultracite) [![Formatted with Biome](https://img.shields.io/badge/Formatted_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev/) [![Linted with Biome](https://img.shields.io/badge/Linted_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev) -- **Framework**: Next.js 16.3.0 with App Router, Cache Components, React Compiler, typed routes (`typedRoutes`), experimental `useOffline`, and root `maxDuration = 45` (platform hard kill ceiling) -- **Language**: TypeScript 7.0.2 with React 19.3 canary (`19.3.0-canary-96fcba90-20260728`, aged for `bunfig` `minimumReleaseAge`) -- **Styling**: Panda CSS 2.0.0-beta.12 (`@pandacss/dev`, `@pandacss/preset-base`, `@pandacss/preset-panda`, `@pandacss/preset-typography`, `panda.config.ts`); generated `styled-system/` from `panda build` (gitignored; run via `bun install` / `prepare`); imports use the `@styled-system/*` path alias (`tsconfig.json`); `@layer` stack in `src/app/index.css`; view-transition animations colocated via Panda helpers / `ViewTransition` (duration tokens + `group` where needed); shared `Skeleton` uses shimmer CSS vars (`color` / `highlightColor` props) +- **Framework**: Next.js 16.4.0-canary.2 with App Router, Cache Components, React Compiler, typed routes (`typedRoutes`), experimental `useOffline`, and root `maxDuration = 45` (platform hard kill ceiling) +- **Language**: TypeScript 7.0.2 with React 19.3 canary (`19.3.0-canary-eb8feb71-20260814`, aged for `bunfig` `minimumReleaseAge`) +- **Styling**: Panda CSS 2.0.0-beta.14 (`@pandacss/dev`, `@pandacss/preset-base`, `@pandacss/preset-panda`, `@pandacss/preset-typography`, `panda.config.ts`); generated `styled-system/` from `panda build` (gitignored; run via `bun install` / `prepare`); imports use the `@styled-system/*` path alias (`tsconfig.json`); `@layer` stack in `src/app/index.css`; view-transition animations colocated via Panda helpers / `ViewTransition` (duration tokens + `group` where needed); shared `Skeleton` uses shimmer CSS vars (`color` / `highlightColor` props) - **Database**: Turso (libSQL / SQLite) with Drizzle ORM 1.0.0-rc.4 (`dialect: "turso"`, `@libsql/client`) -- **Authentication**: Better Auth 1.7.0-rc.4 with email/password and GitHub OAuth, cookie caching enabled, ElysiaJS API backend; Drizzle adapter uses `relations-v2` with experimental joins (`provider: "sqlite"`); account identity keyed by `(issuer, accountId)` -- **Search Params**: nuqs 2.9.5 for type-safe URL state (`query`, `page`, `limit`, `sort`); `NuqsAdapterBoundary` wraps the `3d-models` layout so search works on index and category routes; listing canonical URLs use `nuqs/server` loaders/serializers (`features/pagination/listing-canonical.ts`) with `clearOnDefault` for SEO metadata; model detail `from` return paths are allowlisted via `features/pagination/listing-path.ts` -- **Linting & Formatting**: Biome 2.5.6 with Ultracite 7.10.1 presets (`ultracite/biome/core`, `react`, `next`); [React Doctor](https://github.com/millionco/react-doctor) on PRs and pushes to `main` (`.github/workflows/react-doctor.yml`, pinned actions, `doctor.config.ts`) and on staged TS/TSX via Husky + lint-staged (`type`, `react-doctor:staged`); Cursor agent hooks in `.cursor/hooks.json` (`afterFileEdit`: Ultracite fix skipping unused-import removal + `test:affected`; `stop`: full fix, `fallow audit`, full `test`) +- **Authentication**: Better Auth 1.7.1 with email/password and GitHub OAuth, cookie caching enabled, ElysiaJS API backend; Drizzle adapter uses `relations-v2` with experimental joins (`provider: "sqlite"`); account identity keyed by `(issuer, accountId)` +- **Search Params**: nuqs 2.10.0 for type-safe URL state (`query`, `page`, `limit`, `sort`); `NuqsAdapterBoundary` wraps the `3d-models` layout so search works on index and category routes; listing canonical URLs use `nuqs/server` loaders/serializers (`features/pagination/listing-canonical.ts`) with `clearOnDefault` for SEO metadata; model detail `from` return paths are allowlisted via `features/pagination/listing-path.ts` +- **Linting & Formatting**: Biome 2.5.9 with Ultracite 7.10.6 presets (`ultracite/biome/core`, `react`, `next`); [React Doctor](https://github.com/millionco/react-doctor) on PRs and pushes to `main` (`.github/workflows/react-doctor.yml`, pinned actions, `doctor.config.ts`) and on staged TS/TSX via Husky + lint-staged (`type`, `react-doctor:staged`); Cursor agent hooks in `.cursor/hooks.json` (`afterFileEdit`: Ultracite fix skipping unused-import removal + `test:affected`; `stop`: full fix, `fallow audit`, full `test`) - **Type Checking**: TypeScript 7 via `tsc` (`bun run type` / `typegen`); Next build uses project-local `tsc` (`experimental.useTypeScriptCli` in `next.config.ts`) because TS 7 has no JS compiler API - **Package Manager**: Bun (install, tests, Drizzle scripts, `prepare`); `bunfig.toml` sets `minimumReleaseAge` (3 days) - **Next.js runtime**: **Bun is the desired runtime** (`bun --bun` for inspect / debug build). **Temporarily**, `dev`, production `build`, and `start` run **Next on Node** (`next dev`, `bun varlock run -- next build`, `bun run next start`) because Next.js 16 Cache Components + `bun --bun` can surface spurious `AbortError` unhandled rejections during prerender. Plan to re-enable `bun --bun` for all Next scripts once Bun/Next compatibility improves (see [vercel/next.js#87630](https://github.com/vercel/next.js/issues/87630), [oven-sh/bun#26508](https://github.com/oven-sh/bun/issues/26508)). - **Build Tool**: Turbopack for dev and build; `partialPrefetching`, experimental view transitions, MCP server, cached navigations, `appNewScrollHandler`, Turbopack filesystem caches (`turbopackFileSystemCacheForDev` / `ForBuild`), and `turbopackRustReactCompiler` (`next.config.ts`); env types from Varlock (`.env.schema`, `src/env.d.ts`), not Next `typedEnv` -- **Environment**: [Varlock](https://varlock.dev/) 1.16.0 with `.env.schema` (`@encryptInjectedEnv`), `@varlock/nextjs-integration` plugin in `next.config.ts`, optional Bitwarden Secrets Manager via `@varlock/bitwarden-plugin` (see `docs/VARLOCK.md`) +- **Environment**: [Varlock](https://varlock.dev/) 1.17.0 with `.env.schema` (`@currentEnv=$VARLOCK_ENV`, `@encryptInjectedEnv=forEnv(production, preview)`), committed `.env.test` fixtures for Bun tests (no Bitwarden fetch), `@varlock/nextjs-integration` plugin in `next.config.ts`, optional Bitwarden Secrets Manager via `@varlock/bitwarden-plugin` (see `docs/VARLOCK.md`) - **Validation**: Varlock for environment; Valibot 1.4.2 for server action and form schemas; model slugs validated via slugify idempotency (`lib/slugify.ts` + `isModelSlug` in `db/brands.ts`) ## 🚀 Features @@ -37,7 +37,8 @@ A modern web application for browsing and discovering 3D models, built with Next - **Search across listings**: Search bar lives in `ModelsGridHeader` (index + category routes); grid title shows `Results for "{query}"` via nuqs when a query is present - **Model detail back link**: Detail pages restore the prior listing via allowlisted `from` query (`features/models/back-link/`) with runtime prefetch under Partial Prefetching - **Shimmer skeletons**: Shared `Skeleton` shimmer (CSS vars / `color` props) for listing and detail loading states -- **Offline indicator**: Navbar `OfflineBanner` via Next.js experimental `useOffline` (`components/offline-indicator.tsx`, `experimental.useOffline` in `next.config.ts`) +- **Offline indicator**: Navbar `OfflineBanner` via Next.js experimental `useOffline` — pill with wifi-off icon, pulsing dot when offline, solid green when online; visible on all breakpoints (`components/offline-indicator.tsx`) +- **Mobile navigation**: Below `md`, navbar uses native ` + +
+
+ Navigate +
+ +
); diff --git a/src/components/offline-indicator.tsx b/src/components/offline-indicator.tsx index eda96a3..2aae098 100644 --- a/src/components/offline-indicator.tsx +++ b/src/components/offline-indicator.tsx @@ -1,7 +1,55 @@ "use client"; -import { circle, hstack } from "@styled-system/patterns"; +import { css, cx } from "@styled-system/css"; +import { circle, hstack, square } from "@styled-system/patterns"; import { useOffline } from "next/offline"; +import { MdWifiOff } from "react-icons/md"; + +const offlineIndicatorClassName = hstack({ + backgroundColor: "bg.surface", + borderColor: "border.subtle", + borderWidth: 1, + color: "text.secondary", + display: "flex", + flexShrink: 0, + fontSize: { base: "xs", md: "sm" }, + gap: { base: 1.5, md: 2 }, + insetBlockStart: { md: "50%" }, + insetInlineStart: { md: "50%" }, + paddingBlock: { base: 1, md: 2 }, + paddingInline: { base: 2, md: 3 }, + position: { md: "absolute" }, + rounded: "full", + shadow: "sm", + shadowColor: "gray.300/20", + translate: { md: "-50% -50%" }, + whiteSpace: "nowrap", +}); + +const offlineIconContainerClassName = cx( + circle({ + backgroundColor: "brand.subtle", + size: { base: 6, md: 7 }, + }), + css({ + alignItems: "center", + display: "inline-flex", + flexShrink: 0, + justifyContent: "center", + }), +); + +const offlineIconClassName = cx( + square({ size: { base: 3, md: 4 } }), + css({ color: "brand" }), +); + +const offlineLabelClassName = css({ fontWeight: "semibold" }); + +const offlineSupportingTextClassName = css({ + color: "text.muted", + display: { base: "none", md: "inline" }, +}); export function OfflineBanner() { const isOffline = useOffline(); @@ -12,20 +60,18 @@ export function OfflineBanner() { return (
- - Offline + + + Offline + + {" · cached browsing available"} +
); } diff --git a/src/components/search-input/search-input.tsx b/src/components/search-input/search-input.tsx index 6f1812f..375fca7 100644 --- a/src/components/search-input/search-input.tsx +++ b/src/components/search-input/search-input.tsx @@ -104,7 +104,9 @@ const SearchInput = () => { {} - export interface PublicTypedEnvSchema extends Readonly> {} + export interface TypedEnvSchema extends Readonly<_CoercedEnvSchema_e8b9bcf3> {} + export interface PublicTypedEnvSchema extends Readonly> {} } @@ -77,17 +84,17 @@ export type EnvSchemaAsStrings = { : (CoercedEnvSchema[Property] extends boolean ? ('true' | 'false') : string) }; -type _EnvSchemaAsStrings_78a35131 = EnvSchemaAsStrings; +type _EnvSchemaAsStrings_e8b9bcf3 = EnvSchemaAsStrings; declare global { // add types for global import.meta.env - interface ImportMetaEnv extends _EnvSchemaAsStrings_78a35131 {} + interface ImportMetaEnv extends _EnvSchemaAsStrings_e8b9bcf3 {} interface ImportMeta { readonly env: ImportMetaEnv; } // add types for global process.env namespace NodeJS { - interface ProcessEnv extends _EnvSchemaAsStrings_78a35131 {} + interface ProcessEnv extends _EnvSchemaAsStrings_e8b9bcf3 {} } } \ No newline at end of file diff --git a/src/features/auth/components/sign-in-nav-link.tsx b/src/features/auth/components/sign-in-nav-link.tsx index ddb5186..f00ea65 100644 --- a/src/features/auth/components/sign-in-nav-link.tsx +++ b/src/features/auth/components/sign-in-nav-link.tsx @@ -10,6 +10,7 @@ const SignInNavLink = () => ( className={buttonRecipe({ size: "md", variant: "ghost" })} href="/signin" matchStrategy="endsWith" + prefetch skeletonCh={3} > Sign In diff --git a/src/features/models/back-link/model-back-link.tsx b/src/features/models/back-link/model-back-link.tsx index 8ab390d..eb288fc 100644 --- a/src/features/models/back-link/model-back-link.tsx +++ b/src/features/models/back-link/model-back-link.tsx @@ -22,7 +22,7 @@ const ModelBackLink = async ({ searchParams }: ModelBackLinkProps) => { )} href={href} // Runtime prefetch: listing searchParams aren't in App Shell under partialPrefetching - prefetch={true} + prefetch >