A modern web application for browsing and discovering 3D models, built with Next.js, TypeScript, and Drizzle ORM.
- Framework: Next.js 16.4.0-canary.34 with App Router, Cache Components, React Compiler, typed routes (
typedRoutes), experimentaluseOffline, and rootmaxDuration = 45(platform hard kill ceiling) - Language: TypeScript 7.0.2 with React 19.3 (
19.3.0);bunfigminimumReleaseAge(3 days) excludes Next/SWC plus Panda compiler/preset packages - Styling: Panda CSS 2.0.0-beta.17 (
@pandacss/dev,@pandacss/preset-base,@pandacss/preset-panda,@pandacss/preset-typography); thinpanda.config.tsplustheme/define*blocks (defineKeyframes,defineTokens,defineSemanticTokens,defineConditions,definePattern,defineUtility,defineGlobalStyles); generatedstyled-system/frompanda build(gitignored; run viabun install/prepare); imports use the@styled-system/*path alias (tsconfig.json);@layerstack insrc/app/index.css; shared theme keyframes intheme/keyframes.ts; scroll-driven / view-timeline animations stay local via@styled-system/csskeyframes()(navbar, scroll-progress, categories mask, model-card);optimize.removeUnusedKeyframes: false; sharedSkeletonuses shimmer CSS vars (color/highlightColorprops);@pandacss/mcp2.0.0-beta.17 for agent token lookup - Database: Turso (libSQL / SQLite) with Drizzle ORM 1.0.0-rc.4 (
dialect: "turso",@libsql/client); model PK is uuidv7ModelId(uuidv7+src/db/create-id.ts) - Authentication: Better Auth 1.7.4 with email/password and GitHub OAuth, cookie caching enabled, ElysiaJS API backend; Drizzle adapter uses
relations-v2withadvanced.database.joins(provider: "sqlite");account.issuerdropped (src/db/migrations/20260911114623_better_auth_1_7_3_drop_issuer/) - Server Actions: next-safe-action 8.7.3 (
lib/safe-action.tsactionClient/authActionClient, flattened ValibotvalidationErrors);formDataInputmaps<form>FormData onto object schemas; clientuseStateAction/useOptimisticStateAction;lib/safe-action-form.tslastNonSecretFormValuefor non-passworddefaultValue - Search Params: nuqs 2.10.1 for type-safe URL state (
query,cursor,direction,limit,sort);NuqsAdapterBoundarywraps the3d-modelslayout so search works on index and category routes; listing canonical URLs usenuqs/serverloaders/serializers (features/models/listing/listing-canonical.ts) withclearOnDefaultfor SEO metadata (omits defaultcursor/direction/limit/sortand emptyquery; dropsdirectionwhencursoris absent); model detailfromreturn paths are allowlisted viafeatures/models/listing/listing-path.ts; component tests wrapwithNuqsTestingAdapterviatests/setup/nuqs-testing.ts(withListingNuqsTestingAdapter) - Linting & Formatting: Biome 2.5.12 with Ultracite 7.11.1 presets (
ultracite/biome/core,react,next); Fallow 3.25.0 withboundaries.preset: "bulletproof"(.fallowrc.json); healthmaxCyclomatic/maxCognitive12,maxCrap30 (src/db/**override 80);unused-dependencies,prop-drilling,thin-wrapper,duplicate-prop-shapeaterror; React Doctor 0.9.14 on PRs and pushes tomain(.github/workflows/react-doctor.yml, SHA-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, fulltest); Panda MCP in.cursor/mcp.json/AGENTS.md - Type Checking: TypeScript 7 via
tsc(bun run type/typegen); Next build uses project-localtsc(experimental.useTypeScriptCliinnext.config.ts) because TS 7 has no JS compiler API - Package Manager: Bun (install, tests, Drizzle scripts,
prepare);bunfig.tomlsetsminimumReleaseAge(3 days) with Next/SWC/Panda excludes - Next.js runtime: Bun is the desired runtime. Local
dev, productionbuild,start, andnext:analyzestill run Next on Node (varlock run -- bun run next dev,bun varlock run -- next build,bun run next start,bun varlock run -- next experimental-analyze).dev:inspect,build:debug, andnext:upgradeusebun --bun. Cache Components +partialPrefetchingon Bun leak Next prerender abort rejects asCLIENT_HOOK_DYNAMIC(usePathname/useSearchParams“outside<Suspense>”) duringbun --bun next build: exit 0, routes stay◐PPR, Nodenext buildsilent on the same tree.export const instant = falsedoes not silence this — it only opts that segment out of Instant Navigation validation and does not disable prerender. Runtimebun server.jscan also leakAbortError/Date.now()unhandled rejections because Bun drops AsyncLocalStorage onunhandledRejection. Track oven-sh/bun#39847, oven-sh/bun#40223, vercel/next.js#97663. The oldersetTimeout/_idleStartwarning (vercel/next.js#87630, oven-sh/bun#26508) is fixed in Bun 1.4.0 and is not the current blocker. Re-enablebun --bunfordev/build/startwhen the ALS / hanging-promise fixes land. - Build Tool: Turbopack for dev and build;
partialPrefetching, MCP server, cached navigations, Turbopack filesystem caches (turbopackFileSystemCacheForDev/ForBuild), andturbopackRustReactCompiler(next.config.ts); env types from Varlock (.env.schema,src/env.d.ts), not NexttypedEnv - Environment: Varlock 1.19.0 with
.env.schema(@currentEnv=$VARLOCK_ENV,@encryptInjectedEnv=forEnv(production, preview)), committed.env.testfixtures for Bun tests (no Bitwarden fetch),@varlock/nextjs-integrationplugin innext.config.ts, optional Bitwarden Secrets Manager via@varlock/bitwarden-plugin(seedocs/VARLOCK.md) - Validation: Varlock for environment; Valibot 1.5.0 for next-safe-action input schemas; model slugs validated via slugify idempotency (
lib/slugify.ts+isModelSlugindb/brands.ts)
- Browse 3D Models: View a curated collection of 3D models across various categories
- Category Filtering: Filter models by category (3D Printer, Art, Education, Fashion, etc.)
- Sort Controls: Sort listings by A-Z, Popular, or Recent via nuqs
sortsearch param (default A-Z omitted from URL); ties break onModelId - Cursor pagination: Exclusive keyset on
(sortCol, ModelId)with URLcursor(uuidv7ModelId) +direction(forward/backward) +limit; next/prev flags fromlimit+1(noCOUNT(*)); old?page=bookmarks land on the first slice.sort=popularis a live feed: likes can skip/dup between slices (accepted). ADR:docs/adr/0001-cursor-pagination.md. Domain terms:CONTEXT.md. Survey:docs/MUTABLE_SORT_KEYSET.md - Search across listings: Search bar lives in
ModelsGridHeader(index + category routes); grid title showsResults for "{query}"via nuqs when a query is present; search/sort/limit resetcursorrather than paging - Model detail back link: Detail pages restore the prior listing via allowlisted
fromquery (features/models/back-link/) with runtime prefetch under Partial Prefetching - Shimmer skeletons: Shared
Skeletonshimmer (CSS vars /colorprops) for listing and detail loading states - Offline indicator: Navbar
OfflineBannervia Next.js experimentaluseOffline— persistent pill with a wifi-off icon and container-responsive status detail; visible on all breakpoints (components/offline-indicator.tsx) - Mobile navigation: Below
sm(640px), navbar uses native<button popovertarget>+popover="auto"menu; the compact icon logo is used belowxs(480px), with the full wordmark fromxsupward (src/components/navbar/) - Categories sidebar: From
md, sticky nav with header clearance (insetBlockStart: 6.5rem) andoverflowY: auto(maxBlockSize: calc(100dvh - 7.5rem)); small-screen horizontal strip uses a local scroll-driven fade mask (keyframes()inapp/3d-models/layout.tsx) - Responsive Design: Optimized for desktop, tablet, and mobile devices
- Smooth Page Transitions: View Transitions API; pager uses
addTransitionTypeforwards/backwardsonPaginationPageSlice(full-viewport slide on small screens, compact frommd). Search/sort/limit are not page turns - Type-Safe Database: Full TypeScript support with Drizzle ORM
- Performance Optimized: Caching for frequently accessed data; listing DAL combines cache + timeout abort signals (
utils/with-abort.ts,ABORT_TIMEOUT_MS) - Modern Stack: Built with Next.js 16.4, TypeScript, and Panda CSS
- Feature-Based Architecture: Well-organized codebase with clear separation of concerns
Note: Like/dislike functionality with optimistic updates and real-time like count synchronization is fully implemented.
Static assets are served from public/ at the repository root (not under src/), including logos, hero images, and public/img/models/*.avif thumbnails referenced by seed data. Supplemental docs live in docs/ (for example AUTH_SETUP.md, VARLOCK.md, MODEL_CACHE_SPLIT.md, PSEUDO_CLASS_TRANSITIONS.md, PERFORMANCE_IMPROVEMENTS.md, REACT_STINKY.md, docs/adr/0001-cursor-pagination.md). Listing domain language (ModelId, ListingCursor, PageSlice) lives in root CONTEXT.md. Panda CSS config is a thin panda.config.ts that imports theme/ define* modules; generated files go to styled-system/ at the repo root (outdir); that folder is gitignored—run bun install (or bunx panda build) so imports like @styled-system/css resolve. Root tooling includes doctor.config.ts and .github/workflows/react-doctor.yml for PR diagnostics.
theme/ # Panda define* blocks (imported by panda.config.ts)
├── conditions.ts # defineConditions (scroll/linear/hover support, data-*)
├── global-styles.ts # defineGlobalStyles (fonts, scrollbar)
├── keyframes.ts # Shared fade/slide/shimmer theme animations
├── patterns.ts # between, hoverShadow
├── semantic-tokens.ts
├── tokens.ts # colors, easings, sizes, shadows
└── utilities.ts # cornerShape
src/
├── app/ # Next.js App Router
│ ├── @navbar/ # Parallel route for navbar
│ │ ├── default.tsx
│ │ └── error.tsx
│ ├── @footer/ # Parallel route for footer
│ │ └── default.tsx
│ ├── 3d-models/ # 3D models routes
│ │ ├── @categories/ # Parallel route for categories nav
│ │ │ ├── default.tsx
│ │ │ └── error.tsx # Error boundary for categories
│ │ ├── @results/ # Parallel route for search results
│ │ │ ├── [...catchAll]/
│ │ │ │ └── page.tsx
│ │ │ ├── default.tsx
│ │ │ ├── error.tsx # Error boundary for results with retry functionality
│ │ │ ├── loading.tsx # Loading state for results
│ │ │ └── page.tsx
│ │ ├── [slug]/ # Individual model page
│ │ │ ├── error.tsx # Error boundary for model detail page
│ │ │ ├── not-found.tsx
│ │ │ └── page.tsx
│ │ ├── categories/ # Category-specific pages
│ │ │ └── [categoryName]/
│ │ │ ├── error.tsx # Error boundary for category pages with retry functionality
│ │ │ ├── loading.tsx # Loading state for category pages
│ │ │ ├── not-found.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx # Models layout: sticky categories overflow + local scroll-mask keyframes
│ │ └── page.tsx # Models landing page
│ ├── about/ # About page
│ │ └── page.tsx
│ ├── (auth)/ # Authentication group route
│ │ ├── layout.tsx # Centered auth layout
│ │ ├── signin/
│ │ │ └── page.tsx
│ │ └── signup/
│ │ └── page.tsx
│ ├── api/ # API routes
│ │ └── [[...slugs]]/
│ │ ├── better-auth-openapi.ts # Better Auth OpenAPI spec for Elysia docs
│ │ └── route.ts # ElysiaJS handler mounting Better Auth (`basePath` /api/auth)
│ ├── index.css # Global `@layer` stack (reset → utilities)
│ ├── icon.png # App icon (metadata)
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ ├── global-error.tsx # Root error boundary (App Router)
│ ├── robots.ts # robots.txt Route Handler
│ └── sitemap.ts # Sitemap generation
├── instrumentation-client.ts # Oddbird popover / CSS anchor polyfills
├── features/
│ ├── auth/ # Auth journeys (sign-in / sign-up / GitHub)
│ │ ├── actions/ # NSA stateActions (Valibot + formDataInput)
│ │ │ ├── sign-in-action.ts
│ │ │ ├── sign-in-github-action.ts
│ │ │ └── sign-up-action.ts # SignUpData type co-located here
│ │ ├── components/ # Auth page UI
│ │ │ ├── auth-card.tsx
│ │ │ ├── auth-footer-link.tsx
│ │ │ ├── auth-form.tsx # Shared form (`useStateAction` + lastNonSecretFormValue)
│ │ │ └── sign-in-button.tsx
│ │ └── constants.ts # Auth validation constants
│ ├── categories/ # Categories feature
│ │ ├── components/
│ │ │ ├── categories-block-transition.tsx
│ │ │ └── categories-nav.tsx
│ │ ├── constants.ts # ALL_CATEGORIES, CATEGORY_LIST_ITEMS, not-found metadata
│ │ ├── types.ts # CategoryName (CategorySlug route param)
│ │ └── queries/
│ │ ├── get-all-categories.ts
│ │ ├── get-all-category-slugs.ts
│ │ └── get-category-by-slug.ts
│ ├── models/ # Models feature
│ │ ├── back-link/ # Detail → listing return path (`from` query)
│ │ │ ├── from-search-params.ts
│ │ │ ├── model-back-link.tsx
│ │ │ └── model-back-link-skeleton.tsx
│ │ ├── components/
│ │ │ ├── model-card.tsx
│ │ │ ├── model-card.styles.ts # Container-query + subgrid listing card
│ │ │ ├── models-grid.styles.ts # Listing grid pattern (`modelsGrid`)
│ │ │ ├── model-card-skeleton.tsx
│ │ │ ├── model-detail.tsx
│ │ │ ├── model-detail.styles.ts # Container-query detail layout
│ │ │ ├── models-grid.tsx
│ │ │ ├── models-grid-header.tsx
│ │ │ ├── models-grid-header-skeleton.tsx
│ │ │ ├── models-grid-skeleton.tsx
│ │ │ ├── models-grid-title.tsx
│ │ │ ├── models-grid-title-skeleton.tsx
│ │ │ ├── models-not-found.tsx
│ │ │ ├── models-sort-controls.tsx
│ │ │ ├── models-sort-controls-skeleton.tsx
│ │ │ └── models-view.tsx
│ │ ├── constants.ts
│ │ ├── dal/
│ │ │ ├── get-models.ts # `{ result, query, isAuthenticated }`; search + user, `withLikeStatuses`
│ │ │ └── search-models.ts # Unified listing/search + abortable cached awaits
│ │ ├── listing/
│ │ │ ├── listing-canonical.ts # Canonical path serializer (`query`/`cursor`/`direction`/`limit`/`sort`)
│ │ │ └── listing-path.ts # Allowlisted listing href → Route (open-redirect safe)
│ │ ├── listing-search-params.ts # Composes sort + cursor pagination + query nuqs parsers
│ │ ├── sort/ # Sort sub-feature (nuqs param, order mapping, controls hook)
│ │ │ ├── brands.ts # Valibot branded Sort type; `isSortList` guard
│ │ │ ├── components/
│ │ │ │ └── sort-option.tsx # Radio + label pill for one sort value
│ │ │ ├── constants.ts # SORT_VALUES, DEFAULT_SORT, SORT_LABELS
│ │ │ ├── hooks/
│ │ │ │ └── use-sort-query.ts
│ │ │ ├── order-for-sort.ts # Drizzle orderBy + keyset column/dir (tie-break ModelId)
│ │ │ └── sort-search-params.ts
│ │ ├── likes/ # Likes sub-feature (toggle, status, heart UI)
│ │ │ ├── actions/
│ │ │ │ └── toggle-like.ts
│ │ │ ├── components/
│ │ │ │ ├── heart-button-client.tsx
│ │ │ │ ├── heart-button-count.tsx
│ │ │ │ ├── heart-button-recipe.ts
│ │ │ │ ├── heart-button-server.tsx
│ │ │ │ ├── heart-button-skeleton.tsx
│ │ │ │ ├── heart-sign-in-hint.tsx
│ │ │ │ └── likes-count-transition.tsx
│ │ │ ├── dal/
│ │ │ │ └── toggle-like.ts
│ │ │ ├── hooks/
│ │ │ │ ├── heart-like-optimistic.ts
│ │ │ │ └── use-heart-like.ts
│ │ │ ├── queries/
│ │ │ │ └── like-status.ts
│ │ │ ├── constants.ts
│ │ │ ├── types.ts
│ │ │ └── with-like-status.ts # Map models → `hasLiked` from liked slug set
│ │ ├── queries/
│ │ │ ├── build-models-where.ts # Shared SQL where builder for list filter
│ │ │ ├── get-all-model-slugs.ts
│ │ │ ├── get-model-by-slug.ts
│ │ │ └── get-models-list.ts # Keyset list (`limit+1`); no COUNT(*)
│ │ └── types.ts # ModelWithLikeStatus, QueryPagination (cursor ModelId), SearchPattern, Category; component props extend IsAuthenticated
├── constants.ts # EMPTY_LIST_LENGTH, ABORT_TIMEOUT_MS
├── components/ # Shared/generic components
│ ├── form/
│ │ ├── field-errors.tsx
│ │ ├── form-error.tsx
│ │ ├── form-field.tsx # Shared labeled field + errors (auth ViewTransitions)
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ └── reset-button.tsx
│ ├── pending-button.tsx # Pending/busy button (aria-busy, srOnly children)
│ ├── inline-error-fallback.tsx # Shared retry error fallback (navbar / categories)
│ ├── nav-link/
│ │ ├── nav-link-list-item.tsx
│ │ ├── nav-link-skeleton.tsx
│ │ ├── nav-link.tsx # Suspense-wrapped link with active state (prefetch off by default)
│ │ └── types.ts
│ ├── navbar/ # Sticky header (compact logo below xs; nav split at sm)
│ │ ├── navbar.tsx # Orchestrator: logo, offline, desktop nav, mobile popover
│ │ ├── constants.ts
│ │ ├── types.ts
│ │ ├── navbar-logo.tsx
│ │ ├── desktop-nav.tsx
│ │ ├── mobile-menu-button.tsx
│ │ ├── mobile-nav-popover.tsx
│ │ ├── mobile-nav-link.tsx # Mobile row link; closePopoverOnClick by default
│ │ ├── mobile-nav-auth.tsx
│ │ ├── navbar-auth-slot.tsx
│ │ ├── avatar.tsx
│ │ ├── auth-buttons.tsx
│ │ ├── auth-buttons-skeleton.tsx
│ │ └── sign-in-nav-link.tsx
│ ├── pagination/ # Shared pager UI (`cursor` / `direction` via nuqs)
│ │ ├── pagination.tsx # Prev/next; `addTransitionType` forwards/backwards
│ │ ├── pagination-button.tsx
│ │ ├── pagination-limit-control.tsx
│ │ ├── pagination-page-control.tsx
│ │ ├── pagination-page-slice.tsx # PageSlice ViewTransition wrapper
│ │ └── pagination-skeleton.tsx
│ ├── offline-indicator.tsx # OfflineBanner via next/offline useOffline
│ ├── nuqs/
│ │ └── nuqs-adapter-boundary.tsx # Suspense + NuqsAdapter for listing routes
│ ├── button-recipe.ts # Panda CVA recipe for Button variants
│ ├── button.tsx
│ ├── generic-component.tsx
│ ├── not-found/
│ │ ├── unsuccessful-state-list-item.tsx
│ │ └── unsuccessful-state.tsx
│ ├── pill.tsx
│ ├── scroll-progress.tsx
│ ├── skeleton.tsx # Shared shimmer skeleton (CSS vars / color props)
│ ├── skeleton-enter.ts # Shared enter transition for skeleton / pending spinner
│ ├── search-input/
│ │ ├── search-input.tsx
│ │ ├── search-input-transition.tsx
│ │ └── search-input-skeleton.tsx
│ ├── suspend.tsx
│ └── top-link.tsx
├── db/
│ ├── schema/
│ │ ├── auth.ts
│ │ ├── likes.ts
│ │ ├── models.ts # uuidv7 `id` PK + unique `slug`; keyset indexes
│ │ ├── relations.ts
│ │ └── index.ts
│ ├── migrations/ # Drizzle SQL migrations (drizzle-kit generate)
│ ├── seed-data/
│ │ └── models.ts
│ ├── brands.ts # CategorySlug / ModelSlug / ModelId / User; `parseModelId`
│ ├── categories.ts # CATEGORIES constant (source of truth for enum values)
│ ├── create-id.ts # uuidv7 ModelId factory
│ ├── seed.ts
│ ├── drop-tables.ts
│ └── index.ts
├── lib/
│ ├── api.ts
│ ├── auth.cli.config.ts # CLI-only Better Auth config for `auth:generate` (no secrets)
│ ├── auth/ # Session kernel (instance, getUser, types, HasAuth, sign-out)
│ │ ├── index.ts # Runtime Better Auth (`server-only`; secrets + plugins)
│ │ ├── get-user.ts
│ │ ├── types.ts
│ │ ├── auth-types.ts
│ │ ├── has-auth.tsx
│ │ └── sign-out-action.ts
│ ├── pagination/ # Cursor pagination kit (parsers, schema, DAL, types)
│ │ ├── constants.ts # DEFAULT_CURSOR / DEFAULT_DIRECTION / LIMITS
│ │ ├── parse-cursor.ts # URL cursor → branded ModelId
│ │ ├── schema.ts
│ │ ├── search-params.ts # query / cursor / direction / limit parsers (no sort)
│ │ ├── types.ts
│ │ ├── dal/
│ │ │ └── paginate-items.ts
│ │ └── utils/
│ │ ├── create-keyset-cursor.ts
│ │ ├── to-effective-pagination.ts
│ │ ├── to-pagination.ts # Slice `limit+1`; hasNext/hasPrevious from cursor
│ │ └── to-paginated-result.ts
│ ├── date.ts
│ ├── hero-image.ts
│ ├── placeholder-image.ts
│ ├── slugify.ts # MODEL_SLUGIFY_OPTIONS + SlugifyOptions
│ ├── safe-action.ts # NSA clients + formDataInput (server-only)
│ ├── safe-action-form.ts # lastNonSecretFormValue for useStateAction (client)
│ └── url.ts # Shared URL helpers (nuqs defaultOptions)
├── types/
│ └── index.ts
├── utils/
│ ├── cache-invalidation.ts
│ ├── sanitise-name.ts
│ ├── try-catch.ts
│ └── with-abort.ts # Combine cache/timeout AbortSignals; withAbort helper
├── global.d.ts
└── proxy.ts
The project follows a feature-based architecture where related functionality is co-located:
features/models/: Model listing, detail, and search components, queries, and DALfeatures/models/back-link/: Detail-page back link + allowlistedfromsearch-param helpersfeatures/models/listing/: Listing canonical URLs + path allowlistfeatures/models/sort/: Sort URL state (sortnuqs param), branded types, and DrizzleorderBymappingfeatures/models/likes/: Like toggle action, DAL, queries, hooks, and heart-button UIfeatures/categories/: All category-related components and data queriesfeatures/auth/: Sign-in / sign-up / GitHub NSA stateActions and page UIlib/auth/: Session kernel (getUser, types,HasAuth, sign-out)lib/safe-action.ts: next-safe-action clients (actionClient,authActionClient,formDataInput)lib/safe-action-form.ts: Client helper to echo last non-secret form valueslib/pagination/: Cursor pagination parsers (cursor/direction/limit), keyset helpers, DAL, and typescomponents/pagination/: Shared pager UI (PaginationPageSlice+addTransitionTypeforwards/backwards)db/create-id.ts: uuidv7ModelIdfactory for model PK and listing cursorcomponents/: Shared components used across features (including navigation)
_prefix: Private folders that are not part of Next.js routingfeatures/: Feature-based modules with their own components and queriescomponents/: Shared/generic components used across featuresdb/categories.ts: Source-of-truth category list; drives SQLite category slug enum values and Valibot branded slugs indb/brands.tsdb/brands.ts: Valibot brands forCategorySlug,ModelSlug,ModelId(uuidv7),Userdb/seed-data/: Model seed data only (models.ts;idassigned at insert)
- NuqsAdapterBoundary:
NuqsAdapterwraps entire 3d-models tree - Font Loading: Variable fonts are used as 3+ font weights are used
- Error Handling: Centralized
tryCatchutility for consistent error handling across database queries - Cache Components: Uses
"use cache","use cache: remote", and"use cache: private"directives for persistent caching; Reactcache()is used only for functions called multiple times in the same render pass (e.g.,getModelBySlugandgetCategoryBySlugcalled in bothgenerateMetadataand page components) - Type Safety:
Maybe<T>for nullable query results;UserAuthStatediscriminated union ({ isAuthenticated: true, user }|{ isAuthenticated: false }) fromgetUser()andHasAuth; sharedIsAuthenticatedinterface extended by models/pagination props; component-specific props co-located next to components where not reused - Query Builder: Migrated to Drizzle ORM RQBv2 for simple relational queries (
db.query.tableName.findMany/findFirst) with object-basedwhereclauses; complex queries and mutations remain on SQL builder - Error Recovery: Error boundaries with
error.tsxfor failed queries (results, category pages, and model detail pages) with built-inreset()retry functionality and helpful error guidance - Database Query Separation: Listing fetches
limit+1rows via exclusive keyset;toPagination/transformToPaginatedResultslice the extra row and sethasNextPage/hasPreviousPage(noCOUNT(*)) - View Transitions: Pager
addTransitionTypeforwards/backwardsdrivesPaginationPageSliceenter/exit (fade + slide);--slide-distanceis responsive (100vw-based on small screens, compact frommd). Search/sort/limit do not use pager types - Abortable listing fetches:
search-modelscombines Next cache signal +AbortSignal.timeout(ABORT_TIMEOUT_MS)viautils/with-abort.tsso cancelled navigations drop DB awaits sooner - Platform timeout: Root layout exports
maxDuration = 45(literal) so Vercel/Next can apply a hard execution ceiling
- Bun for package management, tests, and database scripts
- A current Node.js LTS (used by
dev/next build/next startuntilbun --bunis re-enabled for those scripts; see Next.js runtime note) - Turso database (
TURSO_DATABASE_URL+TURSO_DATABASE_AUTH) - Optional: Bitwarden Secrets Manager machine account token if you use
bitwarden()resolvers in.env.schema(seedocs/VARLOCK.md)
-
Clone the repository
git clone <repository-url> cd 3dmodels
-
Install dependencies
bun install
This runs the
preparelifecycle script (panda buildto generatestyled-system/, plus Husky). If codegen ever needs a manual rerun:bunx panda build. -
Environment Setup Configuration is defined in
.env.schema(Varlock). Copy it to.envand fill in values, or use literal strings in place ofbitwarden("…")UUIDs for local development. Typical variables:# Bootstrap (Bitwarden resolvers in .env.schema) BITWARDEN_ACCESS_TOKEN="your-machine-account-token" NEXT_PUBLIC_SITE_URL="http://localhost:3000" BETTER_AUTH_SECRET="your-secret-key-here-change-this-in-production" GITHUB_CLIENT_ID="your-github-oauth-client-id" GITHUB_CLIENT_SECRET="your-github-oauth-client-secret" TURSO_DATABASE_URL="libsql://your-db.turso.io" TURSO_DATABASE_AUTH="your-turso-auth-token"
Run
bun run env:typegenafter changing.env.schemato refreshsrc/env.d.ts. Typed access usesimport { ENV } from "varlock/env". Tests use committed.env.test(static fixtures; no secret fetch). Seedocs/VARLOCK.mdanddocs/AUTH_SETUP.mdfor Bitwarden, Bun, and Vercel notes. -
Database Setup Scripts use
varlock run --so Drizzle and seed commands receive resolved env (seepackage.json):bun run db:push bun run db:seed
Alternatively, migrations (SQL and
meta/snapshots are written tosrc/db/migrations/when you run generate; clones may usedb:pushonly until migrations exist):bun run db:generate bun run db:migrate bun run db:seed
For one-off Drizzle CLI use without the
db:*scripts, use the same pattern aspackage.json(for examplevarlock run -- bun x drizzle-kit push). -
Start the development server
bun run dev
Open http://localhost:3000 to view the application.
id: Primary key (auto-increment)displayName: Human-readable category nameslug: Category slug text enum (unique); values defined insrc/db/categories.ts
id: Primary key (uuidv7ModelId, listing cursor;src/db/create-id.ts)slug: Unique public key (text, auto-generated from name); detail path/3d-models/{slug}name: Model name (unique)description: Model descriptionlikes: Number of likes (counter)image: Image URLcategorySlug: Foreign key to categories.sluguserId: Foreign key to user.id (cascade delete)dateAdded: Timestamp when model was added- Indexes:
(date_added, id),(likes, id),(name, id)for keyset seeks - Migration
20260916193419_models_uuidv7_pkrebuildsmodelswith uuidv7id, copies existing rows, keepslikes(slug FK)
id: Primary key (auto-increment)userId: Foreign key to users.id (cascade delete)modelSlug: Foreign key to models.slug (cascade delete)createdAt: Timestamp when like was created- Unique constraint on
(userId, modelSlug)pair
user: User accounts with email/password and OAuth supportaccount: OAuth / credential accounts (issuercolumn dropped in Better Auth 1.7.3)session: User sessions with cookie cachingverification: Email verification tokens
bun run auth:generate— Regenerate Better Auth Drizzle schema (src/db/schema/auth.tsfromsrc/lib/auth.cli.config.ts)bun run db:generate— Generate migrations (varlock run -- bun x drizzle-kit generate)bun run db:migrate— Run migrations (varlock run -- bun x drizzle-kit migrate)bun run db:push— Push schema (varlock run -- bun x drizzle-kit push --force)bun run db:studio— Drizzle Studio (varlock run -- bun x drizzle-kit studio)bun run db:seed— Wipe and reseed models/likes (requires existing users; uuidv7ModelIdat insert). Auth tables stay. Not run bydb:migrate.bun run db:drop— Drop all tables (development reset)
The application uses Drizzle ORM 1.0.0-rc.4 with defineRelations for type-safe relations:
- Relations defined using the v1/rc syntax with
r.one()andr.many()helpers - Relation names avoid conflicts with column names (e.g.,
modelLikesinstead oflikesto avoid conflict withmodels.likescolumn) - All relations exported from
schema/relations.tsand included in the database schema
The application uses Drizzle ORM's Relational Query Builder v2 (RQBv2) for type-safe relational queries:
- Read queries: All read queries use RQBv2 syntax (
db.query.tableName.findMany(),db.query.tableName.findFirst()) with object-basedwhereclauses - Listing / filter queries: Listing search uses SQL builder (
and(),or(),like()+COLLATE NOCASE) since SQLite has noilike; exclusive keyset on(sortCol, ModelId)viacreate-keyset-cursor.ts - Mutations: Insert, update, and delete operations use the SQL builder syntax (mutations not yet available in RQBv2)
- Hybrid approach: The codebase uses a hybrid strategy - RQBv2 object syntax for simple reads, SQL builder for listing keyset/
whereand mutations - Query organization: Model listing is
get-models-list.ts(limit+1keyset, noCOUNT(*)) plusbuild-models-where.tsfor search/category filters, composed in DAL (get-models.ts,search-models.ts). List ordering and keyset column/dir come fromfeatures/models/sort/order-for-sort.tsvia thesortsearch param; equal likes / equaldateAddedorder byModelId - Better Auth adapter: Uses
@better-auth/drizzle-adapter/relations-v2withadvanced.database.joins(lib/auth/index.tsruntime,lib/auth.cli.config.tsfor generate);provider: "sqlite"; mounted on ElysiaJS at/api/[[...slugs]]/route.tswithbasePath/api/auth; OpenAPI viabetter-auth-openapi.ts
The application uses Next.js Cache Components for optimal performance:
- Static content is pre-rendered at build time
- Dynamic content (like authentication state) is rendered at request time
- Server components use
connection()to opt into dynamic rendering when needed (e.g.getUser()) - Cache invalidation handled by
cacheTagutilities - Error handling with
error.tsxerror boundaries for failed queries (categories, results, and category pages with built-inreset()retry functionality) - Loading states with
loading.tsxfor results and category pages
The application uses Next.js Cache Components with granular cache tags for efficient invalidation:
- Models: Cached with
models,model-{slug}, andmodels-category-{slug}tags - Categories: Cached at component level with
categoriestag andcacheLife("max")for pre-rendered HTML output - Cache Life: Hours profile for most queries (5 min stale, 1 hour revalidate, 1 day expire), max for static categories (component-level caching)
- Query Functions: Unified
getModels()usessearchModels()for search (optional query), category filter, sort, and cursor pagination. HelpergetModelsListsupports optional search/category; sort maps throughorderByForSort/sortColumnForSort. Cursor parsed viaparsePaginationCursor(uuidv7ModelId) - Like Status:
like-status.tsqueries use"use cache: private"for user-specific like status (cached on device) - Model Lists:
get-models.tsaddshasLikedper model after a single batched like query for the current PageSlice - Invalidation:
invalidateAllModels()inutils/cache-invalidation.ts(updateTag("models")) — simple broad invalidation on like/unlike so counts andsort=popularstay correct; longer-term content/likes split documented indocs/MODEL_CACHE_SPLIT.md - Optimistic Updates: Heart button uses NSA
useOptimisticStateActionfor immediate UI feedback with server state synchronization via form actions
- Tokens & utilities: Panda CSS 2
theme/define*modules assembled bypanda.config.ts(@pandacss/preset-base,@pandacss/preset-panda,@pandacss/preset-typography); orange accent and shared patterns (between,hoverShadow) live intheme/;treeshakeDesignSystemenabled; localkeyframes()from@styled-system/cssfor scroll/view-timeline animations not meant for the global theme - Typography: Albert Sans + Montserrat Alternates via
next/fontin root layout; heading font applied in PandaglobalCss - Layout & spacing: Panda
css()/ layout patterns (e.g.,gridfor model grids insrc/features/models/components/models-grid.styles.ts) - View transitions: Colocated with components via React
ViewTransition+ Panda view-transition helpers (global VT CSS removed fromindex.css) - Responsive: Mobile-first breakpoints via Panda conditions and component styles
features/models/components/model-card- Individual model display card (container-query split + subgrid viamodel-card.styles; local view-timelinekeyframes()on touch)features/models/components/model-card-skeleton- Loading skeleton for model cardsfeatures/models/components/model-detail- Detailed model view (model-detail.stylescontainer queries)features/models/back-link/model-back-link- Server back link restoring allowlisted listingfrom(runtimeprefetch={true};arrowRecipeleft chevron micro-interactions)features/models/back-link/from-search-params-modelDetailHref/resolveBackHrefwith slugify-stable slug checksfeatures/models/components/models-grid- Grid layout for model cards (embedsfromon detail links)features/models/components/models-grid-header- Listing header: search input, query-aware title, sort controlsfeatures/models/components/models-grid-title- Client title via nuqs (Results for "{query}"or fallback category / default title)features/models/components/models-not-found- Empty search state with the active query in the subheadingfeatures/models/components/models-sort-controls- ClientfieldsetofSortOptionradios for A-Z / Popular / Recent (useSortQuery)features/models/components/models-sort-controls-skeleton- Loading skeleton for sort controlsfeatures/models/components/models-view- Shared server shell: header outsideSuspense,ModelsViewResultswitch, async inner awaitsgetModels; success wraps grid + pager inPaginationPageSlicefeatures/models/sort/components/sort-option- Single sort radio + label pillfeatures/models/sort/hooks/use-sort-query- nuqs hook forsortwith pending state; resetscursor/directionon sort changefeatures/models/sort/order-for-sort- Maps sort brand to DrizzleorderByplus keyset column/dir (tie-breakModelId)features/models/listing/listing-canonical- Canonical path serializer for listing SEO (query,cursor,direction,limit,sort)features/models/listing/listing-path- Allowlisted listingRoutevalidation (/3d-models, category listings)components/pagination/pagination- Cursor pager via nuqscursor/direction;addTransitionTypeforwards/backwardscomponents/pagination/pagination-button- Page control button (groupfor arrow micro-interactions)components/pagination/pagination-limit-control- Per-page limit via customizable<select>(appearance: base-select,selectedcontent, picker transitions)components/pagination/pagination-page-slice- PageSlice ViewTransition wrapper; pager types only (not search/sort/limit)components/pagination/pagination-page-control- Prev/next buttons witharia-labelandarrowRecipechevronslib/pagination/parse-cursor- URL cursor → brandedModelIdlib/pagination/utils/create-keyset-cursor- Exclusive(sortCol, ModelId)predicatelib/pagination/utils/to-pagination- Slicelimit+1;hasNextPage/hasPreviousPagefrom cursor + extra rowfeatures/models/likes/components/heart-button-client- Client like button (useHeartLike, optimistic state) or guest/signinLink + hint popover; colors fromheartButtonRecipe({ visual, guest })features/models/likes/components/likes-count-transition- Wraps like count withViewTransitionupdate names for increase/decreasefeatures/models/likes/components/heart-button-server- Server component for detail pages (resolves like status server-side)features/models/likes/components/heart-button-skeleton- Loading skeleton for heart buttonfeatures/models/likes/hooks/use-heart-like- Client hook wrapping NSAuseOptimisticStateAction(toggleAction, optimistic state,visualState)components/search-input/search-input- Model search with nuqs URL state (mounted inModelsGridHeader); Enter flushes current input value; resetscursor/direction;search-input-transitionfor view transitionsfeatures/categories/components/categories-nav- Category filtering sidebar (server component)features/categories/components/categories-block-transition- View transition wrapper for category listing blocksapp/3d-models/layout- Sticky categories aside:mdoverflow-y + header clearance; localkeyframes()scroll mask on the horizontal stripapp/3d-models/@categories/error.tsx- Error boundary for categories with built-in retry functionalityapp/3d-models/@results/error.tsx- Error boundary for search results with retry and error guidanceapp/3d-models/@results/loading.tsx- Loading state for search resultsapp/3d-models/categories/[categoryName]/error.tsx- Error boundary for category pages with retry and error guidanceapp/3d-models/categories/[categoryName]/loading.tsx- Loading state for category pagesapp/3d-models/[slug]/error.tsx- Error boundary for model detail pages with retry and error guidance
app/@navbar/default- Parallel route delegating to sharedNavbarapp/@navbar/error.tsx- Error boundary for navbar with retry functionalityapp/@footer/default- Footer parallel route with copyrightcomponents/navbar/navbar- Thin orchestrator:NavbarLogo,OfflineBanner,DesktopNav,MobileMenuButton,MobileNavPopover(smbreakpoint); localkeyframes()scroll-driven header elevatecomponents/navbar/mobile-nav-link- Mobile popover row; closes ancestor popover on navigatecomponents/offline-indicator-OfflineBannerusing Next.jsuseOffline, with persistent reserved navbar placement and container-responsive detail (hidden when online)components/nav-link/nav-link-NavLinkwithSuspensefallback, active state (includesorendsWith), border position (bottomorleft);prefetch={false}by default to reduce network noise (opt-in per link, e.g. model back link)components/nav-link/nav-link-skeleton- Width-matched skeleton forNavLinkSuspense fallbackcomponents/nav-link/nav-link-list-item-li+NavLinkwrappercomponents/nuqs/nuqs-adapter-boundary-Suspense+NuqsAdapterfor the3d-modelslayoutcomponents/top-link- Top-of-page control used in layoutscomponents/navbar/auth-buttons- Sign-out control wrapping avatar (authenticated navbar slot)components/navbar/auth-buttons-skeleton- Navbar auth slot loading statecomponents/navbar/sign-in-nav-link- Icon-only sign-in link for unauthenticated navbar slotfeatures/auth/components/auth-card- Card shell for sign-in/sign-up pagesfeatures/auth/components/auth-footer-link- Footer link between auth screenscomponents/navbar/avatar- Avatar image with fallback
components/arrow-recipe- Panda CVA for directional arrow icons (left/right, compact/default distance, size; group hover/focus/active)components/button- Shared button styled with Panda variants (primary,outline,brandOutline,ghost) and densitiescomponents/button-recipe- Button CVA recipe (brandOutlinefor idle sort; neutraloutlinefor GitHub sign-in)components/form/input- Text input with consistent field stylingcomponents/form/label- Accessible labels for form fieldscomponents/form/form-field- Shared labeled field shell with field errors and shared ViewTransition names for auth formscomponents/pending-button- Pending/busy button (aria-busy,srOnlychildren, spinner)components/inline-error-fallback- Shared retry error fallback (message+retry) for slim parallel-routeerror.tsxfilescomponents/form/reset-button- Reset control for formscomponents/form/field-errors- Field-level error display component with ViewTransition supportcomponents/form/form-error- Form-level error display component with ViewTransition supportcomponents/not-found/unsuccessful-state- Unified component for not-found and error states with conditional styling based onisErrorpropcomponents/not-found/unsuccessful-state-list-item- List item component for unsuccessful state suggestionscomponents/pill- Small label componentcomponents/scroll-progress- Top-of-page reading progress indicator; localkeyframes()scroll-timeline fill/revealcomponents/skeleton- Shared shimmer skeleton (color/highlightColor→ CSS vars; layout viaclassName)components/suspend- Suspense helper componentcomponents/generic-component- Generic wrapper for collections
lib/auth.cli.config- CLI-only Better Auth config for schema generate (no secrets)lib/auth- Runtime Better Auth (server-only; secrets, OAuth, cookies, OpenAPI)lib/auth/get-user- User query withReact.cache()(returnsUserAuthStatefrom session)lib/auth/has-auth- Renderschildren(auth)withUserAuthState;HasAuthSuspensewraps inSuspendlib/auth/sign-out-action- Sign-out server action (actionClient.stateAction)lib/safe-action-next-safe-actionclients (actionClient,authActionClient,formDataInput;server-only)lib/safe-action-form-lastNonSecretFormValueforuseStateActionlastinput(client; skips password fields)lib/slugify- SharedMODEL_SLUGIFY_OPTIONS/SlugifyOptionsfor seed +isModelSlugutils/with-abort- Combine AbortSignals and race promises against abortfeatures/auth/actions- Sign-in, GitHub OAuth, and sign-up NSA stateActions (Valibot +formDataInput)features/auth/constants- Validation constants (password length, email length, name length limits)features/auth/components/auth-form- Shared sign-in/sign-up form (useStateAction+lastNonSecretFormValue)features/auth/components/sign-in-button- GitHub OAuth form (signInGithubAction+useStateAction)components/form/field-errors- Field errors from NSA flattenedvalidationErrors.fieldErrorscomponents/form/form-error- Form-levelserverErrorfrom NSA result
- Biome / Ultracite: Linting and formatting (see
biome.jsonandAGENTS.md) - Fallow: Dead-code / health / duplication plus
boundaries.preset: "bulletproof"(.fallowrc.json); healthmaxCyclomatic/maxCognitive12,maxCrap30;fallow auditon Cursorstophook, before commit/push, and on PRs (.github/workflows/fallow.yml) - React Doctor: React/Next.js diagnostics on pull requests and
mainpushes (.github/workflows/react-doctor.yml); pre-commit via lint-staged (bun run type,bun run react-doctor:staged); full local run withbun run react-doctor - GitHub Actions: Pin
uses:to a full SHA plus same-line# vX.Y.Z; Dependabot weekly (github-actionsin.github/dependabot.yml) bumps SHA and comment together - Cursor hooks:
.cursor/hooks.json— fastafterFileEdit(Ultracite with--skip=correctness/noUnusedImports,test:affected); heavierstop(full fix,fallow audit, full test) - TypeScript 7: Static type checking via
tsc(bun run type); Next usesexperimental.useTypeScriptCli
prepare(automatic onbun install) — Pandastyled-system/build and Husky setupbun run dev- Start development server (Turbopack; Node runtime — see Next.js runtime note)bun run dev:inspect- Start development server with inspector (bun --bun; expectCLIENT_HOOK_DYNAMICnoise — see runtime note)bun run next:upgrade- Upgrade Next.js to latest version (bun --bun)bun run next:analyze- Analyze Next.js bundle (next experimental-analyzeon Node via Varlock, same asbuild). Pass-through:bun run next:analyze -- --output --baseline-name pre-nsa.--outputwrites.next/diagnostics/analyzewithout serving;--baseline-namelabels the snapshot so a later run can compare in the analyzer UI history. Omit--outputto open the UI (default port 4000).bun run build- Build for production (Node runtime viabun varlock run -- next build)bun run build:debug- Build with debug prerender (bun --bun; sameCLIENT_HOOK_DYNAMICleak as runtime note)bun run start- Start production server (Node runtime viabun run next start)bun run test- Run tests (Bun test runner;VARLOCK_ENV=testloads.env.test— no Bitwarden)bun run test:affected- Run tests affected by git changes (bun test --changed --pass-with-no-tests; CursorafterFileEdit)bun run test:watch- Run tests in watch modebun run test:unit- Run unit testsbun run test:components- Run component testsbun run test:integration- Run integration testsbunfig.toml—env = false(Varlock owns.env*resolution);minimumReleaseAge(3 days) with Next/SWC/Panda excludes; test preload order:tests/setup/varlock-test-env.ts→varlock/auto-load→tests/setup/test-preload.ts(Happy DOM +server-onlystub);tests/setup/next-mocks.tsstubs Next navigation; listing nuqs tests usewithListingNuqsTestingAdapterfromtests/setup/nuqs-testing.ts(nuqs/adapters/testing+defaultOptions); integration DB helpers intests/setup/db-test.tsuse Drizzle 1.0{ client }configbun run test:e2e- Run Playwright E2E testsbun run e2e:open- Open Playwright UIbun run e2e:codegen- Playwright codegen (localhost:3000)bun run type- Run Next typegen thentsctype checkingbun run typegen- Generate Next.js routes (next typegen)bun run env:typegen- Regeneratesrc/env.d.tsfrom.env.schema(Varlock)bun run auth:generate- Regenerate Better Auth Drizzle schema fromauth.cli.config.tsbun run db:generate- Generate Drizzle migrationsbun run db:migrate- Run Drizzle migrationsbun run db:push- Push schema directly to databasebun run db:studio- Open Drizzle Studiobun run db:seed- Seed database with initial databun run db:drop- Drop all tables (development reset)bun run fix- Fix linting/formatting issues with Ultracite/Biomebun run check- Check linting rules with Ultracite/Biomebun run doctor- Run Ultracite doctor diagnosticsbun run ultracite:upgrade- Upgrade Ultracite configuration (ultracite@7.11.1)bun run react-doctor- Run React Doctor on the repo (doctor.config.ts)bun run react-doctor:staged- Run React Doctor on staged files only (lint-staged / pre-commit)
The project follows a consistent coding style with:
- ES modules (import/export syntax)
- TypeScript for type safety
- Panda CSS for styling (
cssrecipes, semantic tokens) - Feature-based organization
- Component-specific type definitions
- Proper error handling and logging
- Connect your repository to Vercel
- Set environment variables in Vercel dashboard
- Deploy automatically on push to main branch
- Runtime note: Bun is the install runtime (
vercel.jsonbunVersion1.4.x,installCommand:bun install). Leave the build on default Next (next buildon Node) — do not setbuildCommandtobun --bun run next builduntil oven-sh/bun#39847 / vercel/next.js#97663 land. Cache Components still require the Node runtime. Git auto-deploys are currently off (git.deploymentEnabled: false).
Mirror .env.schema: NEXT_PUBLIC_SITE_URL, BETTER_AUTH_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, TURSO_DATABASE_URL, TURSO_DATABASE_AUTH, and BITWARDEN_ACCESS_TOKEN when using bitwarden() resolvers. Varlock validates at runtime; types live in src/env.d.ts. See docs/VARLOCK.md for Vercel and Bitwarden.
- Update
src/db/seed-data/models.tswith new model data (note:userIdandlikesare omitted from seed data) - Run
bun run db:seedto update the database (requires existing users in the database)
- Add the category to
src/db/categories.ts(updates the category slug enum source of truth) - Run
bun run db:generatethenbun run db:migrate(orbun run db:pushin development) - Run
bun run db:seedto update the database
- Use centralized cache invalidation in
utils/cache-invalidation.ts - Function:
invalidateAllModels()— expires every entry tagged"models"viaupdateTag("models") toggleLikecallsinvalidateAllModels()so list counts andsort=popularstay fresh (simple correctness choice for now)- Longer-term split of stable content vs likes (and popular-order tags): see
docs/MODEL_CACHE_SPLIT.md - Session cache uses
"use cache: private"directive withcacheTag("session")for responsive auth state - Like status uses
features/models/likes/queries/like-status.tswith"use cache: private"for user-specific cache
- Fork the repository
- Create a feature branch
- Make your changes following the feature-based architecture
- Run tests and linting
- Submit a pull request
This project is licensed under the MIT License.
For support and questions:
- Check the documentation
- Review existing issues
- Create a new issue with detailed information