first commit
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"tabWidth": 4
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+41
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { GET, POST } from "@/lib/auth";
|
||||||
|
|
||||||
|
export { GET, POST };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+248
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+56
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7525
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
msw: true
|
||||||
|
sharp: false
|
||||||
|
unrs-resolver: false
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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*"],
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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 |
@@ -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
Reference in New Issue
Block a user