first commit

This commit is contained in:
luoyangwei
2026-06-06 00:49:07 +08:00
commit 2b860d201e
134 changed files with 22773 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
---
name: wallora-searcher
description: Project-specific guide for working on the Wallora searcher Next.js app. Use when Codex is asked to modify, debug, design, review, build, lint, or explain this project, especially public APIs with JWT auth, admin session auth, protected admin pages, SEO public pages, shadcn UI, Supabase data with wg_ table prefixes, gallery photo sync jobs, Aliyun OSS images, NextAuth authentication, Swagger/OpenAPI docs, files under app/, public/, Next.js configuration, Tailwind CSS styling, package scripts, or project setup.
---
# Wallora Searcher
## Purpose
Use this skill to work consistently inside the Wallora searcher project. Treat the local codebase as the source of truth and keep changes aligned with the current Next.js App Router, React, TypeScript, and Tailwind CSS setup.
## First Steps
1. Confirm the working directory is the project root: `/Users/luoyangwei/Documents/workspace/wallora.top/searcher`.
2. Read `references/project.md` when the task involves commands, current baseline state, or project conventions.
3. Read `references/architecture.md` when the task involves API design, authentication, authorization, admin features, public website pages, data storage, image upload/storage, or API documentation.
4. Read `references/supabase-gallery-sync.md` when the task involves gallery photos, Unsplash sync, cron jobs, Supabase migrations, or `wg_gallery_*` tables.
5. Inspect the relevant files before editing. Prefer `rg` and targeted `sed -n` reads.
## Project Workflow
- Use `pnpm` commands because the project has `pnpm-lock.yaml` and `pnpm-workspace.yaml`.
- Use the App Router under `app/`; avoid creating a `pages/` tree unless the project intentionally changes architecture.
- Keep UI work in React Server Components by default. Add `"use client"` only when interactivity, state, effects, or browser APIs require it.
- Use shadcn/ui as the base UI system once it is initialized. Check `components.json` and installed UI components before importing or adding components.
- Prefer Tailwind utility classes and the existing `app/globals.css` theme variables before adding custom CSS.
- Keep metadata in `app/layout.tsx` current when building user-facing pages.
- Do not assume a Git repository is present in this directory; verify before using Git-based workflows.
- Do not add Vercel-specific deployment config unless the user explicitly changes the deployment target. This project is not planned for Vercel.
## Product Architecture
- Separate three surfaces clearly: public API clients, authenticated admin users, and public SEO website visitors.
- Treat API JWT authorization and admin session authorization as different trust boundaries. Do not reuse admin session checks as public API authorization.
- Store application data in Supabase and design RLS/policies deliberately for every exposed table.
- Prefix application-owned Supabase table names with `wg_`.
- Store image files in Aliyun OSS; keep metadata, ownership, and public/private visibility decisions in Supabase.
- Treat raw gallery rows as admin curation data. Public gallery APIs should expose picked rows only.
- Picked photos use `wg_gallery_photos.is_picked` plus `picked_*` and `oss_*` metadata; admin pick/unpick should mirror selected images to Aliyun OSS when `OSS_*` env vars are configured.
- App photo APIs live under `/api/v1/gallery/photos`; list/detail endpoints expose picked photos only and include `isFavorited` when a Bearer app-user JWT is provided.
- Collections are built from picked photos with `wg_photo_collections` and `wg_photo_collection_items`; admin CRUD lives at `/admin/collections` and public APIs live under `/api/v1/gallery/collections`.
- App user features use `wg_app_users`, `wg_user_favorites`, and `wg_user_settings`; public app user auth is email/password plus Bearer JWT, kept separate from admin NextAuth sessions. Admins can view and enable/disable app users under `/admin/users`.
- Use NextAuth for authentication flows where appropriate, especially account/password admin login and session persistence.
- Generate and update `swagger.yaml` when API routes or request/response contracts change.
- Protect every page under `app/(admin)` except `/admin/login`. Use route middleware/proxy and server-side layout checks for protected admin route groups.
## Frontend Expectations
- Build the actual usable experience as the first screen; avoid placeholder landing copy unless explicitly requested.
- Keep layouts responsive from mobile through desktop using stable dimensions, clear spacing, and readable type.
- Use visual assets for website/app experiences when they materially improve the feature. Prefer existing public assets only when they are relevant.
- Avoid adding decorative gradients, oversized marketing sections, or generic template aesthetics unless the user asks for that direction.
- When adding interactive UI, include loading, empty, error, and disabled states where the workflow naturally needs them.
## Verification
Run the smallest useful checks after edits:
```bash
pnpm format:check
pnpm lint
pnpm build
```
For visual or interaction changes, start the dev server with `pnpm dev` and verify in the browser at `http://localhost:3000`. Use screenshots or browser inspection when layout quality matters.
If a command fails because dependencies are missing, install with `pnpm install` only after confirming that is appropriate for the workspace.
## References
- `references/project.md`: project snapshot, file map, scripts, conventions, and current baseline notes.
- `references/architecture.md`: role model, auth boundaries, storage choices, API documentation expectations, and implementation guardrails.
- `references/supabase-gallery-sync.md`: `wg_` table naming, gallery sync schema, Unsplash cron workflow, and non-Vercel scheduler expectations.
@@ -0,0 +1,4 @@
interface:
display_name: "Wallora Searcher"
short_description: "Guide work on the Wallora searcher app"
default_prompt: "Use $wallora-searcher to implement a focused change in the Wallora searcher project."
@@ -0,0 +1,96 @@
# Wallora Searcher Architecture Reference
## Surfaces And Roles
The project has three user-facing surfaces:
- Public API: available to external users. Use JWT login/authorization for protected API access. Keep contracts documented in `swagger.yaml`.
- Admin: available only after account/password login. Use NextAuth-backed sessions to persist admin login state and gate admin features.
- Public website: available to anyone on the public internet. Optimize pages for SEO, metadata, crawlability, and public performance.
Keep these surfaces separate in routing, middleware, authorization helpers, and mental model.
## Route Boundaries
Prefer clear route groups as the app grows:
- Public website: `app/(site)/...`
- Admin UI: `app/(admin)/admin/...`
- Protected admin UI: `app/(admin)/admin/(protected)/...`
- Public API: `app/api/...`
- Auth endpoints: `app/api/auth/...` for NextAuth
If the existing tree does not yet use route groups, introduce them only when it makes the current change clearer.
## Authentication And Authorization
- Public API authorization: validate JWTs explicitly for protected API routes. Define who issues the token before implementing token verification.
- Current app-user JWTs are issued by `/api/v1/auth/login` and `/api/v1/auth/register`, signed with `APP_JWT_SECRET` or `AUTH_SECRET`, and validated by server-only helpers before accessing `/api/v1/users/me/...`.
- Disabled app users should not be able to log in or use existing Bearer tokens because token validation reloads current `wg_app_users.status`.
- Admin authorization: use NextAuth account/password login and session checks. Do not rely on public API JWT logic as the admin session source.
- Only `/admin/login` is public under the admin surface. All other `/admin` pages need middleware/proxy protection and server-side `auth()` checks, preferably via a protected route-group layout.
- Server code must keep secrets server-only. Never expose service-role Supabase keys, Aliyun OSS secrets, NextAuth secrets, or JWT signing secrets in `NEXT_PUBLIC_*`.
- Keep authorization decisions close to the server boundary: route handlers, server actions, middleware, or server-only helpers.
## Data Storage
Use Supabase for application data.
- Prefix project-owned tables, indexes, triggers, and helper functions with `wg_` when practical.
- Enable and design RLS for tables exposed through Supabase APIs.
- Do not use user-editable metadata claims for authorization decisions.
- Model admin roles and API consumer permissions in trusted tables or trusted auth metadata.
- Verify Supabase behavior against current documentation before implementing schema, RLS, or auth-sensitive behavior.
- Use service role credentials only in server-only code, one-shot scripts, cron workers, or protected internal route handlers.
- Treat raw gallery rows as admin data. Public photo APIs should return picked rows only.
- App-facing gallery list/detail endpoints may accept an optional Bearer app-user JWT to add user-specific fields such as `isFavorited`; anonymous responses must still work.
- Keep picked state and OSS mirror metadata on `wg_gallery_photos`; use collections tables to group picked photos into public sets.
- Admin collection writes should validate that every collection item references a picked photo.
- Public collection APIs should filter on `is_published = true` and keep collection photo pagination compatible with infinite scroll.
- App user favorites should accept picked photos only, and preferences/download preferences should be stored as JSON in `wg_user_settings`.
- Keep phone and WeChat identity support as additive fields on `wg_app_users`; do not split them into separate user tables unless provider-specific complexity requires it later.
## Image Storage
Use Aliyun OSS for image files.
- Store files in OSS buckets; store metadata such as object key, URL, owner, MIME type, size, visibility, and related entity in Supabase.
- Prefer server-side signed upload or server-mediated upload for private/admin flows.
- Avoid placing OSS access keys in browser-exposed code.
- Existing OSS env support accepts `OSS_REGION`, `OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_BUCKET`, and either `OSS_PUBLIC_URL` or `OSS_PUBLIC_BASE_URL`.
- Decide whether each image class is public, signed, or private before generating URLs.
## UI System
Use shadcn/ui as the base UI layer.
- Initialize and inspect shadcn project config before adding components.
- Use the project package runner: `pnpm dlx shadcn@latest ...`.
- Prefer existing shadcn components before writing custom controls.
- Use semantic tokens and component variants instead of raw color overrides.
- Compose forms, tables, dialogs, sheets, empty states, loading states, and feedback with shadcn primitives where available.
## SEO Website Pages
For public website pages:
- Use server-rendered pages by default.
- Keep page-level metadata accurate with Next.js metadata APIs.
- Use semantic HTML, crawlable content, canonical URLs where needed, and fast-loading assets.
- Avoid hiding important public content behind client-only rendering.
## API Documentation
Maintain `swagger.yaml` for public API contracts.
- Update it whenever API endpoints, auth requirements, request schemas, response schemas, status codes, or error formats change.
- Keep operation IDs stable and descriptive.
- Document JWT requirements per route instead of assuming all routes share the same auth behavior.
- Treat generated examples as contract examples, not placeholders.
## Scheduled Jobs
- Do not assume Vercel Cron. This project is not planned for Vercel.
- Prefer a one-shot script that can be called by crontab, PM2, Docker, or another scheduler.
- Keep scheduled routes protected with a secret when an HTTP trigger is useful.
- Never rely on `setInterval` inside a Next.js request/serverless process for durable jobs.
@@ -0,0 +1,109 @@
# Wallora Searcher Project Reference
## Snapshot
- Root: `/Users/luoyangwei/Documents/workspace/wallora.top/searcher`
- Package: `searcher`
- Framework: Next.js `16.2.6` with App Router
- React: `19.2.4`
- Styling: Tailwind CSS v4 via `@import "tailwindcss";`
- TypeScript: enabled
- Package manager: `pnpm`
- Git: this directory is not currently a Git repository
- Intended UI system: shadcn/ui
- Intended data store: Supabase with `wg_` table prefixes for project-owned tables
- Intended image store: Aliyun OSS
- Intended auth foundation: NextAuth plus JWT authorization for public APIs
- Intended API documentation: `swagger.yaml`
## Important Files
- `app/layout.tsx`: root HTML shell, metadata, body layout classes, CSS-variable font tokens
- `app/(site)/page.tsx`: public website home page
- `app/(admin)/admin/login/page.tsx`: public admin login page
- `app/(admin)/admin/(protected)/...`: admin pages that require a NextAuth session
- `app/(admin)/admin/(protected)/gallery/page.tsx`: admin gallery picker with picked/color filters
- `app/(admin)/admin/(protected)/collections/page.tsx`: admin collections CRUD surface
- `components/admin/collections/CollectionsManager.tsx`: create/edit/delete published collections from picked photos
- `app/api/admin/gallery-photos/route.ts`: protected admin gallery list and pick/unpick endpoint
- `app/api/admin/photo-collections/route.ts`: protected admin collection list/create API
- `app/api/admin/photo-collections/[id]/route.ts`: protected admin collection read/update/delete API
- `app/api/v1/gallery/photos/route.ts`: public picked-photo API for waterfall pagination
- `app/api/v1/gallery/photos/[id]/route.ts`: public picked-photo detail API plus favorite/unfavorite shortcut
- `app/api/v1/gallery/collections/route.ts`: public published collection list API
- `app/api/v1/gallery/collections/[slug]/route.ts`: public published collection detail API with paginated photos
- `app/api/v1/auth/register/route.ts`: app user email/password registration
- `app/api/v1/auth/login/route.ts`: app user email/password login and Bearer JWT issuance
- `app/api/v1/users/me/route.ts`: current app user profile from Bearer JWT
- `app/api/v1/users/me/favorites/...`: app user favorites list/add/remove APIs
- `app/api/v1/users/me/settings/route.ts`: app user preferences and download preferences APIs
- `app/(admin)/admin/(protected)/users/page.tsx`: protected admin app user list
- `components/admin/users/UsersManager.tsx`: admin app user list with enable/disable actions
- `app/api/admin/app-users/[id]/route.ts`: protected admin app user status update API
- `app/api/cron/gallery-photos/route.ts`: protected gallery sync trigger endpoint
- `app/globals.css`: Tailwind import, CSS variables for background/foreground, font theme tokens
- `package.json`: scripts and dependencies
- `next.config.ts`: Next.js config
- `public/`: starter SVG assets from create-next-app
- `swagger.yaml`: create or update when public API contracts are introduced or changed
- `supabase/migrations/`: SQL migration scripts, including `wg_` gallery tables
- `scripts/sync-gallery-photos.mjs`: one-shot gallery sync script for server cron/process managers
## Commands
```bash
pnpm dev
pnpm lint
pnpm build
pnpm start
pnpm sync:gallery
```
Use `pnpm format:check`, `pnpm lint`, and `pnpm build` for verification. Use `pnpm sync:gallery` only when intentionally touching the real Supabase database.
## Current Baseline Notes
- The public home page still contains create-next-app starter content unless updated later.
- The admin dashboard uses shadcn `dashboard-01` under `/admin/dashboard`.
- The admin login page uses shadcn `login-03` and NextAuth credentials under `/admin/login`.
- The admin gallery supports picked/unpicked filters, color-family filters, pick/unpick actions, and OSS mirror status.
- The admin collections page supports create, edit, delete, publish/draft status, cover selection, and selecting photos from picked photos.
- Public photo consumption should use `/api/v1/gallery/photos`, which returns picked photos only.
- App photo detail should use `/api/v1/gallery/photos/{id}`. Public callers can omit auth; authenticated callers receive `isFavorited` and can `POST`/`DELETE` the same route to favorite/unfavorite.
- Public collection consumption should use `/api/v1/gallery/collections` and `/api/v1/gallery/collections/{slug}`; collection photos are picked photos only.
- App users register and login with email/password via `/api/v1/auth/register` and `/api/v1/auth/login`; the returned Bearer JWT is for public app APIs only.
- App user favorites and settings live under `/api/v1/users/me/...`.
- Admins can review app users and enable/disable accounts under `/admin/users`.
- Bare `/dashboard` and `/login` routes should stay absent; admin templates belong under `/admin`.
## Coding Conventions
- Keep components typed with TypeScript and follow existing double-quote import style.
- Prefer server components unless client behavior is necessary.
- Put global tokens and truly global styles in `app/globals.css`; keep page-specific styling near components with Tailwind classes.
- Keep font tokens in CSS variables; avoid `next/font/google` unless the deployment environment can reliably fetch Google Fonts during build.
- Avoid broad dependency additions for small UI tasks; use the platform and existing stack first.
- Use `pnpm dlx shadcn@latest ...` for shadcn CLI tasks.
- Verify current Supabase, NextAuth, and Aliyun OSS APIs before implementing auth-sensitive or storage-sensitive behavior.
- For database objects created by this app, use the `wg_` prefix, e.g. `wg_gallery_photos`.
- Do not read or print secrets from `.env.local`; report only whether required variables are present.
- This project is not targeting Vercel. Prefer server cron, process manager jobs, or external scheduler calls over `vercel.json` cron config.
## Design Conventions
- Replace starter content with domain-specific product UI when asked to build the app experience.
- Keep cards at `8px` radius or less unless a future design system says otherwise.
- Do not nest cards inside cards.
- Avoid one-note palettes and generic purple/blue gradient-heavy layouts.
- Make text fit inside controls and panels at mobile and desktop widths.
## Architecture Summary
- Public API: available to external users; use JWT login/authorization and document routes in `swagger.yaml`.
- Admin: account/password login; use NextAuth sessions to persist login state and gate admin features.
- Public website: open internet access; optimize for SEO, metadata, crawlability, and performance.
- Supabase stores application data with `wg_` table prefixes; Aliyun OSS stores image files while Supabase stores image metadata.
- Gallery photo ingestion writes to `wg_gallery_photos` and tracks progress in `wg_gallery_sync_state`.
- Picked photo metadata lives on `wg_gallery_photos` via `is_picked`, `picked_at`, `picked_by`, and `oss_*` fields.
- Collections live in `wg_photo_collections` and `wg_photo_collection_items`.
- App users/favorites/settings live in `wg_app_users`, `wg_user_favorites`, and `wg_user_settings`; identity fields include email now and schema placeholders for phone/WeChat later.
@@ -0,0 +1,132 @@
# Supabase Gallery Sync Reference
## Naming
Use `wg_` for project-owned Supabase objects.
- Gallery table: `wg_gallery_photos`
- Sync state table: `wg_gallery_sync_state`
- Collections tables: `wg_photo_collections`, `wg_photo_collection_items`
- User tables: `wg_app_users`, `wg_user_favorites`, `wg_user_settings`
- Example trigger/function names: `set_wg_gallery_photos_updated_at`, `set_wg_updated_at`
Do not introduce unprefixed replacements such as `gallery_photos`.
## Files
- `supabase/migrations/20260530000000_create_wg_gallery_tables.sql`: table/RLS/index/trigger migration.
- `lib/supabase/service-role.ts`: server-only Supabase service-role client.
- `supabase/migrations/20260601000000_extend_gallery_pick_collections_users.sql`: picked-photo, OSS metadata, collections, and user/favorite/settings schema extension.
- `server/api/unsplash.ts`: official Unsplash API fetch and response validation.
- `server/admin/gallery-sync.ts`: reusable sync implementation for Next route handlers.
- `server/admin/gallery-pick.ts`: admin pick/unpick implementation and OSS mirror call.
- `server/storage/aliyun-oss.ts`: server-only Aliyun OSS PUT Object helper.
- `app/api/admin/gallery-photos/route.ts`: protected admin list + pick/unpick API.
- `app/api/admin/photo-collections/route.ts`: protected admin collection list/create API.
- `app/api/admin/photo-collections/[id]/route.ts`: protected admin collection read/update/delete API.
- `app/api/v1/gallery/photos/route.ts`: public picked photos API.
- `app/api/v1/gallery/collections/route.ts`: public published collections API.
- `app/api/v1/gallery/collections/[slug]/route.ts`: public published collection detail API.
- `app/api/cron/gallery-photos/route.ts`: protected HTTP trigger, guarded by `CRON_SECRET`.
- `scripts/sync-gallery-photos.mjs`: one-shot Node script for server cron/process managers.
## Environment
Required for sync:
- `NEXT_PUBLIC_SUPABASE_URL`
- `SUPABASE_SERVICE_ROLE_KEY`
- `UNSPLASH_ACCESS_KEY`
Required for OSS mirroring when an admin marks a photo as picked:
- `OSS_REGION`
- `OSS_ACCESS_KEY_ID`
- `OSS_ACCESS_KEY_SECRET`
- `OSS_BUCKET`
- `OSS_PUBLIC_URL` or `OSS_PUBLIC_BASE_URL`
Optional for OSS:
- `OSS_ENDPOINT` to override the region-derived endpoint
Required for HTTP trigger:
- `CRON_SECRET`
Never print secret values. It is fine to report whether each variable is set.
## Scheduler
This project is not intended for Vercel deployment. Do not add `vercel.json` cron config unless the deployment target changes.
Preferred server cron:
```bash
*/10 * * * * cd /Users/luoyangwei/Documents/workspace/wallora.top/searcher && pnpm sync:gallery
```
The script runs once and exits. That makes it suitable for crontab, PM2 cron, Docker scheduled jobs, or an external scheduler.
## Sync Behavior
Source endpoint:
```txt
https://api.unsplash.com/topics/wallpapers/photos?page=<page>&per_page=20
```
Use the official Unsplash API with `Authorization: Client-ID <UNSPLASH_ACCESS_KEY>`. Do not rely on `unsplash.com/napi/...`; it is a website-internal endpoint and can return `401`, anti-bot HTML, or other non-contract responses.
Workflow:
1. Read `wg_gallery_sync_state` for source `unsplash-wallpapers`.
2. Fetch the current page from the official Unsplash API with `per_page = 20`.
3. Require JSON and validate that the response is an array of photo-like objects.
4. Map every item into `wg_gallery_photos`.
5. Populate `color_family` from the Unsplash hex color so admin color filters work.
6. Upsert with `onConflict: "unsplash_id"` and `ignoreDuplicates: true`.
7. Update `wg_gallery_sync_state` with next page, seen count, inserted count, timestamp, and last error.
If Unsplash returns an error, anti-bot HTML, or any non-JSON response, record the error in `wg_gallery_sync_state.last_error` and do not write photo rows.
## Picked Photos
- Raw gallery rows are for admin curation.
- Public consumers should query picked rows only through `/api/v1/gallery/photos`.
- RLS should expose `wg_gallery_photos` to `anon`/`authenticated` only when `is_picked = true`.
- Admin pick/unpick operations run through server-only code using the Supabase service-role key.
- Picking a photo attempts to mirror the selected Unsplash image into Aliyun OSS and stores `oss_bucket`, `oss_object_key`, `oss_url`, `oss_synced_at`, and `oss_sync_error`.
- If OSS is missing or upload fails, the photo can still be marked picked, but `oss_sync_error` should be visible to admin users.
## Collections
- Collections are admin-managed under `/admin/collections`.
- Admin APIs are session-protected and run with the Supabase service-role client.
- Collection items must reference picked photos only.
- Published collections are exposed publicly; draft collections are not returned by public APIs.
- Public collection detail uses `page` and `pageSize` for waterfall-friendly pagination of collection photos.
- Public gallery photo detail lives at `/api/v1/gallery/photos/[id]`; it is picked-only and supports authenticated favorite/unfavorite shortcuts.
## App Users
- App users are separate from admin users and live in `wg_app_users`.
- Email/password auth uses server-side password hashing and Bearer JWT issuance under `/api/v1/auth/*`.
- Protected app-user routes live under `/api/v1/users/me/*` and must validate Bearer JWTs server-side.
- Admin app-user review and status control live under `/admin/users` and `/api/admin/app-users/[id]`.
- Favorites live in `wg_user_favorites` and should only reference picked photos.
- Preferences and download preferences live in `wg_user_settings` as JSON objects.
- `wg_app_users` has additive identity columns for future phone and WeChat login support.
## Verification
Use these checks after touching sync code:
```bash
node --check scripts/sync-gallery-photos.mjs
pnpm format:check
pnpm lint
pnpm build
```
Only run `pnpm sync:gallery` when the user explicitly wants to write to the configured Supabase database.
+41
View File
@@ -0,0 +1,41 @@
# 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/
/out/
# production
/build
# 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
*.tsbuildinfo
next-env.d.ts
+13
View File
@@ -0,0 +1,13 @@
.codex
.next
node_modules
scripts/.venv
scripts/__pycache__
**/__pycache__
out
build
coverage
docs/api/swagger.html
public/swagger.html
pnpm-lock.yaml
next-env.d.ts
+4
View File
@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"tabWidth": 4
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"prettier.tabWidth": 4,
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"tailwindCSS.codeActions": true,
"tailwindCSS.validate": true,
"tailwindCSS.lint.suggestCanonicalClasses": "warning",
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
+207
View File
@@ -0,0 +1,207 @@
# AGENTS.md — Wallora Searcher
> This file contains agent-focused context about the project: build steps, tests, conventions, and preferences that don't belong in the README.
## Project Overview
Wallora Searcher is a Next.js 16 App Router project with three distinct surfaces:
1. **Public SEO website** — Landing pages for visitors
2. **Admin dashboard** — Authenticated admin interface for gallery/photo management
3. **Public API** — REST endpoints for external clients
## Tech Stack
- **Framework**: Next.js 16.2.6 (App Router, Turbopack)
- **Runtime**: React 19.2.4, React DOM 19.2.4
- **Language**: TypeScript 6.0.3
- **Styling**: Tailwind CSS v4, OKLCH color system
- **UI System**: shadcn/ui (base-nova style, base primitives)
- **Auth**: NextAuth.js v5 beta (Credentials provider)
- **Database**: Supabase (PostgreSQL)
- **Package Manager**: pnpm (monorepo with pnpm-workspace.yaml)
- **Lint**: ESLint 9 + eslint-config-next + tailwind-canonical-classes
- **Python Scripts**: Selenium + ChromeDriver for browser automation
## Directory Structure
```
app/
(admin)/ # Admin route group
admin/
(protected)/ # Protected routes (requires auth)
dashboard/ # Admin dashboard
gallery/ # Gallery photo picker (瀑布流 masonry)
login/ # Admin login page
(site)/ # Public website route group
api/ # API routes
admin/
gallery-photos/ # GET — paginated gallery photos
auth/[...nextauth]/ # NextAuth handlers
cron/
gallery-photos/ # Cron job for sync
components/
admin/gallery/ # Gallery-specific components
ui/ # shadcn/ui components
lib/
auth.ts # NextAuth configuration
supabase/
service-role.ts # Supabase service role client
utils.ts # cn() utility
server/
admin/gallery-sync.ts # Gallery sync logic
api/unsplash.ts # Unsplash API client
scripts/
sync-gallery-photos.py # Python script: Selenium Chrome → Unsplash → Supabase
requirements.txt # Python deps: selenium, webdriver-manager, supabase, python-dotenv
.venv/ # Python virtual environment
types/
admin/gallery.ts # GalleryPhoto, GalleryPagination types
```
## Environment Variables
Required in `.env.local`:
```bash
# NextAuth
AUTH_SECRET=<random-string>
# Supabase
NEXT_PUBLIC_SUPABASE_URL=http://your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
# Admin Credentials
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=yourpassword
# Cron (optional)
CRON_SECRET=<random-string>
```
## Scripts
```bash
# Development
pnpm dev # Start dev server at localhost:3000
# Build & Lint
pnpm build # Production build
pnpm lint # ESLint check
pnpm lint:fix # ESLint with auto-fix
# Format
pnpm format # Prettier format all
pnpm format:check # Prettier check
# Gallery Sync
pnpm sync:gallery # Node.js sync script (legacy)
# Python Sync
python scripts/sync-gallery-photos.py --max-pages=10 --topic=wallpapers
```
## Python Sync Script
The Python script controls a real Chrome browser via Selenium to fetch Unsplash data:
```bash
# Setup (first time)
cd scripts
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# Run
python scripts/sync-gallery-photos.py --max-pages=5 --topic=wallpapers
```
**Features**:
- `--max-pages`: Required. Pages to fetch (1..N)
- `--topic`: Optional. Unsplash topic slug (e.g. `wallpapers`, `3d-renders`, `nature`)
- `--per-page`: Optional. Default 20
- `--headless/--no-headless`: Default headless
- `--delay`: Base delay between pages (default 3s, +0-2s random)
## Admin Authentication
- Uses NextAuth.js v5 beta with Credentials provider
- Login page: `/admin/login`
- Protected routes: `/admin/(protected)/*`
- Route protection via `proxy.ts` (Next.js 16 middleware convention)
- Session stored as JWT
## Gallery Page Features
- **Masonry Layout**: 4 independent columns with greedy shortest-column algorithm
- **Infinite Scroll**: IntersectionObserver-based pagination
- **Animations**: Entrance fade + translateY + scale, hover scale + shadow, lightbox transitions
- **Lightbox**: Click to view full image with metadata overlay
- **API**: `/api/admin/gallery-photos?page=N&per_page=24`
## Supabase Tables
- `wg_gallery_photos` — Photo data from Unsplash
- `wg_gallery_sync_state` — Sync state tracking
## Conventions
### Routing
- Use App Router (`app/`). Never create `pages/` unless explicitly requested.
- Route groups: `(admin)` for admin, `(site)` for public.
- Protected routes nested under `(protected)`.
### Components
- React Server Components by default.
- Add `"use client"` only for interactivity (state, effects, browser APIs).
- shadcn/ui as base. Use `npx shadcn@latest add <component>` to install.
- Prefer Tailwind utility classes and CSS variables over custom CSS.
### API Design
- Separate JWT auth (public API) and session auth (admin) as different trust boundaries.
- Update `swagger.yaml` when API contracts change.
### Styling
- Use semantic colors (`bg-primary`, `text-muted-foreground`).
- No raw colors like `bg-blue-500`.
- Use `size-*` for equal width/height.
- Use `gap-*` instead of `space-x-*` / `space-y-*`.
### Python Scripts
- All Python code lives in `scripts/`.
- Use virtual environment at `scripts/.venv/`.
- Keep `.env.local` parsing logic consistent with Node.js scripts.
## Known Issues / Gotchas
1. **Next.js 16 uses `proxy.ts` instead of `middleware.ts`** for route interception.
2. **shadcn/ui base-nova style** uses `@base-ui/react` primitives, not Radix.
3. **ESLint** has `react-hooks/set-state-in-effect` disabled (false positives with async data fetching).
4. **`.venv/` and `node_modules/`** should be ignored by ESLint (configured in `eslint.config.mjs`).
5. **GalleryGrid** uses `<img>` not Next.js `<Image>` because external Unsplash URLs don't work well with the Image optimizer without domain configuration.
## Verification Checklist
After making changes:
- [ ] `pnpm lint` passes
- [ ] `pnpm build` succeeds
- [ ] For visual changes: verify at `http://localhost:3000`
- [ ] For admin changes: verify login/logout flow
- [ ] For API changes: test with curl or browser
## Contact
- Admin: luoyw1106703846@gmail.com
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -0,0 +1,47 @@
import type { CSSProperties } from "react";
import { CollectionsManager } from "@/components/admin/collections/CollectionsManager";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import {
listAdminPhotoCollections,
listPickedPhotosForCollections,
} from "@/server/admin/photo-collections";
export const dynamic = "force-dynamic";
export default async function CollectionsPage() {
const [collections, pickedPhotos] = await Promise.all([
listAdminPhotoCollections(),
listPickedPhotosForCollections(),
]);
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<h1 className="text-base font-medium">Collections</h1>
</div>
</header>
<CollectionsManager
initialCollections={collections}
initialPickedPhotos={pickedPhotos}
/>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,614 @@
[
{
"id": 1,
"header": "Cover page",
"type": "Cover page",
"status": "In Process",
"target": "18",
"limit": "5",
"reviewer": "Eddie Lake"
},
{
"id": 2,
"header": "Table of contents",
"type": "Table of contents",
"status": "Done",
"target": "29",
"limit": "24",
"reviewer": "Eddie Lake"
},
{
"id": 3,
"header": "Executive summary",
"type": "Narrative",
"status": "Done",
"target": "10",
"limit": "13",
"reviewer": "Eddie Lake"
},
{
"id": 4,
"header": "Technical approach",
"type": "Narrative",
"status": "Done",
"target": "27",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 5,
"header": "Design",
"type": "Narrative",
"status": "In Process",
"target": "2",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 6,
"header": "Capabilities",
"type": "Narrative",
"status": "In Process",
"target": "20",
"limit": "8",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 7,
"header": "Integration with existing systems",
"type": "Narrative",
"status": "In Process",
"target": "19",
"limit": "21",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 8,
"header": "Innovation and Advantages",
"type": "Narrative",
"status": "Done",
"target": "25",
"limit": "26",
"reviewer": "Assign reviewer"
},
{
"id": 9,
"header": "Overview of EMR's Innovative Solutions",
"type": "Technical content",
"status": "Done",
"target": "7",
"limit": "23",
"reviewer": "Assign reviewer"
},
{
"id": 10,
"header": "Advanced Algorithms and Machine Learning",
"type": "Narrative",
"status": "Done",
"target": "30",
"limit": "28",
"reviewer": "Assign reviewer"
},
{
"id": 11,
"header": "Adaptive Communication Protocols",
"type": "Narrative",
"status": "Done",
"target": "9",
"limit": "31",
"reviewer": "Assign reviewer"
},
{
"id": 12,
"header": "Advantages Over Current Technologies",
"type": "Narrative",
"status": "Done",
"target": "12",
"limit": "0",
"reviewer": "Assign reviewer"
},
{
"id": 13,
"header": "Past Performance",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "33",
"reviewer": "Assign reviewer"
},
{
"id": 14,
"header": "Customer Feedback and Satisfaction Levels",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "34",
"reviewer": "Assign reviewer"
},
{
"id": 15,
"header": "Implementation Challenges and Solutions",
"type": "Narrative",
"status": "Done",
"target": "3",
"limit": "35",
"reviewer": "Assign reviewer"
},
{
"id": 16,
"header": "Security Measures and Data Protection Policies",
"type": "Narrative",
"status": "In Process",
"target": "6",
"limit": "36",
"reviewer": "Assign reviewer"
},
{
"id": 17,
"header": "Scalability and Future Proofing",
"type": "Narrative",
"status": "Done",
"target": "4",
"limit": "37",
"reviewer": "Assign reviewer"
},
{
"id": 18,
"header": "Cost-Benefit Analysis",
"type": "Plain language",
"status": "Done",
"target": "14",
"limit": "38",
"reviewer": "Assign reviewer"
},
{
"id": 19,
"header": "User Training and Onboarding Experience",
"type": "Narrative",
"status": "Done",
"target": "17",
"limit": "39",
"reviewer": "Assign reviewer"
},
{
"id": 20,
"header": "Future Development Roadmap",
"type": "Narrative",
"status": "Done",
"target": "11",
"limit": "40",
"reviewer": "Assign reviewer"
},
{
"id": 21,
"header": "System Architecture Overview",
"type": "Technical content",
"status": "In Process",
"target": "24",
"limit": "18",
"reviewer": "Maya Johnson"
},
{
"id": 22,
"header": "Risk Management Plan",
"type": "Narrative",
"status": "Done",
"target": "15",
"limit": "22",
"reviewer": "Carlos Rodriguez"
},
{
"id": 23,
"header": "Compliance Documentation",
"type": "Legal",
"status": "In Process",
"target": "31",
"limit": "27",
"reviewer": "Sarah Chen"
},
{
"id": 24,
"header": "API Documentation",
"type": "Technical content",
"status": "Done",
"target": "8",
"limit": "12",
"reviewer": "Raj Patel"
},
{
"id": 25,
"header": "User Interface Mockups",
"type": "Visual",
"status": "In Process",
"target": "19",
"limit": "25",
"reviewer": "Leila Ahmadi"
},
{
"id": 26,
"header": "Database Schema",
"type": "Technical content",
"status": "Done",
"target": "22",
"limit": "20",
"reviewer": "Thomas Wilson"
},
{
"id": 27,
"header": "Testing Methodology",
"type": "Technical content",
"status": "In Process",
"target": "17",
"limit": "14",
"reviewer": "Assign reviewer"
},
{
"id": 28,
"header": "Deployment Strategy",
"type": "Narrative",
"status": "Done",
"target": "26",
"limit": "30",
"reviewer": "Eddie Lake"
},
{
"id": 29,
"header": "Budget Breakdown",
"type": "Financial",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 30,
"header": "Market Analysis",
"type": "Research",
"status": "Done",
"target": "29",
"limit": "32",
"reviewer": "Sophia Martinez"
},
{
"id": 31,
"header": "Competitor Comparison",
"type": "Research",
"status": "In Process",
"target": "21",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 32,
"header": "Maintenance Plan",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "23",
"reviewer": "Alex Thompson"
},
{
"id": 33,
"header": "User Personas",
"type": "Research",
"status": "In Process",
"target": "27",
"limit": "24",
"reviewer": "Nina Patel"
},
{
"id": 34,
"header": "Accessibility Compliance",
"type": "Legal",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 35,
"header": "Performance Metrics",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "David Kim"
},
{
"id": 36,
"header": "Disaster Recovery Plan",
"type": "Technical content",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 37,
"header": "Third-party Integrations",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Eddie Lake"
},
{
"id": 38,
"header": "User Feedback Summary",
"type": "Research",
"status": "Done",
"target": "20",
"limit": "15",
"reviewer": "Assign reviewer"
},
{
"id": 39,
"header": "Localization Strategy",
"type": "Narrative",
"status": "In Process",
"target": "12",
"limit": "19",
"reviewer": "Maria Garcia"
},
{
"id": 40,
"header": "Mobile Compatibility",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "James Wilson"
},
{
"id": 41,
"header": "Data Migration Plan",
"type": "Technical content",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Assign reviewer"
},
{
"id": 42,
"header": "Quality Assurance Protocols",
"type": "Technical content",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Priya Singh"
},
{
"id": 43,
"header": "Stakeholder Analysis",
"type": "Research",
"status": "In Process",
"target": "11",
"limit": "14",
"reviewer": "Eddie Lake"
},
{
"id": 44,
"header": "Environmental Impact Assessment",
"type": "Research",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Assign reviewer"
},
{
"id": 45,
"header": "Intellectual Property Rights",
"type": "Legal",
"status": "In Process",
"target": "17",
"limit": "20",
"reviewer": "Sarah Johnson"
},
{
"id": 46,
"header": "Customer Support Framework",
"type": "Narrative",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 47,
"header": "Version Control Strategy",
"type": "Technical content",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 48,
"header": "Continuous Integration Pipeline",
"type": "Technical content",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Michael Chen"
},
{
"id": 49,
"header": "Regulatory Compliance",
"type": "Legal",
"status": "In Process",
"target": "13",
"limit": "16",
"reviewer": "Assign reviewer"
},
{
"id": 50,
"header": "User Authentication System",
"type": "Technical content",
"status": "Done",
"target": "28",
"limit": "31",
"reviewer": "Eddie Lake"
},
{
"id": 51,
"header": "Data Analytics Framework",
"type": "Technical content",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 52,
"header": "Cloud Infrastructure",
"type": "Technical content",
"status": "Done",
"target": "16",
"limit": "19",
"reviewer": "Assign reviewer"
},
{
"id": 53,
"header": "Network Security Measures",
"type": "Technical content",
"status": "In Process",
"target": "29",
"limit": "32",
"reviewer": "Lisa Wong"
},
{
"id": 54,
"header": "Project Timeline",
"type": "Planning",
"status": "Done",
"target": "14",
"limit": "17",
"reviewer": "Eddie Lake"
},
{
"id": 55,
"header": "Resource Allocation",
"type": "Planning",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Assign reviewer"
},
{
"id": 56,
"header": "Team Structure and Roles",
"type": "Planning",
"status": "Done",
"target": "20",
"limit": "23",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 57,
"header": "Communication Protocols",
"type": "Planning",
"status": "In Process",
"target": "15",
"limit": "18",
"reviewer": "Assign reviewer"
},
{
"id": 58,
"header": "Success Metrics",
"type": "Planning",
"status": "Done",
"target": "30",
"limit": "33",
"reviewer": "Eddie Lake"
},
{
"id": 59,
"header": "Internationalization Support",
"type": "Technical content",
"status": "In Process",
"target": "23",
"limit": "26",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 60,
"header": "Backup and Recovery Procedures",
"type": "Technical content",
"status": "Done",
"target": "18",
"limit": "21",
"reviewer": "Assign reviewer"
},
{
"id": 61,
"header": "Monitoring and Alerting System",
"type": "Technical content",
"status": "In Process",
"target": "25",
"limit": "28",
"reviewer": "Daniel Park"
},
{
"id": 62,
"header": "Code Review Guidelines",
"type": "Technical content",
"status": "Done",
"target": "12",
"limit": "15",
"reviewer": "Eddie Lake"
},
{
"id": 63,
"header": "Documentation Standards",
"type": "Technical content",
"status": "In Process",
"target": "27",
"limit": "30",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 64,
"header": "Release Management Process",
"type": "Planning",
"status": "Done",
"target": "22",
"limit": "25",
"reviewer": "Assign reviewer"
},
{
"id": 65,
"header": "Feature Prioritization Matrix",
"type": "Planning",
"status": "In Process",
"target": "19",
"limit": "22",
"reviewer": "Emma Davis"
},
{
"id": 66,
"header": "Technical Debt Assessment",
"type": "Technical content",
"status": "Done",
"target": "24",
"limit": "27",
"reviewer": "Eddie Lake"
},
{
"id": 67,
"header": "Capacity Planning",
"type": "Planning",
"status": "In Process",
"target": "21",
"limit": "24",
"reviewer": "Jamik Tashpulatov"
},
{
"id": 68,
"header": "Service Level Agreements",
"type": "Legal",
"status": "Done",
"target": "26",
"limit": "29",
"reviewer": "Assign reviewer"
}
]
@@ -0,0 +1,37 @@
import { AppSidebar } from "@/components/app-sidebar";
import { ChartAreaInteractive } from "@/components/chart-area-interactive";
import { DataTable } from "@/components/data-table";
import { SectionCards } from "@/components/section-cards";
import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import data from "./data.json";
export default function DashboardPage() {
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
<SectionCards />
<div className="px-4 lg:px-6">
<ChartAreaInteractive />
</div>
<DataTable data={data} />
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,33 @@
"use client";
import { GalleryGrid } from "@/components/admin/gallery/GalleryGrid";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function GalleryPage() {
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<h1 className="text-base font-medium">Gallery</h1>
</div>
</header>
<GalleryGrid />
</SidebarInset>
</SidebarProvider>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function ProtectedAdminLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
if (!session) {
redirect("/admin/login");
}
return children;
}
@@ -0,0 +1,38 @@
import type { CSSProperties } from "react";
import { UsersManager } from "@/components/admin/users/UsersManager";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { listAdminAppUsers } from "@/server/admin/app-users";
export const dynamic = "force-dynamic";
export default async function UsersPage() {
const users = await listAdminAppUsers();
return (
<SidebarProvider
style={
{
"--sidebar-width": "calc(var(--spacing) * 72)",
"--header-height": "calc(var(--spacing) * 12)",
} as CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<h1 className="text-base font-medium">Users</h1>
</div>
</header>
<UsersManager initialUsers={users} />
</SidebarInset>
</SidebarProvider>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { LoginForm } from "@/components/login-form";
import { signIn } from "@/lib/auth";
import { GalleryVerticalEndIcon } from "lucide-react";
import { AuthError } from "next-auth";
import { redirect } from "next/navigation";
async function authenticate(formData: FormData) {
"use server";
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/admin/dashboard",
});
} catch (error) {
if (error instanceof AuthError) {
redirect("/admin/login?error=CredentialsSignin");
}
throw error;
}
}
export default async function LoginPage({
searchParams,
}: {
searchParams?: Promise<{ error?: string }>;
}) {
const params = await searchParams;
const error =
params?.error === "CredentialsSignin"
? "账号或密码错误,请重试。"
: undefined;
return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6">
<div className="flex items-center gap-2 self-center font-medium">
<div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<GalleryVerticalEndIcon className="size-4" />
</div>
Wallora Admin
</div>
<LoginForm action={authenticate} error={error} />
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function AdminPage() {
const session = await auth();
if (!session) {
redirect("/admin/login");
}
redirect("/admin/dashboard");
}
+66
View File
@@ -0,0 +1,66 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex w-full max-w-3xl flex-1 flex-col items-center justify-between bg-white px-16 py-32 sm:items-start dark:bg-black">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl leading-10 font-semibold tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head
over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="bg-foreground text-background flex h-12 w-full items-center justify-center gap-2 rounded-full px-5 transition-colors hover:bg-[#383838] md:w-39.5 dark:hover:bg-[#ccc]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/8 px-5 transition-colors hover:border-transparent hover:bg-black/4 md:w-39.5 dark:border-white/[.145] dark:hover:bg-[#1a1a1a]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { auth } from "@/lib/auth";
import { updateAdminAppUserStatus } from "@/server/admin/app-users";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const updateUserSchema = z.object({
status: z.enum(["active", "disabled"]),
});
async function requireAdminSession() {
const session = await auth();
if (!session) {
return null;
}
return session;
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = updateUserSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const { id } = await params;
const data = await updateAdminAppUserStatus({
userId: id,
status: parsed.data.status,
});
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+137
View File
@@ -0,0 +1,137 @@
import { auth } from "@/lib/auth";
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import { updateGalleryPhotoPickStatus } from "@/server/admin/gallery-pick";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const galleryPhotoSelect =
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,is_picked,picked_at,picked_by,oss_url,oss_object_key,oss_synced_at,oss_sync_error";
const patchSchema = z.object({
unsplashId: z.string().min(1),
isPicked: z.boolean(),
});
async function requireAdminSession() {
const session = await auth();
if (!session) {
return null;
}
return session;
}
export async function GET(request: NextRequest) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const perPage = parseInt(searchParams.get("per_page") || "24", 10);
const picked = searchParams.get("picked");
const colorFamily = searchParams.get("color_family");
if (page < 1 || perPage < 1 || perPage > 100) {
return NextResponse.json(
{ ok: false, error: "Invalid pagination parameters." },
{ status: 400 },
);
}
const supabase = createSupabaseServiceRoleClient();
let query = supabase
.from("wg_gallery_photos")
.select(galleryPhotoSelect, { count: "exact" });
if (picked === "true") {
query = query.eq("is_picked", true);
} else if (picked === "false") {
query = query.eq("is_picked", false);
}
if (colorFamily && colorFamily !== "all") {
query = query.eq("color_family", colorFamily);
}
const { data, error, count } = await query
.order("picked_at", { ascending: false, nullsFirst: false })
.order("photo_created_at", { ascending: false })
.range((page - 1) * perPage, page * perPage - 1);
if (error) {
throw error;
}
return NextResponse.json({
ok: true,
data: data ?? [],
pagination: {
page,
perPage,
total: count ?? 0,
hasMore: (count ?? 0) > page * perPage,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
console.error("[api/admin/gallery-photos] PATCH failed", error);
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function PATCH(request: NextRequest) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = patchSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const data = await updateGalleryPhotoPickStatus({
isPicked: parsed.data.isPicked,
pickedBy:
session.user?.email ?? session.user?.id ?? "wallora-admin",
unsplashId: parsed.data.unsplashId,
});
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
@@ -0,0 +1,138 @@
import { auth } from "@/lib/auth";
import {
deletePhotoCollection,
getAdminPhotoCollection,
updatePhotoCollection,
} from "@/server/admin/photo-collections";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const collectionInputSchema = z.object({
title: z.string().min(1),
slug: z.string().optional().nullable(),
description: z.string().optional().nullable(),
coverPhotoId: z.string().uuid().optional().nullable(),
isPublished: z.boolean().default(false),
photoIds: z.array(z.string().uuid()).default([]),
});
async function requireAdminSession() {
const session = await auth();
if (!session) {
return null;
}
return session;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const { id } = await params;
const data = await getAdminPhotoCollection(id);
if (!data) {
return NextResponse.json(
{ ok: false, error: "Collection was not found." },
{ status: 404 },
);
}
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = collectionInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const { id } = await params;
const data = await updatePhotoCollection(id, parsed.data);
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const { id } = await params;
await deletePhotoCollection(id);
return NextResponse.json({
ok: true,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import { auth } from "@/lib/auth";
import {
createPhotoCollection,
listAdminPhotoCollections,
} from "@/server/admin/photo-collections";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const collectionInputSchema = z.object({
title: z.string().min(1),
slug: z.string().optional().nullable(),
description: z.string().optional().nullable(),
coverPhotoId: z.string().uuid().optional().nullable(),
isPublished: z.boolean().default(false),
photoIds: z.array(z.string().uuid()).default([]),
});
async function requireAdminSession() {
const session = await auth();
if (!session) {
return null;
}
return session;
}
export async function GET() {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const data = await listAdminPhotoCollections();
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const session = await requireAdminSession();
if (!session) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = collectionInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const data = await createPhotoCollection(parsed.data);
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { GET, POST } from "@/lib/auth";
export { GET, POST };
+52
View File
@@ -0,0 +1,52 @@
import { syncUnsplashWallpapersToGallery } from "@/server/admin/gallery-sync";
import { NextResponse, type NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function isAuthorized(request: NextRequest) {
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret) {
return false;
}
return request.headers.get("authorization") === `Bearer ${cronSecret}`;
}
async function handleGalleryPhotosCron(request: NextRequest) {
if (!process.env.CRON_SECRET) {
return NextResponse.json(
{ ok: false, error: "CRON_SECRET is not configured." },
{ status: 500 },
);
}
if (!isAuthorized(request)) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
try {
const result = await syncUnsplashWallpapersToGallery();
return NextResponse.json({ ok: true, result });
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown cron error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
return handleGalleryPhotosCron(request);
}
export async function POST(request: NextRequest) {
return handleGalleryPhotosCron(request);
}
+44
View File
@@ -0,0 +1,44 @@
import {
getAppUserLoginContextFromRequest,
loginAppUser,
} from "@/server/app/app-auth";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const loginSchema = z.object({
email: z.email(),
password: z.string().min(1),
});
export async function POST(request: Request) {
try {
const parsed = loginSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const data = await loginAppUser({
...parsed.data,
context: getAppUserLoginContextFromRequest(request),
});
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 401 },
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { registerAppUser } from "@/server/app/app-auth";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const registerSchema = z.object({
email: z.email(),
password: z.string().min(8),
displayName: z.string().optional().nullable(),
});
export async function POST(request: Request) {
try {
const parsed = registerSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const data = await registerAppUser(parsed.data);
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 400 },
);
}
}
@@ -0,0 +1,67 @@
import { getOptionalAppUserFromRequest } from "@/server/app/app-auth";
import { getPublishedCollectionBySlug } from "@/server/site/photo-collections";
import { NextResponse, type NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
try {
const { searchParams } = new URL(request.url);
const { slug } = await params;
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(
searchParams.get("pageSize") ||
searchParams.get("page_size") ||
"24",
10,
);
if (page < 1 || pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ ok: false, error: "Invalid pagination parameters." },
{ status: 400 },
);
}
const user = await getOptionalAppUserFromRequest(request);
const result = await getPublishedCollectionBySlug({
slug,
page,
pageSize,
userId: user?.id ?? null,
});
if (!result) {
return NextResponse.json(
{ ok: false, error: "Collection was not found." },
{ status: 404 },
);
}
return NextResponse.json({
ok: true,
data: {
collection: result.collection,
photos: result.photos,
},
pagination: {
page,
pageSize,
total: result.total,
hasMore: result.total > page * pageSize,
nextPage: result.total > page * pageSize ? page + 1 : null,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { getOptionalAppUserFromRequest } from "@/server/app/app-auth";
import { listPublishedCollections } from "@/server/site/photo-collections";
import { NextResponse, type NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(
searchParams.get("pageSize") ||
searchParams.get("page_size") ||
"20",
10,
);
if (page < 1 || pageSize < 1 || pageSize > 50) {
return NextResponse.json(
{ ok: false, error: "Invalid pagination parameters." },
{ status: 400 },
);
}
const user = await getOptionalAppUserFromRequest(request);
const result = await listPublishedCollections({
page,
pageSize,
userId: user?.id ?? null,
});
return NextResponse.json({
ok: true,
data: result.data,
pagination: {
page,
pageSize,
total: result.total,
hasMore: result.total > page * pageSize,
nextPage: result.total > page * pageSize ? page + 1 : null,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+142
View File
@@ -0,0 +1,142 @@
import {
getAppUserFromRequest,
getOptionalAppUserFromRequest,
} from "@/server/app/app-auth";
import {
addUserFavorite,
removeUserFavorite,
} from "@/server/app/user-favorites";
import { getPickedGalleryPhotoById } from "@/server/site/gallery-photos";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const paramsSchema = z.object({
id: z.uuid(),
});
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const parsed = paramsSchema.safeParse(await params);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid photo id." },
{ status: 400 },
);
}
const user = await getOptionalAppUserFromRequest(request);
const data = await getPickedGalleryPhotoById({
id: parsed.data.id,
userId: user?.id ?? null,
});
if (!data) {
return NextResponse.json(
{ ok: false, error: "Photo was not found." },
{ status: 404 },
);
}
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = paramsSchema.safeParse(await params);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid photo id." },
{ status: 400 },
);
}
const data = await addUserFavorite({
userId: user.id,
photoId: parsed.data.id,
});
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = paramsSchema.safeParse(await params);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid photo id." },
{ status: 400 },
);
}
await removeUserFavorite({
userId: user.id,
photoId: parsed.data.id,
});
return NextResponse.json({
ok: true,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { getOptionalAppUserFromRequest } from "@/server/app/app-auth";
import { listPickedGalleryPhotos } from "@/server/site/gallery-photos";
import { NextResponse, type NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(
searchParams.get("pageSize") ||
searchParams.get("page_size") ||
"24",
10,
);
const colorFamily = searchParams.get("colorFamily");
if (page < 1 || pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ ok: false, error: "Invalid pagination parameters." },
{ status: 400 },
);
}
const user = await getOptionalAppUserFromRequest(request);
const result = await listPickedGalleryPhotos({
colorFamily,
page,
pageSize,
userId: user?.id ?? null,
});
return NextResponse.json({
ok: true,
data: result.data,
pagination: {
page,
pageSize,
total: result.total,
hasMore: result.total > page * pageSize,
nextPage: result.total > page * pageSize ? page + 1 : null,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
@@ -0,0 +1,52 @@
import { getAppUserFromRequest } from "@/server/app/app-auth";
import { removeUserFavorite } from "@/server/app/user-favorites";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const paramsSchema = z.object({
photoId: z.uuid(),
});
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ photoId: string }> },
) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = paramsSchema.safeParse(await params);
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid photo id." },
{ status: 400 },
);
}
await removeUserFavorite({
userId: user.id,
photoId: parsed.data.photoId,
});
return NextResponse.json({
ok: true,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+107
View File
@@ -0,0 +1,107 @@
import { getAppUserFromRequest } from "@/server/app/app-auth";
import {
addUserFavorite,
listUserFavorites,
} from "@/server/app/user-favorites";
import { NextResponse, type NextRequest } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const favoriteSchema = z.object({
photoId: z.uuid(),
});
export async function GET(request: NextRequest) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(
searchParams.get("pageSize") ||
searchParams.get("page_size") ||
"24",
10,
);
if (page < 1 || pageSize < 1 || pageSize > 100) {
return NextResponse.json(
{ ok: false, error: "Invalid pagination parameters." },
{ status: 400 },
);
}
const result = await listUserFavorites({
userId: user.id,
page,
pageSize,
});
return NextResponse.json({
ok: true,
data: result.data,
pagination: {
page,
pageSize,
total: result.total,
hasMore: result.total > page * pageSize,
nextPage: result.total > page * pageSize ? page + 1 : null,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function POST(request: Request) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = favoriteSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const data = await addUserFavorite({
userId: user.id,
photoId: parsed.data.photoId,
});
return NextResponse.json({
ok: true,
data,
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { getAppUserFromRequest } from "@/server/app/app-auth";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
return NextResponse.json({
ok: true,
data: {
user,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 401 },
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import { getAppUserFromRequest } from "@/server/app/app-auth";
import {
getUserSettings,
updateUserSettings,
} from "@/server/app/user-preferences";
import { NextResponse } from "next/server";
import { z } from "zod";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const settingsSchema = z.object({
preferences: z.record(z.string(), z.unknown()).optional(),
downloadPreferences: z.record(z.string(), z.unknown()).optional(),
});
function mapSettings(settings: Awaited<ReturnType<typeof getUserSettings>>) {
return {
preferences: settings.preferences,
downloadPreferences: settings.download_preferences,
updatedAt: settings.updated_at,
};
}
export async function GET(request: Request) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const settings = await getUserSettings(user.id);
return NextResponse.json({
ok: true,
data: mapSettings(settings),
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
export async function PATCH(request: Request) {
try {
const user = await getAppUserFromRequest(request);
if (!user) {
return NextResponse.json(
{ ok: false, error: "Unauthorized." },
{ status: 401 },
);
}
const parsed = settingsSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "Invalid request body." },
{ status: 400 },
);
}
const settings = await updateUserSettings({
userId: user.id,
preferences: parsed.data.preferences,
downloadPreferences: parsed.data.downloadPreferences,
});
return NextResponse.json({
ok: true,
data: mapSettings(settings),
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error.";
return NextResponse.json(
{ ok: false, error: message },
{ status: 500 },
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+248
View File
@@ -0,0 +1,248 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9551 0 0);
--foreground: oklch(0.3211 0 0);
--card: oklch(0.9702 0 0);
--card-foreground: oklch(0.3211 0 0);
--popover: oklch(0.9702 0 0);
--popover-foreground: oklch(0.3211 0 0);
--primary: oklch(0.4891 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9067 0 0);
--secondary-foreground: oklch(0.3211 0 0);
--muted: oklch(0.8853 0 0);
--muted-foreground: oklch(0.5103 0 0);
--accent: oklch(0.8078 0 0);
--accent-foreground: oklch(0.3211 0 0);
--destructive: oklch(0.5594 0.19 25.8625);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.8576 0 0);
--input: oklch(0.9067 0 0);
--ring: oklch(0.4891 0 0);
--chart-1: oklch(0.4891 0 0);
--chart-2: oklch(0.4863 0.0361 196.0278);
--chart-3: oklch(0.6534 0 0);
--chart-4: oklch(0.7316 0 0);
--chart-5: oklch(0.8078 0 0);
--sidebar: oklch(0.937 0 0);
--sidebar-foreground: oklch(0.3211 0 0);
--sidebar-primary: oklch(0.4891 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.8078 0 0);
--sidebar-accent-foreground: oklch(0.3211 0 0);
--sidebar-border: oklch(0.8576 0 0);
--sidebar-ring: oklch(0.4891 0 0);
--font-sans: Montserrat, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 20% / 0.1);
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-sm:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow-md:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 2px 4px -1px hsl(0 0% 20% / 0.15);
--shadow-lg:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 4px 6px -1px hsl(0 0% 20% / 0.15);
--shadow-xl:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 8px 10px -1px hsl(0 0% 20% / 0.15);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2178 0 0);
--foreground: oklch(0.8853 0 0);
--card: oklch(0.2435 0 0);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0.2435 0 0);
--popover-foreground: oklch(0.8853 0 0);
--primary: oklch(0.7058 0 0);
--primary-foreground: oklch(0.2178 0 0);
--secondary: oklch(0.3092 0 0);
--secondary-foreground: oklch(0.8853 0 0);
--muted: oklch(0.285 0 0);
--muted-foreground: oklch(0.5999 0 0);
--accent: oklch(0.3715 0 0);
--accent-foreground: oklch(0.8853 0 0);
--destructive: oklch(0.6591 0.153 22.1703);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.329 0 0);
--input: oklch(0.3092 0 0);
--ring: oklch(0.7058 0 0);
--chart-1: oklch(0.7058 0 0);
--chart-2: oklch(0.6714 0.0339 206.3482);
--chart-3: oklch(0.5452 0 0);
--chart-4: oklch(0.4604 0 0);
--chart-5: oklch(0.3715 0 0);
--sidebar: oklch(0.2393 0 0);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.7058 0 0);
--sidebar-primary-foreground: oklch(0.2178 0 0);
--sidebar-accent: oklch(0.3715 0 0);
--sidebar-accent-foreground: oklch(0.8853 0 0);
--sidebar-border: oklch(0.329 0 0);
--sidebar-ring: oklch(0.7058 0 0);
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 20% / 0.1);
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-sm:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow-md:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 2px 4px -1px hsl(0 0% 20% / 0.15);
--shadow-lg:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 4px 6px -1px hsl(0 0% 20% / 0.15);
--shadow-xl:
0px 2px 0px 0px hsl(0 0% 20% / 0.15),
0px 8px 10px -1px hsl(0 0% 20% / 0.15);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Animation Easings */
@theme inline {
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
/* Gallery Photo Entrance Animation */
@keyframes photo-enter {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes pulse-subtle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.animate-photo-enter {
animation: photo-enter 0.5s var(--ease-out-quart) forwards;
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.animate-photo-enter {
animation: none;
opacity: 1;
transform: none;
}
.animate-pulse-subtle {
animation: none;
}
* {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
import "./globals.css";
export const metadata: Metadata = {
title: "Wallora Searcher",
description: "Wallora Searcher Admin",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="h-full antialiased">
<body className="flex min-h-full flex-col">
<SessionProvider>
<TooltipProvider>
{children}
<Toaster richColors closeButton />
</TooltipProvider>
</SessionProvider>
</body>
</html>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
@@ -0,0 +1,517 @@
"use client";
import { useMemo, useState } from "react";
import {
ImageIcon,
Loader2Icon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type {
CollectionPhoto,
PhotoCollection,
} from "@/types/admin/collection";
type CollectionForm = {
id: string | null;
title: string;
slug: string;
description: string;
coverPhotoId: string | null;
isPublished: boolean;
photoIds: string[];
};
type ApiResponse<T> = {
ok: boolean;
data: T;
error?: string;
};
const emptyForm: CollectionForm = {
id: null,
title: "",
slug: "",
description: "",
coverPhotoId: null,
isPublished: false,
photoIds: [],
};
function getPhotoUrl(photo: CollectionPhoto) {
const urls = photo.urls as Record<string, string> | undefined;
return photo.oss_url ?? urls?.small ?? urls?.thumb ?? urls?.regular ?? "";
}
function getPhotoLabel(photo: CollectionPhoto) {
return (
photo.alt_description ||
photo.description ||
photo.unsplash_id ||
"Picked photo"
);
}
function getFormFromCollection(collection: PhotoCollection): CollectionForm {
return {
id: collection.id,
title: collection.title,
slug: collection.slug,
description: collection.description ?? "",
coverPhotoId: collection.cover_photo_id,
isPublished: collection.is_published,
photoIds: collection.photos.map((photo) => photo.id),
};
}
export function CollectionsManager({
initialCollections,
initialPickedPhotos,
}: {
initialCollections: PhotoCollection[];
initialPickedPhotos: CollectionPhoto[];
}) {
const [collections, setCollections] =
useState<PhotoCollection[]>(initialCollections);
const [pickedPhotos, setPickedPhotos] =
useState<CollectionPhoto[]>(initialPickedPhotos);
const [form, setForm] = useState<CollectionForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedPhotoIds = useMemo(
() => new Set(form.photoIds),
[form.photoIds],
);
const selectedPhotos = useMemo(
() =>
form.photoIds
.map((photoId) =>
pickedPhotos.find((photo) => photo.id === photoId),
)
.filter((photo): photo is CollectionPhoto => Boolean(photo)),
[form.photoIds, pickedPhotos],
);
const updateForm = (patch: Partial<CollectionForm>) => {
setForm((current) => ({ ...current, ...patch }));
};
const startNew = () => {
setError(null);
setForm(emptyForm);
};
const togglePhoto = (photoId: string) => {
setForm((current) => {
const exists = current.photoIds.includes(photoId);
const photoIds = exists
? current.photoIds.filter((id) => id !== photoId)
: [...current.photoIds, photoId];
const coverPhotoId =
current.coverPhotoId && photoIds.includes(current.coverPhotoId)
? current.coverPhotoId
: (photoIds[0] ?? null);
return {
...current,
photoIds,
coverPhotoId,
};
});
};
const saveCollection = async () => {
setSaving(true);
setError(null);
try {
const res = await fetch(
form.id
? `/api/admin/photo-collections/${form.id}`
: "/api/admin/photo-collections",
{
method: form.id ? "PATCH" : "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
title: form.title,
slug: form.slug || null,
description: form.description || null,
coverPhotoId: form.coverPhotoId,
isPublished: form.isPublished,
photoIds: form.photoIds,
}),
},
);
const json = (await res.json()) as ApiResponse<PhotoCollection>;
if (!json.ok) {
throw new Error(json.error || "Failed to save collection.");
}
setCollections((current) => {
const exists = current.some(
(collection) => collection.id === json.data.id,
);
if (exists) {
return current.map((collection) =>
collection.id === json.data.id ? json.data : collection,
);
}
return [json.data, ...current];
});
setForm(getFormFromCollection(json.data));
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setSaving(false);
}
};
const deleteCollection = async (collection: PhotoCollection) => {
if (!confirm(`Delete "${collection.title}"?`)) {
return;
}
setDeletingId(collection.id);
setError(null);
try {
const res = await fetch(
`/api/admin/photo-collections/${collection.id}`,
{
method: "DELETE",
},
);
const json = (await res.json()) as ApiResponse<unknown>;
if (!json.ok) {
throw new Error(json.error || "Failed to delete collection.");
}
setCollections((current) =>
current.filter((item) => item.id !== collection.id),
);
if (form.id === collection.id) {
setForm(emptyForm);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setDeletingId(null);
}
};
const refreshPickedPhotos = async () => {
setRefreshing(true);
setError(null);
try {
const res = await fetch(
"/api/admin/gallery-photos?picked=true&per_page=100",
);
const json = (await res.json()) as ApiResponse<CollectionPhoto[]>;
if (!json.ok) {
throw new Error(json.error || "Failed to refresh photos.");
}
setPickedPhotos(json.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setRefreshing(false);
}
};
return (
<main className="grid flex-1 gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_420px] lg:p-6">
<section className="flex min-w-0 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">Collections</h2>
<Button onClick={startNew}>
<PlusIcon />
New
</Button>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Photos</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{collections.map((collection) => (
<TableRow key={collection.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="bg-muted relative size-12 shrink-0 overflow-hidden rounded-md">
{collection.cover_photo ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={getPhotoUrl(
collection.cover_photo,
)}
alt={collection.title}
className="size-full object-cover"
/>
) : (
<ImageIcon className="text-muted-foreground absolute top-1/2 left-1/2 size-4 -translate-x-1/2 -translate-y-1/2" />
)}
</div>
<div className="min-w-0">
<div className="truncate font-medium">
{collection.title}
</div>
<div className="text-muted-foreground truncate font-mono text-xs">
{collection.slug}
</div>
</div>
</div>
</TableCell>
<TableCell>
{collection.photos.length}
</TableCell>
<TableCell>
<Badge
variant={
collection.is_published
? "default"
: "secondary"
}
>
{collection.is_published
? "Published"
: "Draft"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
size="icon-sm"
variant="ghost"
onClick={() =>
setForm(
getFormFromCollection(
collection,
),
)
}
>
<PencilIcon />
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={
deletingId === collection.id
}
onClick={() =>
void deleteCollection(
collection,
)
}
>
{deletingId ===
collection.id ? (
<Loader2Icon className="animate-spin" />
) : (
<Trash2Icon />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
{collections.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-muted-foreground h-32 text-center text-sm"
>
No collections yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</section>
<aside className="flex min-w-0 flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>
{form.id ? "Edit collection" : "New collection"}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Title</span>
<Input
value={form.title}
onChange={(event) =>
updateForm({ title: event.target.value })
}
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Slug</span>
<Input
value={form.slug}
onChange={(event) =>
updateForm({ slug: event.target.value })
}
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Description</span>
<textarea
value={form.description}
onChange={(event) =>
updateForm({
description: event.target.value,
})
}
className="border-input focus-visible:border-ring focus-visible:ring-ring/50 min-h-20 resize-none rounded-lg border bg-transparent px-2.5 py-2 text-sm transition-colors outline-none focus-visible:ring-3"
/>
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Cover</span>
<select
value={form.coverPhotoId ?? ""}
onChange={(event) =>
updateForm({
coverPhotoId:
event.target.value || null,
})
}
className="border-input focus-visible:border-ring focus-visible:ring-ring/50 bg-background h-8 rounded-lg border px-2.5 text-sm transition-colors outline-none focus-visible:ring-3"
>
<option value="">Auto</option>
{selectedPhotos.map((photo, index) => (
<option key={photo.id} value={photo.id}>
{index + 1}. {getPhotoLabel(photo)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={form.isPublished}
onCheckedChange={(value) =>
updateForm({ isPublished: Boolean(value) })
}
/>
<span className="font-medium">Published</span>
</label>
{error && (
<p className="text-destructive text-sm">{error}</p>
)}
<Button
disabled={saving || !form.title.trim()}
onClick={() => void saveCollection()}
>
{saving ? (
<Loader2Icon className="animate-spin" />
) : (
<SaveIcon />
)}
Save
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>Picked photos</CardTitle>
<Button
size="icon-sm"
variant="ghost"
disabled={refreshing}
onClick={() => void refreshPickedPhotos()}
>
{refreshing ? (
<Loader2Icon className="animate-spin" />
) : (
<RefreshCwIcon />
)}
</Button>
</CardHeader>
<CardContent>
<div className="grid max-h-130 grid-cols-3 gap-2 overflow-y-auto pr-1">
{pickedPhotos.map((photo) => {
const selected = selectedPhotoIds.has(photo.id);
return (
<button
key={photo.id}
type="button"
className="group bg-muted hover:border-primary focus-visible:border-ring focus-visible:ring-ring/50 relative overflow-hidden rounded-md border text-left transition-colors outline-none focus-visible:ring-3"
onClick={() => togglePhoto(photo.id)}
>
<span className="block aspect-3/4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getPhotoUrl(photo)}
alt={getPhotoLabel(photo)}
className="size-full object-cover transition-transform group-hover:scale-[1.03]"
/>
</span>
<span className="absolute top-1.5 left-1.5">
<Checkbox
checked={selected}
aria-label="Select photo"
tabIndex={-1}
/>
</span>
{form.coverPhotoId === photo.id && (
<Badge className="absolute right-1.5 bottom-1.5">
Cover
</Badge>
)}
</button>
);
})}
{pickedPhotos.length === 0 && (
<div className="text-muted-foreground col-span-3 flex h-32 items-center justify-center text-sm">
No picked photos.
</div>
)}
</div>
</CardContent>
</Card>
</aside>
</main>
);
}
+610
View File
@@ -0,0 +1,610 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CheckIcon,
ImageOffIcon,
Loader2Icon,
SparklesIcon,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { GalleryPhoto } from "@/types/admin/gallery";
interface GalleryGridProps {
initialPhotos?: GalleryPhoto[];
}
type PickedFilter = "all" | "picked" | "unpicked";
type ColorFilter =
| "all"
| "black"
| "blue"
| "green"
| "light"
| "orange"
| "purple"
| "red"
| "yellow";
const PER_PAGE = 24;
const COLUMNS_COUNT = 4;
const colorFilters: Array<{ label: string; value: ColorFilter }> = [
{ label: "All colors", value: "all" },
{ label: "Light", value: "light" },
{ label: "Black", value: "black" },
{ label: "Red", value: "red" },
{ label: "Orange", value: "orange" },
{ label: "Yellow", value: "yellow" },
{ label: "Green", value: "green" },
{ label: "Blue", value: "blue" },
{ label: "Purple", value: "purple" },
];
function getImageUrl(photo: GalleryPhoto): string {
const urls = photo.urls as Record<string, string> | undefined;
return urls?.small ?? urls?.regular ?? urls?.thumb ?? urls?.raw ?? "";
}
function getFullImageUrl(photo: GalleryPhoto): string {
const urls = photo.urls as Record<string, string> | undefined;
return photo.oss_url ?? urls?.full ?? urls?.regular ?? urls?.raw ?? "";
}
function getUserName(photo: GalleryPhoto): string {
const user = photo.user_info as Record<string, unknown> | undefined;
return (user?.name as string) || (user?.username as string) || "Unknown";
}
function getPhotoAspectRatio(photo: GalleryPhoto): number {
if (photo.width && photo.height && photo.height > 0) {
return photo.width / photo.height;
}
return 1.5;
}
function distributeToColumns(
photos: GalleryPhoto[],
columnCount: number,
): GalleryPhoto[][] {
const columns: GalleryPhoto[][] = Array.from(
{ length: columnCount },
() => [],
);
const columnHeights = Array(columnCount).fill(0);
for (const photo of photos) {
const shortestColIndex = columnHeights.indexOf(
Math.min(...columnHeights),
);
columns[shortestColIndex].push(photo);
columnHeights[shortestColIndex] += 1 / getPhotoAspectRatio(photo);
}
return columns;
}
export function GalleryGrid({ initialPhotos = [] }: GalleryGridProps) {
const [columns, setColumns] = useState<GalleryPhoto[][]>([]);
const [allPhotos, setAllPhotos] = useState<GalleryPhoto[]>(initialPhotos);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhoto | null>(
null,
);
const [lightboxVisible, setLightboxVisible] = useState(false);
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
const [pickedFilter, setPickedFilter] = useState<PickedFilter>("all");
const [colorFilter, setColorFilter] = useState<ColorFilter>("all");
const [pickingIds, setPickingIds] = useState<Set<string>>(new Set());
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const loadingRef = useRef(loading);
const hasMoreRef = useRef(hasMore);
useEffect(() => {
loadingRef.current = loading;
}, [loading]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
setColumns(distributeToColumns(allPhotos, COLUMNS_COUNT));
}, [allPhotos]);
const fetchPhotos = useCallback(
async (targetPage: number, replace = false) => {
if (loadingRef.current || (!replace && !hasMoreRef.current)) {
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
page: String(targetPage),
per_page: String(PER_PAGE),
});
if (pickedFilter === "picked") {
params.set("picked", "true");
} else if (pickedFilter === "unpicked") {
params.set("picked", "false");
}
if (colorFilter !== "all") {
params.set("color_family", colorFilter);
}
const res = await fetch(
`/api/admin/gallery-photos?${params.toString()}`,
);
const json = await res.json();
if (!json.ok) {
throw new Error(json.error || "Failed to fetch photos.");
}
const newPhotos: GalleryPhoto[] = json.data;
setAllPhotos((prev) =>
replace || targetPage === 1
? newPhotos
: [...prev, ...newPhotos],
);
setHasMore(json.pagination.hasMore);
setPage(targetPage);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setLoading(false);
}
},
[colorFilter, pickedFilter],
);
useEffect(() => {
hasMoreRef.current = true;
setHasMore(true);
setPage(1);
setAllPhotos([]);
setLoadedImages(new Set());
void fetchPhotos(1, true);
}, [fetchPhotos]);
useEffect(() => {
observerRef.current?.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (
entries[0]?.isIntersecting &&
hasMoreRef.current &&
!loadingRef.current
) {
void fetchPhotos(page + 1);
}
},
{ rootMargin: "200px" },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => {
observerRef.current?.disconnect();
};
}, [fetchPhotos, page]);
const replacePhoto = useCallback(
(updated: GalleryPhoto) => {
setAllPhotos((prev) => {
const shouldRemoveFromCurrentFilter =
(pickedFilter === "picked" && !updated.is_picked) ||
(pickedFilter === "unpicked" && updated.is_picked);
if (shouldRemoveFromCurrentFilter) {
return prev.filter(
(photo) => photo.unsplash_id !== updated.unsplash_id,
);
}
return prev.map((photo) =>
photo.unsplash_id === updated.unsplash_id ? updated : photo,
);
});
setSelectedPhoto((photo) =>
photo?.unsplash_id === updated.unsplash_id ? updated : photo,
);
},
[pickedFilter],
);
const togglePicked = async (photo: GalleryPhoto) => {
const nextPicked = !photo.is_picked;
const toastId = toast.loading(
nextPicked ? "Picking photo..." : "Unpicking photo...",
);
setPickingIds((prev) => new Set(prev).add(photo.unsplash_id));
setError(null);
try {
const res = await fetch("/api/admin/gallery-photos", {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
unsplashId: photo.unsplash_id,
isPicked: nextPicked,
}),
});
const json = await res.json().catch(() => null);
if (!res.ok || !json?.ok) {
throw new Error(
json?.error ||
`Failed to update pick status (${res.status}).`,
);
}
replacePhoto(json.data);
toast.success(nextPicked ? "Photo picked" : "Photo unpicked", {
id: toastId,
description: json.data.oss_sync_error || undefined,
});
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown pick error.";
setError(message);
toast.error("Pick request failed", {
id: toastId,
description: message,
});
} finally {
setPickingIds((prev) => {
const next = new Set(prev);
next.delete(photo.unsplash_id);
return next;
});
}
};
const handlePhotoClick = (photo: GalleryPhoto) => {
setSelectedPhoto(photo);
requestAnimationFrame(() => {
setLightboxVisible(true);
});
};
const handleCloseModal = () => {
setLightboxVisible(false);
setTimeout(() => {
setSelectedPhoto(null);
}, 250);
};
const handleImageLoad = (unsplashId: string) => {
setLoadedImages((prev) => new Set(prev).add(unsplashId));
};
const totalPickedOnPage = useMemo(
() => allPhotos.filter((photo) => photo.is_picked).length,
[allPhotos],
);
const renderPhotoCard = (
photo: GalleryPhoto,
colIndex: number,
itemIndex: number,
) => {
const isLoaded = loadedImages.has(photo.unsplash_id);
const isPicking = pickingIds.has(photo.unsplash_id);
const staggerDelay = ((colIndex * 3 + itemIndex) % 12) * 60;
return (
<div
key={`${colIndex}-${photo.unsplash_id}`}
className="group animate-photo-enter mb-4 cursor-pointer opacity-0"
style={{
animationDelay: `${staggerDelay}ms`,
animationFillMode: "forwards",
}}
onClick={() => handlePhotoClick(photo)}
>
<div
className={`bg-muted ease-out-quart relative overflow-hidden rounded-lg shadow-sm transition-all duration-250 hover:shadow-lg ${
isPicking ? "opacity-70" : "opacity-100"
}`}
>
<div
className="absolute inset-0 transition-opacity duration-500"
style={{
backgroundColor: photo.color || "#f0f0f0",
opacity: isLoaded ? 0 : 1,
}}
/>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getImageUrl(photo)}
alt={
photo.alt_description ||
photo.description ||
"Unsplash photo"
}
loading="lazy"
onLoad={() => handleImageLoad(photo.unsplash_id)}
className={`ease-out-quart h-auto w-full object-cover transition-all duration-250 group-hover:scale-[1.03] ${
isLoaded ? "opacity-100" : "opacity-0"
}`}
style={{
transitionProperty: "opacity, transform",
}}
/>
<div className="absolute top-2 left-2 z-20 flex items-center gap-1">
{photo.is_picked && (
<Badge className="bg-primary text-primary-foreground">
<SparklesIcon />
Picked
</Badge>
)}
{photo.oss_url && (
<Badge variant="secondary">
<CheckIcon />
OSS
</Badge>
)}
</div>
<div className="absolute top-2 right-2 z-20 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<Button
size="sm"
variant={photo.is_picked ? "secondary" : "default"}
disabled={isPicking}
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void togglePicked(photo);
}}
>
{isPicking ? (
<Loader2Icon className="animate-spin" />
) : photo.is_picked ? (
<XIcon />
) : (
<SparklesIcon />
)}
{photo.is_picked ? "Unpick" : "Pick"}
</Button>
</div>
<div className="ease-out-quart pointer-events-none absolute inset-0 z-10 flex items-end bg-linear-to-t from-black/60 via-black/0 to-transparent p-3 opacity-0 transition-opacity duration-250 group-hover:opacity-100">
<div className="ease-out-quart translate-y-2 text-white transition-transform duration-250 group-hover:translate-y-0">
<p className="truncate text-xs font-medium">
{getUserName(photo)}
</p>
<div className="mt-1 flex flex-wrap gap-1 text-xs text-white/80">
{photo.likes ? (
<span>{photo.likes} likes</span>
) : null}
{photo.color_family ? (
<span>{photo.color_family}</span>
) : null}
</div>
</div>
</div>
</div>
</div>
);
};
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-col gap-3 border-b px-4 py-3 lg:flex-row lg:items-center lg:justify-between lg:px-6">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={pickedFilter === "all" ? "default" : "outline"}
onClick={() => setPickedFilter("all")}
>
All
</Button>
<Button
variant={
pickedFilter === "picked" ? "default" : "outline"
}
onClick={() => setPickedFilter("picked")}
>
<SparklesIcon />
Picked
</Button>
<Button
variant={
pickedFilter === "unpicked" ? "default" : "outline"
}
onClick={() => setPickedFilter("unpicked")}
>
<ImageOffIcon />
Unpicked
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={colorFilter}
onChange={(event) =>
setColorFilter(event.target.value as ColorFilter)
}
className="border-input bg-background focus-visible:border-ring focus-visible:ring-ring/50 h-8 rounded-lg border px-2.5 text-sm transition-colors outline-none focus-visible:ring-3"
>
{colorFilters.map((filter) => (
<option key={filter.value} value={filter.value}>
{filter.label}
</option>
))}
</select>
<Badge variant="outline">
{allPhotos.length} loaded / {totalPickedOnPage} picked
</Badge>
</div>
</div>
<div className="flex gap-4 px-4 py-4">
{columns.map((columnPhotos, colIndex) => (
<div key={colIndex} className="min-w-0 flex-1">
{columnPhotos.map((photo, itemIndex) =>
renderPhotoCard(photo, colIndex, itemIndex),
)}
</div>
))}
</div>
{loading && (
<div className="flex justify-center py-8">
<div className="animate-pulse-subtle text-muted-foreground flex items-center gap-2">
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
<span className="text-sm">Loading more...</span>
</div>
</div>
)}
{error && (
<div className="flex justify-center py-8">
<div className="text-center">
<p className="mb-2 text-sm text-red-500">{error}</p>
<Button
variant="outline"
onClick={() => void fetchPhotos(page, page === 1)}
>
Retry
</Button>
</div>
</div>
)}
{!hasMore && allPhotos.length > 0 && (
<div className="flex justify-center py-8">
<p className="text-muted-foreground text-sm">
No more photos
</p>
</div>
)}
{!loading && allPhotos.length === 0 && !error && (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-lg">
No photos found
</p>
<p className="text-muted-foreground mt-1 text-sm">
Run the sync script to populate the gallery.
</p>
</div>
</div>
)}
<div ref={sentinelRef} className="h-4" />
{selectedPhoto && (
<div
className={`ease-out-quart fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-250 ${
lightboxVisible
? "bg-black/90 opacity-100"
: "bg-black/0 opacity-0"
}`}
onClick={handleCloseModal}
>
<div
className={`ease-out-quart relative flex max-h-[90vh] w-full max-w-5xl flex-col items-center transition-all duration-250 ${
lightboxVisible
? "scale-100 opacity-100"
: "scale-95 opacity-0"
}`}
onClick={(event) => event.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getFullImageUrl(selectedPhoto)}
alt={
selectedPhoto.alt_description ||
selectedPhoto.description ||
"Unsplash photo"
}
className="max-h-[85vh] max-w-full rounded-lg object-contain"
/>
<div className="mt-4 text-center text-white">
<div className="flex items-center justify-center gap-2">
<p className="text-sm font-medium">
{getUserName(selectedPhoto)}
</p>
{selectedPhoto.is_picked && (
<Badge>
<SparklesIcon />
Picked
</Badge>
)}
</div>
{selectedPhoto.description && (
<p className="mt-1 max-w-xl text-sm text-white/70">
{selectedPhoto.description}
</p>
)}
{selectedPhoto.oss_sync_error && (
<p className="mt-2 max-w-xl text-xs text-red-200">
{selectedPhoto.oss_sync_error}
</p>
)}
</div>
<div className="absolute top-2 right-2 flex gap-2">
<Button
variant={
selectedPhoto.is_picked
? "secondary"
: "default"
}
disabled={pickingIds.has(
selectedPhoto.unsplash_id,
)}
onPointerDown={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void togglePicked(selectedPhoto);
}}
>
{pickingIds.has(selectedPhoto.unsplash_id) ? (
<Loader2Icon className="animate-spin" />
) : selectedPhoto.is_picked ? (
<XIcon />
) : (
<SparklesIcon />
)}
{selectedPhoto.is_picked ? "Unpick" : "Pick"}
</Button>
<Button
size="icon"
variant="secondary"
onClick={handleCloseModal}
>
<XIcon />
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { Loader2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AdminAppUser } from "@/server/admin/app-users";
type ApiResponse<T> = {
ok: boolean;
data: T;
error?: string;
};
export function UsersManager({
initialUsers,
}: {
initialUsers: AdminAppUser[];
}) {
const [users, setUsers] = useState(initialUsers);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const updateStatus = async (user: AdminAppUser) => {
const status = user.status === "active" ? "disabled" : "active";
setUpdatingId(user.id);
setError(null);
try {
const res = await fetch(`/api/admin/app-users/${user.id}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ status }),
});
const json = (await res.json()) as ApiResponse<AdminAppUser>;
if (!json.ok) {
throw new Error(json.error || "Failed to update user.");
}
setUsers((current) =>
current.map((item) =>
item.id === json.data.id ? json.data : item,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error.");
} finally {
setUpdatingId(null);
}
};
return (
<main className="flex flex-1 flex-col gap-4 p-4 lg:p-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold">App users</h2>
<Badge variant="outline">{users.length} total</Badge>
</div>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Status</TableHead>
<TableHead>Favorites</TableHead>
<TableHead>Last login</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">
{user.email ?? user.phone ?? "No email"}
</div>
{user.display_name ? (
<div className="text-muted-foreground text-sm">
{user.display_name}
</div>
) : null}
</TableCell>
<TableCell>{user.auth_provider}</TableCell>
<TableCell>
<Badge
variant={
user.status === "active"
? "default"
: "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>{user.favorite_count}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{user.last_login_at
? new Date(
user.last_login_at,
).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{new Date(
user.created_at,
).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
size="sm"
variant={
user.status === "active"
? "destructive"
: "secondary"
}
disabled={updatingId === user.id}
onClick={() => void updateStatus(user)}
>
{updatingId === user.id ? (
<Loader2Icon className="animate-spin" />
) : null}
{user.status === "active"
? "Disable"
: "Enable"}
</Button>
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-muted-foreground h-32 text-center text-sm"
>
No app users yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</main>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import * as React from "react";
import { NavDocuments } from "@/components/nav-documents";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
LayoutDashboardIcon,
ListIcon,
ChartBarIcon,
FolderIcon,
UsersIcon,
CameraIcon,
FileTextIcon,
Settings2Icon,
CircleHelpIcon,
SearchIcon,
DatabaseIcon,
FileChartColumnIcon,
FileIcon,
CommandIcon,
ImageIcon,
ImagesIcon,
} from "lucide-react";
const data = {
user: {
name: "Wallora Admin",
email: "admin@wallora.top",
avatar: "/avatars/shadcn.jpg",
},
navMain: [
{
title: "Dashboard",
url: "/admin/dashboard",
icon: <LayoutDashboardIcon />,
},
{
title: "Gallery",
url: "/admin/gallery",
icon: <ImageIcon />,
},
{
title: "Collections",
url: "/admin/collections",
icon: <ImagesIcon />,
},
{
title: "Lifecycle",
url: "#",
icon: <ListIcon />,
},
{
title: "Analytics",
url: "#",
icon: <ChartBarIcon />,
},
{
title: "Projects",
url: "#",
icon: <FolderIcon />,
},
{
title: "Users",
url: "/admin/users",
icon: <UsersIcon />,
},
],
navClouds: [
{
title: "Capture",
icon: <CameraIcon />,
isActive: true,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Proposal",
icon: <FileTextIcon />,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
{
title: "Prompts",
icon: <FileTextIcon />,
url: "#",
items: [
{
title: "Active Proposals",
url: "#",
},
{
title: "Archived",
url: "#",
},
],
},
],
navSecondary: [
{
title: "Settings",
url: "#",
icon: <Settings2Icon />,
},
{
title: "Get Help",
url: "#",
icon: <CircleHelpIcon />,
},
{
title: "Search",
url: "#",
icon: <SearchIcon />,
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: <DatabaseIcon />,
},
{
name: "Reports",
url: "#",
icon: <FileChartColumnIcon />,
},
{
name: "Word Assistant",
url: "#",
icon: <FileIcon />,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[slot=sidebar-menu-button]:p-1.5!"
render={<a href="#" />}
>
<CommandIcon className="size-5!" />
<span className="text-base font-semibold">
Wallora
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
</SidebarFooter>
</Sidebar>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import * as React from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { useIsMobile } from "@/hooks/use-mobile";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export const description = "An interactive area chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
];
const chartConfig = {
visitors: {
label: "Visitors",
},
desktop: {
label: "Desktop",
color: "var(--primary)",
},
mobile: {
label: "Mobile",
color: "var(--primary)",
},
} satisfies ChartConfig;
export function ChartAreaInteractive() {
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = React.useState(() =>
isMobile ? "7d" : "90d",
);
const filteredData = chartData.filter((item) => {
const date = new Date(item.date);
const referenceDate = new Date("2024-06-30");
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return date >= startDate;
});
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
multiple={false}
value={timeRange ? [timeRange] : []}
onValueChange={(value) => {
setTimeRange(value[0] ?? "90d");
}}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">
Last 3 months
</ToggleGroupItem>
<ToggleGroupItem value="30d">
Last 30 days
</ToggleGroupItem>
<ToggleGroupItem value="7d">
Last 7 days
</ToggleGroupItem>
</ToggleGroup>
<Select
value={timeRange}
onValueChange={(value) => {
if (value !== null) {
setTimeRange(value);
}
}}
>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-62.5 w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient
id="fillDesktop"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="fillMobile"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(
value,
).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
);
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
export function LoginForm({
className,
action,
error,
...props
}: React.ComponentProps<"div"> & {
action?: React.ComponentProps<"form">["action"];
error?: string;
}) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl"></CardTitle>
<CardDescription>
使 Wallora
</CardDescription>
</CardHeader>
<CardContent>
<form action={action}>
<FieldGroup>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
placeholder="admin@example.com"
autoComplete="username"
aria-invalid={Boolean(error)}
required
/>
</Field>
<Field data-invalid={Boolean(error)}>
<FieldLabel htmlFor="password">
Password
</FieldLabel>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
aria-invalid={Boolean(error)}
required
/>
{error ? (
<FieldDescription className="text-destructive">
{error}
</FieldDescription>
) : null}
</Field>
<Field>
<Button type="submit"></Button>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
访 NextAuth session
</FieldDescription>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
MoreHorizontalIcon,
FolderIcon,
ShareIcon,
Trash2Icon,
} from "lucide-react";
export function NavDocuments({
items,
}: {
items: {
name: string;
url: string;
icon: React.ReactNode;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton render={<a href={item.url} />}>
{item.icon}
<span>{item.name}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuAction
showOnHover
className="aria-expanded:bg-muted"
/>
}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<FolderIcon />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<ShareIcon />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<MoreHorizontalIcon className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { CirclePlusIcon, MailIcon } from "lucide-react";
export function NavMain({
items,
}: {
items: {
title: string;
url: string;
icon?: React.ReactNode;
}[];
}) {
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<CirclePlusIcon />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<MailIcon />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
render={<a href={item.url} />}
>
{item.icon}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import * as React from "react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: React.ReactNode;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton render={<a href={item.url} />}>
{item.icon}
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
EllipsisVerticalIcon,
CircleUserRoundIcon,
CreditCardIcon,
BellIcon,
LogOutIcon,
} from "lucide-react";
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
const { isMobile } = useSidebar();
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="aria-expanded:bg-muted"
/>
}
>
<Avatar className="size-8 rounded-lg grayscale">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
CN
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{user.name}
</span>
<span className="text-foreground/70 truncate text-xs">
{user.email}
</span>
</div>
<EllipsisVerticalIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8">
<AvatarImage
src={user.avatar}
alt={user.name}
/>
<AvatarFallback className="rounded-lg">
CN
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{user.name}
</span>
<span className="text-muted-foreground truncate text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<CircleUserRoundIcon />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { TrendingUpIcon, TrendingDownIcon } from "lucide-react";
export function SectionCards() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingDownIcon />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period{" "}
<TrendingDownIcon className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
Engagement exceed targets
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<TrendingUpIcon />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase{" "}
<TrendingUpIcon className="size-4" />
</div>
<div className="text-muted-foreground">
Meets growth projections
</div>
</CardFooter>
</Card>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 h-4 data-vertical:self-auto"
/>
<h1 className="text-base font-medium">Dashboard</h1>
</div>
</header>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
};
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
export { Badge, badgeVariants };
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react";
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
);
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-word",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"transition-colors hover:text-foreground",
className,
),
},
props,
),
render,
state: {
slot: "breadcrumb-link",
},
});
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className,
)}
{...props}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card bg-card text-card-foreground ring-foreground/10 flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className,
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"bg-muted/50 flex items-center rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3",
className,
)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};
+406
View File
@@ -0,0 +1,406 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type { TooltipValueType } from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
type TooltipNameType = number | string;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>;
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
initialDimension?: {
width: number;
height: number;
};
}) {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(
config,
item,
key,
);
const indicatorColor =
color ?? item.payload?.fill ?? item.color;
return (
<div
key={index}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter &&
item?.value !== undefined &&
item.name ? (
formatter(
item.value,
item.name,
item,
index,
item.payload,
)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"border-border shrink-0 rounded-[2px] bg-(--color-bg)",
{
"h-2.5 w-2.5":
indicator ===
"dot",
"w-1":
indicator ===
"line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator ===
"dashed",
"my-0.5":
nestLabel &&
indicator ===
"dashed",
},
)}
style={
{
"--color-bg":
indicatorColor,
"--color-border":
indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel
? "items-end"
: "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel
? tooltipLabel
: null}
<span className="text-muted-foreground">
{itemConfig?.label ??
item.name}
</span>
</div>
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{typeof item.value ===
"number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean;
nameKey?: string;
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`;
const itemConfig = getPayloadConfigFromPayload(
config,
item,
key,
);
return (
<div
key={index}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary relative flex size-4 shrink-0 items-center justify-center rounded-lg border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+131
View File
@@ -0,0 +1,131 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-popover text-popover-foreground fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-1 w-25 shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground text-base font-medium", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+276
View File
@@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { ChevronRightIcon, CheckIcon } from "lucide-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return (
<MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
);
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 outline-none data-closed:overflow-hidden",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7",
className,
)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return (
<MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
);
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-24 rounded-lg p-1 shadow-lg ring-1 duration-100",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+238
View File
@@ -0,0 +1,238 @@
"use client";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label has-data-checked:border-primary/30 has-data-checked:bg-primary/5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-left text-sm leading-normal font-normal group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"border-input file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Input };
+20
View File
@@ -0,0 +1,20 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+208
View File
@@ -0,0 +1,208 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" />
}
/>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg shadow-md ring-1 duration-100 data-[align-trigger=true]:animate-none",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs",
className,
)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"bg-border pointer-events-none -mx-1 my-1 h-px",
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"bg-popover top-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bg-popover bottom-0 z-10 flex w-full cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-10 data-[side=bottom]:data-starting-style:translate-y-10 data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:-translate-x-10 data-[side=left]:data-starting-style:-translate-x-10 data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-10 data-[side=right]:data-starting-style:translate-x-10 data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:-translate-y-10 data-[side=top]:data-starting-style:-translate-y-10 data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground text-base font-medium", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+747
View File
@@ -0,0 +1,747 @@
"use client";
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PanelLeftIcon } from "lucide-react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>
Displays the mobile sidebar.
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">
{children}
</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:-left-(--sidebar-width) data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:-right-(--sidebar-width) md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:inset-s-1/2 after:w-0.5 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn(
"relative flex w-full min-w-0 flex-col p-2",
className,
)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
});
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default:
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar();
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
sidebarMenuButtonVariants({ variant, size }),
className,
),
},
props,
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
});
if (!tooltip) {
return comp;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
});
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn(
"flex h-8 items-center gap-2 rounded-md px-2",
className,
)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-muted animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
TriangleAlertIcon,
OctagonXIcon,
Loader2Icon,
} from "lucide-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+116
View File
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap has-[[role=checkbox]]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"text-foreground/60 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
});
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-vertical:flex-col data-vertical:items-stretch data-[size=sm]:rounded-[min(var(--radius-md),10px)]",
className,
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</TogglePrimitive>
);
}
export { ToggleGroup, ToggleGroupItem };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
);
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Wallora Searcher API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
background: #ffffff;
}
.swagger-ui .topbar {
display: none;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.addEventListener("load", () => {
window.ui = SwaggerUIBundle({
url: "./swagger.yaml",
dom_id: "#swagger-ui",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout"
});
});
</script>
</body>
</html>
+590
View File
@@ -0,0 +1,590 @@
openapi: 3.1.0
info:
title: Wallora Searcher API
version: 0.1.0
description: Public API contract for Wallora Searcher.
servers:
- url: /api/v1
description: Local Next.js API route base
paths:
/auth/login:
post:
summary: Login app user with email and password
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
properties:
email:
type: string
format: email
password:
type: string
responses:
"200":
description: App user token and profile.
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"401":
description: Invalid email or password, or the app user is disabled.
/auth/register:
post:
summary: Register app user with email and password
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
displayName:
type:
- string
- "null"
responses:
"200":
description: App user token and profile.
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
description: Invalid request or duplicate email.
/gallery/collections:
get:
summary: List published photo collections
description: Returns published photo collections with cover metadata.
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 50
default: 20
responses:
"200":
description: Paginated published collections.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
type: array
items:
$ref: "#/components/schemas/PhotoCollection"
pagination:
$ref: "#/components/schemas/Pagination"
/gallery/collections/{slug}:
get:
summary: Get a published photo collection
description: Returns collection metadata and paginated picked photos in collection order.
parameters:
- name: slug
in: path
required: true
schema:
type: string
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 24
responses:
"200":
description: Published collection with paginated photos.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
type: object
properties:
collection:
$ref: "#/components/schemas/PhotoCollection"
photos:
type: array
items:
$ref: "#/components/schemas/PickedGalleryPhoto"
pagination:
$ref: "#/components/schemas/Pagination"
"404":
description: Collection was not found or is not published.
/gallery/photos:
get:
summary: List picked gallery photos
description: Returns public picked photos for waterfall-style infinite scroll. If a Bearer JWT is provided, each photo includes current user's favorite state.
security:
- {}
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 24
- name: colorFamily
in: query
schema:
type: string
enum:
- all
- black
- blue
- green
- light
- orange
- purple
- red
- yellow
responses:
"200":
description: Paginated picked photos.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
type: array
items:
$ref: "#/components/schemas/PickedGalleryPhoto"
pagination:
$ref: "#/components/schemas/Pagination"
/gallery/photos/{id}:
get:
summary: Get picked photo detail
description: Returns one picked photo by id. If a Bearer JWT is provided, includes current user's favorite state.
security:
- {}
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Picked photo detail.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
$ref: "#/components/schemas/PickedGalleryPhoto"
"404":
description: Photo was not found or is not picked.
post:
summary: Favorite picked photo
description: Adds the photo to the current user's favorites.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Favorited photo.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
$ref: "#/components/schemas/FavoritePhoto"
"401":
description: Missing or invalid app user JWT.
delete:
summary: Unfavorite picked photo
description: Removes the photo from the current user's favorites.
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Favorite removed.
"401":
description: Missing or invalid app user JWT.
/users/me:
get:
summary: Get current app user
security:
- bearerAuth: []
responses:
"200":
description: Current app user profile.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
type: object
properties:
user:
$ref: "#/components/schemas/AppUser"
"401":
description: Missing or invalid app user JWT.
/users/me/favorites:
get:
summary: List current user's favorite photos
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: pageSize
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 24
responses:
"200":
description: Paginated favorite picked photos.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
type: array
items:
$ref: "#/components/schemas/FavoritePhoto"
pagination:
$ref: "#/components/schemas/Pagination"
"401":
description: Missing or invalid app user JWT.
post:
summary: Add a picked photo to current user's favorites
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- photoId
properties:
photoId:
type: string
format: uuid
responses:
"200":
description: Favorited photo.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
$ref: "#/components/schemas/FavoritePhoto"
"401":
description: Missing or invalid app user JWT.
/users/me/favorites/{photoId}:
delete:
summary: Remove a photo from current user's favorites
security:
- bearerAuth: []
parameters:
- name: photoId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Favorite removed.
"401":
description: Missing or invalid app user JWT.
/users/me/settings:
get:
summary: Get current user's preferences
security:
- bearerAuth: []
responses:
"200":
description: User preferences and download preferences.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
$ref: "#/components/schemas/UserSettings"
"401":
description: Missing or invalid app user JWT.
patch:
summary: Update current user's preferences
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
preferences:
type: object
additionalProperties: true
downloadPreferences:
type: object
additionalProperties: true
responses:
"200":
description: Updated user settings.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
data:
$ref: "#/components/schemas/UserSettings"
"401":
description: Missing or invalid app user JWT.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
AppUser:
type: object
properties:
id:
type: string
format: uuid
email:
type:
- string
- "null"
format: email
displayName:
type:
- string
- "null"
avatarUrl:
type:
- string
- "null"
authProvider:
type: string
AuthResponse:
type: object
properties:
ok:
type: boolean
data:
type: object
properties:
token:
type: string
user:
$ref: "#/components/schemas/AppUser"
FavoritePhoto:
allOf:
- $ref: "#/components/schemas/PickedGalleryPhoto"
- type: object
properties:
favoritedAt:
type:
- string
- "null"
format: date-time
PhotoCollection:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
slug:
type: string
description:
type:
- string
- "null"
coverPhoto:
oneOf:
- $ref: "#/components/schemas/PickedGalleryPhoto"
- type: "null"
coverUrl:
type:
- string
- "null"
photoCount:
type: integer
publishedAt:
type:
- string
- "null"
format: date-time
PickedGalleryPhoto:
type: object
properties:
id:
type: string
format: uuid
unsplashId:
type: string
slug:
type:
- string
- "null"
description:
type:
- string
- "null"
altDescription:
type:
- string
- "null"
width:
type:
- integer
- "null"
height:
type:
- integer
- "null"
color:
type:
- string
- "null"
colorFamily:
type:
- string
- "null"
url:
type:
- string
- "null"
sourceUrl:
type:
- string
- "null"
user:
type: object
additionalProperties: true
likes:
type:
- integer
- "null"
pickedAt:
type:
- string
- "null"
format: date-time
isFavorited:
type: boolean
UserSettings:
type: object
properties:
preferences:
type: object
additionalProperties: true
downloadPreferences:
type: object
additionalProperties: true
updatedAt:
type: string
format: date-time
Pagination:
type: object
properties:
page:
type: integer
pageSize:
type: integer
total:
type: integer
hasMore:
type: boolean
nextPage:
type:
- integer
- "null"
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import tailwindCanonicalClasses from "eslint-plugin-tailwind-canonical-classes";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
...tailwindCanonicalClasses.configs["flat/recommended"],
{
rules: {
"tailwind-canonical-classes/tailwind-canonical-classes": [
"warn",
{
cssPath: "./app/globals.css",
},
],
// unknownAtRules
"tailwindcss/unknown-at-rules": ["off"],
// TanStack Table and other libs known to be incompatible with React Compiler memoization
"react-hooks/incompatible-library": ["off"],
// This rule is overly strict and causes false positives with async data fetching in useEffect
"react-hooks/set-state-in-effect": ["off"],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
// Python virtual environment and external packages
"**/.venv/**",
"**/node_modules/**",
]),
]);
export default eslintConfig;
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(() =>
typeof window === "undefined"
? undefined
: window.innerWidth < MOBILE_BREAKPOINT,
);
React.useEffect(() => {
const mql = window.matchMedia(
`(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
+1
View File
@@ -0,0 +1 @@
+56
View File
@@ -0,0 +1,56 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const adminEmail = process.env.ADMIN_EMAIL;
const adminPassword = process.env.ADMIN_PASSWORD;
if (
credentials?.email === adminEmail &&
credentials?.password === adminPassword
) {
return {
id: "admin",
name: "Admin",
email: adminEmail,
};
}
return null;
},
}),
],
pages: {
signIn: "/admin/login",
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token?.id) {
session.user.id = token.id as string;
}
return session;
},
},
});
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+21
View File
@@ -0,0 +1,21 @@
import { createClient } from "@supabase/supabase-js";
export function createSupabaseServiceRoleClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl) {
throw new Error("NEXT_PUBLIC_SUPABASE_URL is not configured.");
}
if (!serviceRoleKey) {
throw new Error("SUPABASE_SERVICE_ROLE_KEY is not configured.");
}
return createClient(supabaseUrl, serviceRoleKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+54
View File
@@ -0,0 +1,54 @@
{
"name": "searcher",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint:fix": "eslint --fix",
"sync:gallery": "node scripts/sync-gallery-photos.mjs",
"swagger:docs": "node scripts/generate-swagger-docs.mjs",
"format": "prettier . --write",
"format:check": "prettier . --check"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@supabase/supabase-js": "^2.106.2",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.17.0",
"next": "16.2.6",
"next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "3.8.0",
"shadcn": "^4.8.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/node": "^4.3.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"eslint-plugin-tailwind-canonical-classes": "^1.3.3",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4",
"typescript": "^6.0.3"
}
}
+7525
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
msw: true
sharp: false
unrs-resolver: false
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+25
View File
@@ -0,0 +1,25 @@
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const { nextUrl } = req;
const isLoggedIn = !!req.auth;
const isAdminRoute = nextUrl.pathname.startsWith("/admin");
const isLoginPage = nextUrl.pathname === "/admin/login";
// Only the admin login page is public. Everything else under /admin
// requires a NextAuth session.
if (isAdminRoute && !isLoggedIn && !isLoginPage) {
return NextResponse.redirect(new URL("/admin/login", nextUrl));
}
if (isLoggedIn && isLoginPage) {
return NextResponse.redirect(new URL("/admin/dashboard", nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/admin/:path*"],
};
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More