commit 2b860d201eb9a25169d19653764704885daabad3 Author: luoyangwei <1106703846@qq.com> Date: Sat Jun 6 00:49:07 2026 +0800 first commit diff --git a/.codex/skills/wallora-searcher/SKILL.md b/.codex/skills/wallora-searcher/SKILL.md new file mode 100644 index 0000000..34aeaaf --- /dev/null +++ b/.codex/skills/wallora-searcher/SKILL.md @@ -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. diff --git a/.codex/skills/wallora-searcher/agents/openai.yaml b/.codex/skills/wallora-searcher/agents/openai.yaml new file mode 100644 index 0000000..bb27d88 --- /dev/null +++ b/.codex/skills/wallora-searcher/agents/openai.yaml @@ -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." diff --git a/.codex/skills/wallora-searcher/references/architecture.md b/.codex/skills/wallora-searcher/references/architecture.md new file mode 100644 index 0000000..0d28b91 --- /dev/null +++ b/.codex/skills/wallora-searcher/references/architecture.md @@ -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. diff --git a/.codex/skills/wallora-searcher/references/project.md b/.codex/skills/wallora-searcher/references/project.md new file mode 100644 index 0000000..2d369f1 --- /dev/null +++ b/.codex/skills/wallora-searcher/references/project.md @@ -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. diff --git a/.codex/skills/wallora-searcher/references/supabase-gallery-sync.md b/.codex/skills/wallora-searcher/references/supabase-gallery-sync.md new file mode 100644 index 0000000..ed6af33 --- /dev/null +++ b/.codex/skills/wallora-searcher/references/supabase-gallery-sync.md @@ -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=&per_page=20 +``` + +Use the official Unsplash API with `Authorization: Client-ID `. 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0777de2 --- /dev/null +++ b/.prettierignore @@ -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 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5577937 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"], + "tabWidth": 4 +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..cfea90c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..27fa4c5 --- /dev/null +++ b/.vscode/settings.json @@ -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" + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..12870f7 --- /dev/null +++ b/AGENTS.md @@ -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= + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=http://your-supabase-url +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Admin Credentials +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=yourpassword + +# Cron (optional) +CRON_SECRET= +``` + +## 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 ` 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 `` not Next.js `` 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/(admin)/admin/(protected)/collections/page.tsx b/app/(admin)/admin/(protected)/collections/page.tsx new file mode 100644 index 0000000..b3a5a70 --- /dev/null +++ b/app/(admin)/admin/(protected)/collections/page.tsx @@ -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 ( + + + +
+
+ +

Collections

+
+
+ +
+
+ ); +} diff --git a/app/(admin)/admin/(protected)/dashboard/data.json b/app/(admin)/admin/(protected)/dashboard/data.json new file mode 100644 index 0000000..2b77e2e --- /dev/null +++ b/app/(admin)/admin/(protected)/dashboard/data.json @@ -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" + } +] diff --git a/app/(admin)/admin/(protected)/dashboard/page.tsx b/app/(admin)/admin/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..983f21f --- /dev/null +++ b/app/(admin)/admin/(protected)/dashboard/page.tsx @@ -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 ( + + + + +
+
+
+ +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/app/(admin)/admin/(protected)/gallery/page.tsx b/app/(admin)/admin/(protected)/gallery/page.tsx new file mode 100644 index 0000000..cc0f73d --- /dev/null +++ b/app/(admin)/admin/(protected)/gallery/page.tsx @@ -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 ( + + + +
+
+ +

Gallery

+
+
+ +
+
+ ); +} diff --git a/app/(admin)/admin/(protected)/layout.tsx b/app/(admin)/admin/(protected)/layout.tsx new file mode 100644 index 0000000..5ab8e77 --- /dev/null +++ b/app/(admin)/admin/(protected)/layout.tsx @@ -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; +} diff --git a/app/(admin)/admin/(protected)/users/page.tsx b/app/(admin)/admin/(protected)/users/page.tsx new file mode 100644 index 0000000..6be9597 --- /dev/null +++ b/app/(admin)/admin/(protected)/users/page.tsx @@ -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 ( + + + +
+
+ +

Users

+
+
+ +
+
+ ); +} diff --git a/app/(admin)/admin/login/page.tsx b/app/(admin)/admin/login/page.tsx new file mode 100644 index 0000000..25a0348 --- /dev/null +++ b/app/(admin)/admin/login/page.tsx @@ -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 ( +
+
+
+
+ +
+ Wallora Admin +
+ +
+
+ ); +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..4e59c8a --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -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"); +} diff --git a/app/(site)/page.tsx b/app/(site)/page.tsx new file mode 100644 index 0000000..f108946 --- /dev/null +++ b/app/(site)/page.tsx @@ -0,0 +1,66 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head + over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/app/api/admin/app-users/[id]/route.ts b/app/api/admin/app-users/[id]/route.ts new file mode 100644 index 0000000..bc01851 --- /dev/null +++ b/app/api/admin/app-users/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/admin/gallery-photos/route.ts b/app/api/admin/gallery-photos/route.ts new file mode 100644 index 0000000..8f799fb --- /dev/null +++ b/app/api/admin/gallery-photos/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/admin/photo-collections/[id]/route.ts b/app/api/admin/photo-collections/[id]/route.ts new file mode 100644 index 0000000..b2614dc --- /dev/null +++ b/app/api/admin/photo-collections/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/admin/photo-collections/route.ts b/app/api/admin/photo-collections/route.ts new file mode 100644 index 0000000..540f019 --- /dev/null +++ b/app/api/admin/photo-collections/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7cb00e9 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/lib/auth"; + +export { GET, POST }; diff --git a/app/api/cron/gallery-photos/route.ts b/app/api/cron/gallery-photos/route.ts new file mode 100644 index 0000000..c9acfde --- /dev/null +++ b/app/api/cron/gallery-photos/route.ts @@ -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); +} diff --git a/app/api/v1/auth/login/route.ts b/app/api/v1/auth/login/route.ts new file mode 100644 index 0000000..7055b63 --- /dev/null +++ b/app/api/v1/auth/login/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/auth/register/route.ts b/app/api/v1/auth/register/route.ts new file mode 100644 index 0000000..29a1514 --- /dev/null +++ b/app/api/v1/auth/register/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/gallery/collections/[slug]/route.ts b/app/api/v1/gallery/collections/[slug]/route.ts new file mode 100644 index 0000000..49709f2 --- /dev/null +++ b/app/api/v1/gallery/collections/[slug]/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/gallery/collections/route.ts b/app/api/v1/gallery/collections/route.ts new file mode 100644 index 0000000..265bd76 --- /dev/null +++ b/app/api/v1/gallery/collections/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/gallery/photos/[id]/route.ts b/app/api/v1/gallery/photos/[id]/route.ts new file mode 100644 index 0000000..0583832 --- /dev/null +++ b/app/api/v1/gallery/photos/[id]/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/gallery/photos/route.ts b/app/api/v1/gallery/photos/route.ts new file mode 100644 index 0000000..d13e3d2 --- /dev/null +++ b/app/api/v1/gallery/photos/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/users/me/favorites/[photoId]/route.ts b/app/api/v1/users/me/favorites/[photoId]/route.ts new file mode 100644 index 0000000..695e051 --- /dev/null +++ b/app/api/v1/users/me/favorites/[photoId]/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/users/me/favorites/route.ts b/app/api/v1/users/me/favorites/route.ts new file mode 100644 index 0000000..ad25fa8 --- /dev/null +++ b/app/api/v1/users/me/favorites/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/users/me/route.ts b/app/api/v1/users/me/route.ts new file mode 100644 index 0000000..56dd0e4 --- /dev/null +++ b/app/api/v1/users/me/route.ts @@ -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 }, + ); + } +} diff --git a/app/api/v1/users/me/settings/route.ts b/app/api/v1/users/me/settings/route.ts new file mode 100644 index 0000000..4df081c --- /dev/null +++ b/app/api/v1/users/me/settings/route.ts @@ -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>) { + 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 }, + ); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..aa9e91d --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..262bb98 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..507db21 --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/components/admin/collections/CollectionsManager.tsx b/components/admin/collections/CollectionsManager.tsx new file mode 100644 index 0000000..b0b7cd4 --- /dev/null +++ b/components/admin/collections/CollectionsManager.tsx @@ -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 = { + 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 | 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(initialCollections); + const [pickedPhotos, setPickedPhotos] = + useState(initialPickedPhotos); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(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) => { + 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; + + 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; + + 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; + + 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 ( +
+
+
+

Collections

+ +
+ +
+ + + + Title + Photos + Status + Actions + + + + {collections.map((collection) => ( + + +
+
+ {collection.cover_photo ? ( + // eslint-disable-next-line @next/next/no-img-element + {collection.title} + ) : ( + + )} +
+
+
+ {collection.title} +
+
+ {collection.slug} +
+
+
+
+ + {collection.photos.length} + + + + {collection.is_published + ? "Published" + : "Draft"} + + + +
+ + +
+
+
+ ))} + {collections.length === 0 && ( + + + No collections yet. + + + )} +
+
+
+
+ +