diff --git a/.env.example b/.env.example index 0b8d075..9f54e92 100644 --- a/.env.example +++ b/.env.example @@ -20,8 +20,9 @@ VAPID_SUBJECT=mailto:you@example.com # App NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 -# Database (optional, defaults to ./data/swapify.db) -# DATABASE_PATH=./data/swapify.db +# Database (optional — omit DATABASE_URL for local PGlite, set for production Postgres) +# DATABASE_URL=postgresql://user:pass@host:5432/swapify +# DATABASE_PATH=./data/swapify-pg # AI — vibe name generation (optional, get key at console.anthropic.com) ANTHROPIC_API_KEY= diff --git a/README.md b/README.md index 92ede4e..94025ed 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,44 @@ -# Swapify +

+ Swapify +

-Collaborative Spotify playlists with reactions — share music with friends through **Swaplists**. +

Swapify

-Each member adds tracks, swipes to react (thumbs up / thumbs down), and the playlist syncs back to Spotify. Built as a mobile-first PWA. +

+ CI + Security + OpenSSF Scorecard +

+ +

+ Collaborative Spotify playlists with swipe reactions.
+ Create shared playlists (Swaplists), add tracks, and react to each other's picks. +

+ +--- ## Features -- **Swaplists** — create or join collaborative playlists linked to Spotify -- **Swipe reactions** — swipe right to vibe, swipe left to skip -- **Vibe sort** — reorder tracks by collective reaction score -- **Real-time sync** — tracks stay in sync with the Spotify playlist -- **Push notifications** — get notified when friends add tracks or react -- **Email invites** — invite members by email with verification -- **Installable PWA** — add to home screen, works offline-capable +- **Swaplists** — create or import collaborative playlists linked to your Spotify account +- **Swipe reactions** — swipe right to vibe, swipe left to skip; reactions drive playlist curation +- **Vibe sort** — auto-reorder tracks by collective reaction score and audio features +- **Real-time sync** — tracks stay in sync with the underlying Spotify playlist +- **Activity feed** — see what friends are adding, reacting to, and listening to +- **Push notifications** — get notified when friends add tracks or react to yours +- **Email invites** — invite members by email with secure verification links +- **AI vibe names** — auto-generated Daylist-style labels for playlists (Claude Haiku) +- **Installable PWA** — add to home screen with offline support and native feel ## Tech Stack -- [Next.js](https://nextjs.org) 16 (App Router, Turbopack) -- TypeScript, Tailwind CSS v4, [shadcn/ui](https://ui.shadcn.com) +- [Next.js](https://nextjs.org) 16 (App Router, Turbopack) + React 19 +- TypeScript, [Tailwind CSS](https://tailwindcss.com) v4, [shadcn/ui](https://ui.shadcn.com) - [Motion](https://motion.dev) (Framer Motion v11+) for gestures and animations -- [Drizzle ORM](https://orm.drizzle.team) + SQLite (better-sqlite3) -- Spotify OAuth PKCE (no NextAuth) -- [iron-session](https://github.com/vvo/iron-session) for cookie sessions +- [Drizzle ORM](https://orm.drizzle.team) + PostgreSQL ([PGlite](https://pglite.dev) local / [node-postgres](https://node-postgres.com) production) +- Spotify OAuth PKCE (no NextAuth) + [iron-session](https://github.com/vvo/iron-session) - [Resend](https://resend.com) for transactional email -- Web Push (VAPID) for notifications +- [Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) (VAPID) for notifications +- [Pino](https://getpino.io) structured logging - Deployed on [Fly.io](https://fly.io) with Docker ## Getting Started @@ -31,7 +46,7 @@ Each member adds tracks, swipes to react (thumbs up / thumbs down), and the play ### Prerequisites - Node.js 20+ -- A [Spotify Developer](https://developer.spotify.com/dashboard) app with a redirect URI set to `http://127.0.0.1:3000/api/auth/callback` +- A [Spotify Developer](https://developer.spotify.com/dashboard) app with redirect URI set to `http://127.0.0.1:3000/api/auth/callback` ### Setup @@ -42,8 +57,8 @@ npm install # Copy environment template and fill in your values cp .env.example .env.local -# Initialize the database -npm run db:seed +# Run database migrations (creates local PGlite DB automatically) +npm run db:migrate # Start the dev server npm run dev @@ -53,16 +68,28 @@ Open [http://127.0.0.1:3000](http://127.0.0.1:3000) to log in with Spotify. ### Environment Variables -See [`.env.example`](.env.example) for all required variables. At minimum you need: +See [`.env.example`](.env.example) for all variables. At minimum you need: | Variable | Description | |---|---| | `SPOTIFY_CLIENT_ID` | From your Spotify Developer app | +| `SPOTIFY_REDIRECT_URI` | OAuth callback URL (`http://127.0.0.1:3000/api/auth/callback` for local) | | `IRON_SESSION_PASSWORD` | Random string, min 32 characters | +| `POLL_SECRET` | Random string, min 16 characters (polling auth) | +| `NEXT_PUBLIC_APP_URL` | Full app URL (e.g., `http://127.0.0.1:3000`) | + +Optional for full functionality: -Optional for full functionality: `RESEND_API_KEY` (email invites), `NEXT_PUBLIC_VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` (push notifications). +| Variable | Description | +|---|---| +| `DATABASE_URL` | Production Postgres connection string (omit for local PGlite) | +| `RESEND_API_KEY` | Email invites via Resend | +| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | Web push notifications | +| `TOKEN_ENCRYPTION_KEY` | AES-256-GCM encryption for Spotify tokens at rest | +| `ANTHROPIC_API_KEY` | AI vibe name generation (Claude Haiku) | +| `SPOTIFY_DEV_MODE` | Set `true` for Spotify apps in development mode (5-user cap, conservative limits) | -Generate VAPID keys with: +Generate VAPID keys: ```bash npx web-push generate-vapid-keys @@ -75,9 +102,15 @@ npx web-push generate-vapid-keys | `npm run dev` | Start dev server | | `npm run build` | Production build | | `npm run lint` | ESLint | -| `npm run format` | Prettier | +| `npm run lint:strict` | ESLint with zero warnings | +| `npm run format` | Prettier format | | `npm run type-check` | TypeScript check | -| `npm run db:seed` | Seed database with sample data | +| `npm test` | Run unit tests (Vitest) | +| `npm run test:watch` | Tests in watch mode | +| `npm run db:migrate` | Run database migrations | +| `npm run db:generate` | Generate migrations from schema changes | +| `npm run security` | Local security scans (CodeQL + npm audit + Gitleaks) | +| `npm run security:codeql` | CodeQL SAST only | ## License diff --git a/drizzle/0026_funnel_sync_mode.sql b/drizzle/0026_funnel_sync_mode.sql new file mode 100644 index 0000000..7987c08 --- /dev/null +++ b/drizzle/0026_funnel_sync_mode.sql @@ -0,0 +1,4 @@ +ALTER TABLE "playlist_members" ADD COLUMN "liked_sync_mode" text;--> statement-breakpoint +ALTER TABLE "playlist_members" ADD COLUMN "liked_playlist_name" text;--> statement-breakpoint +-- Backfill: existing liked playlists were all created via "Save to Spotify" +UPDATE "playlist_members" SET "liked_sync_mode" = 'created' WHERE "liked_playlist_id" IS NOT NULL; diff --git a/drizzle/0027_add_help_banner_dismissed.sql b/drizzle/0027_add_help_banner_dismissed.sql new file mode 100644 index 0000000..b72dfca --- /dev/null +++ b/drizzle/0027_add_help_banner_dismissed.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "help_banner_dismissed" boolean DEFAULT false NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 09b7087..2e7eed1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,20 @@ "when": 1773100000000, "tag": "0025_worker_state", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1773200000000, + "tag": "0026_funnel_sync_mode", + "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1773300000000, + "tag": "0027_add_help_banner_dismissed", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c0bfa8a..baaf832 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "swapify", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", + "@anthropic-ai/sdk": "^0.77.0", "@electric-sql/pglite": "^0.3.15", "@types/pg": "^8.16.0", "better-sqlite3": "^12.6.2", @@ -18,15 +18,15 @@ "drizzle-orm": "^0.45.1", "emoji-picker-react": "^4.18.0", "iron-session": "^8.0.4", - "lucide-react": "^0.575.0", - "motion": "^12.34.3", + "lucide-react": "^0.574.0", + "motion": "^12.34.2", "nanoid": "^5.1.6", "next": "16.1.6", "pg": "^8.18.0", "pino": "^10.3.1", "radix-ui": "^1.4.3", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.3", + "react-dom": "19.2.3", "react-icons": "^5.5.0", "resend": "^6.9.2", "sonner": "^2.0.7", @@ -49,7 +49,7 @@ "eslint-config-next": "16.1.6", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-sonarjs": "^4.0.0", - "happy-dom": "^20.7.0", + "happy-dom": "^20.6.3", "husky": "^9.1.7", "lint-staged": "^16.2.7", "msw": "^2.12.10", @@ -97,9 +97,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.78.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz", - "integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==", + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.77.0.tgz", + "integrity": "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -147,6 +147,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -725,6 +726,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -791,7 +793,8 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@emnapi/core": { "version": "1.8.1", @@ -1812,9 +1815,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", - "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -2801,6 +2804,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -5246,6 +5250,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -5303,6 +5308,7 @@ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -5315,6 +5321,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5325,6 +5332,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5415,6 +5423,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -6083,6 +6092,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6570,6 +6580,7 @@ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6673,6 +6684,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8498,6 +8510,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8577,11 +8590,12 @@ } }, "node_modules/eslint": { - "version": "9.39.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", - "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8589,7 +8603,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.3", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -8767,6 +8781,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9211,6 +9226,7 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9530,12 +9546,12 @@ } }, "node_modules/framer-motion": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", - "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "version": "12.34.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.2.tgz", + "integrity": "sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.34.3", + "motion-dom": "^12.34.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -9896,11 +9912,12 @@ } }, "node_modules/happy-dom": { - "version": "20.7.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", - "integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==", + "version": "20.6.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.6.3.tgz", + "integrity": "sha512-QAMY7d228dHs8gb9NG4SJ3OxQo4r+NGN8pOXGZ3SGfQf/XYuuYubrtZ25QVY2WoUQdskhRXSXb4R4mcRk+hV1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -10037,6 +10054,7 @@ "integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -11632,9 +11650,9 @@ } }, "node_modules/lucide-react": { - "version": "0.575.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", - "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz", + "integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -11852,12 +11870,12 @@ "license": "MIT" }, "node_modules/motion": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.34.3.tgz", - "integrity": "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==", + "version": "12.34.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.34.2.tgz", + "integrity": "sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==", "license": "MIT", "dependencies": { - "framer-motion": "^12.34.3", + "framer-motion": "^12.34.2", "tslib": "^2.4.0" }, "peerDependencies": { @@ -11878,9 +11896,9 @@ } }, "node_modules/motion-dom": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", - "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "version": "12.34.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.2.tgz", + "integrity": "sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -12695,6 +12713,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -13313,24 +13332,26 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.3" } }, "node_modules/react-icons": { @@ -14932,6 +14953,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15217,6 +15239,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15509,6 +15532,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -15602,6 +15626,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15615,6 +15640,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -16136,6 +16162,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9130625..bcb56ab 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "db:validate": "npx tsx src/db/validate-migrations.ts", "worker": "npx tsx src/worker.ts", "worker:build": "npx esbuild src/worker.ts --bundle --platform=node --outfile=worker.js --packages=external", + "email:assets": "node scripts/generate-email-assets.mjs", + "security": "bash scripts/security-scan.sh all", + "security:codeql": "bash scripts/security-scan.sh codeql", + "security:audit": "bash scripts/security-scan.sh audit", + "security:secrets": "bash scripts/security-scan.sh secrets", "prepare": "husky" }, "lint-staged": { @@ -34,7 +39,7 @@ ] }, "dependencies": { - "@anthropic-ai/sdk": "^0.78.0", + "@anthropic-ai/sdk": "^0.77.0", "@electric-sql/pglite": "^0.3.15", "@types/pg": "^8.16.0", "better-sqlite3": "^12.6.2", @@ -44,15 +49,15 @@ "drizzle-orm": "^0.45.1", "emoji-picker-react": "^4.18.0", "iron-session": "^8.0.4", - "lucide-react": "^0.575.0", - "motion": "^12.34.3", + "lucide-react": "^0.574.0", + "motion": "^12.34.2", "nanoid": "^5.1.6", "next": "16.1.6", "pg": "^8.18.0", "pino": "^10.3.1", "radix-ui": "^1.4.3", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.3", + "react-dom": "19.2.3", "react-icons": "^5.5.0", "resend": "^6.9.2", "sonner": "^2.0.7", @@ -78,7 +83,7 @@ "eslint-config-next": "16.1.6", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-sonarjs": "^4.0.0", - "happy-dom": "^20.7.0", + "happy-dom": "^20.6.3", "husky": "^9.1.7", "lint-staged": "^16.2.7", "msw": "^2.12.10", diff --git a/public/email-assets/btn-arrow-right.png b/public/email-assets/btn-arrow-right.png new file mode 100644 index 0000000..4f53cda Binary files /dev/null and b/public/email-assets/btn-arrow-right.png differ diff --git a/public/email-assets/btn-check-dark.png b/public/email-assets/btn-check-dark.png new file mode 100644 index 0000000..c293219 Binary files /dev/null and b/public/email-assets/btn-check-dark.png differ diff --git a/public/email-assets/btn-check.png b/public/email-assets/btn-check.png new file mode 100644 index 0000000..bf21fba Binary files /dev/null and b/public/email-assets/btn-check.png differ diff --git a/public/email-assets/btn-headphones-dark.png b/public/email-assets/btn-headphones-dark.png new file mode 100644 index 0000000..12e01f9 Binary files /dev/null and b/public/email-assets/btn-headphones-dark.png differ diff --git a/public/email-assets/btn-headphones.png b/public/email-assets/btn-headphones.png new file mode 100644 index 0000000..feabbff Binary files /dev/null and b/public/email-assets/btn-headphones.png differ diff --git a/public/email-assets/btn-link-dark.png b/public/email-assets/btn-link-dark.png new file mode 100644 index 0000000..f5450c1 Binary files /dev/null and b/public/email-assets/btn-link-dark.png differ diff --git a/public/email-assets/btn-link.png b/public/email-assets/btn-link.png new file mode 100644 index 0000000..9bfda07 Binary files /dev/null and b/public/email-assets/btn-link.png differ diff --git a/public/email-assets/btn-play-dark.png b/public/email-assets/btn-play-dark.png new file mode 100644 index 0000000..ae5db54 Binary files /dev/null and b/public/email-assets/btn-play-dark.png differ diff --git a/public/email-assets/btn-play.png b/public/email-assets/btn-play.png new file mode 100644 index 0000000..4dddec8 Binary files /dev/null and b/public/email-assets/btn-play.png differ diff --git a/public/email-assets/btn-refresh.png b/public/email-assets/btn-refresh.png new file mode 100644 index 0000000..bd3083b Binary files /dev/null and b/public/email-assets/btn-refresh.png differ diff --git a/public/email-assets/btn-settings.png b/public/email-assets/btn-settings.png new file mode 100644 index 0000000..60c3432 Binary files /dev/null and b/public/email-assets/btn-settings.png differ diff --git a/public/email-assets/btn-sliders-dark.png b/public/email-assets/btn-sliders-dark.png new file mode 100644 index 0000000..54700d8 Binary files /dev/null and b/public/email-assets/btn-sliders-dark.png differ diff --git a/public/email-assets/btn-sliders.png b/public/email-assets/btn-sliders.png new file mode 100644 index 0000000..ca9ca00 Binary files /dev/null and b/public/email-assets/btn-sliders.png differ diff --git a/public/email-assets/icon-discover.png b/public/email-assets/icon-discover.png new file mode 100644 index 0000000..0bdef52 Binary files /dev/null and b/public/email-assets/icon-discover.png differ diff --git a/public/email-assets/icon-music.png b/public/email-assets/icon-music.png new file mode 100644 index 0000000..3c8d6d0 Binary files /dev/null and b/public/email-assets/icon-music.png differ diff --git a/public/email-assets/icon-swipe.png b/public/email-assets/icon-swipe.png new file mode 100644 index 0000000..bf7b399 Binary files /dev/null and b/public/email-assets/icon-swipe.png differ diff --git a/public/email-assets/logo-lockup.png b/public/email-assets/logo-lockup.png new file mode 100644 index 0000000..4d45c0d Binary files /dev/null and b/public/email-assets/logo-lockup.png differ diff --git a/public/email-assets/logo-white.png b/public/email-assets/logo-white.png new file mode 100644 index 0000000..329d8cd Binary files /dev/null and b/public/email-assets/logo-white.png differ diff --git a/public/email-assets/underline-blue.png b/public/email-assets/underline-blue.png new file mode 100644 index 0000000..8946793 Binary files /dev/null and b/public/email-assets/underline-blue.png differ diff --git a/public/email-assets/underline-green.png b/public/email-assets/underline-green.png new file mode 100644 index 0000000..3e03f01 Binary files /dev/null and b/public/email-assets/underline-green.png differ diff --git a/public/email-assets/underline-lime.png b/public/email-assets/underline-lime.png new file mode 100644 index 0000000..4edd5fc Binary files /dev/null and b/public/email-assets/underline-lime.png differ diff --git a/public/email-assets/underline-orange.png b/public/email-assets/underline-orange.png new file mode 100644 index 0000000..812920e Binary files /dev/null and b/public/email-assets/underline-orange.png differ diff --git a/scripts/generate-email-assets.mjs b/scripts/generate-email-assets.mjs new file mode 100755 index 0000000..079cf14 --- /dev/null +++ b/scripts/generate-email-assets.mjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +/** + * Generate email template assets for Swapify. + * Produces PNG images in public/email-assets/ for use in transactional emails. + * + * Assets generated: + * - logo-lockup.png — Brand icon + "Swapify" wordmark (retina, 2x) + * - logo-white.png — Same lockup, white at 25% opacity (footer) + * - icon-music.png — Lucide Music icon on brand-tinted rounded square + * - icon-swipe.png — Lucide ArrowLeftRight icon on green-tinted rounded square + * - icon-discover.png — Lucide Sparkles icon on light-blue-tinted rounded square + */ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, ".."); +const OUT_DIR = resolve(ROOT, "public/email-assets"); + +// Swapify brand colors +const BRAND = "#38BDF8"; +const ACCENT_GREEN = "#4ADE80"; +const BRAND_HOVER = "#7DD3FC"; +const NAVY = "#081420"; + +// Noun Project "Share Song" icon path (5120-unit coordinate system, y-inverted) +// Same icon used in generate-icons.mjs +const ICON_PATH = `M1483 5105 c-170 -46 -304 -181 -348 -350 -12 -47 -15 -123 -15 -372 l0 -313 -47 23 c-100 50 -152 62 -273 62 -94 0 -128 -4 -185 -23 -109 -36 -193 -88 -271 -167 -244 -247 -244 -643 1 -891 254 -257 657 -258 907 -1 l48 48 872 -386 873 -387 2 -111 c1 -62 3 -123 5 -137 3 -23 -51 -54 -802 -471 l-805 -447 -3 304 c-3 341 -1 351 64 400 l37 29 217 5 217 5 37 29 c71 54 85 151 32 221 -46 59 -72 65 -293 65 -217 0 -285 -11 -375 -56 -71 -36 -159 -123 -197 -193 -56 -106 -61 -143 -61 -488 l0 -313 -47 23 c-100 50 -152 62 -273 62 -94 0 -128 -4 -185 -23 -109 -36 -193 -88 -271 -167 -247 -249 -244 -645 6 -896 315 -316 845 -219 1032 190 39 85 58 189 58 324 l1 112 886 491 886 491 61 -49 c221 -179 520 -194 759 -39 117 77 203 189 255 333 l26 73 4 383 3 382 193 0 c258 0 332 22 455 136 113 104 169 270 144 419 -33 195 -192 359 -382 395 -80 15 -286 12 -359 -5 -175 -41 -311 -175 -357 -350 -12 -47 -15 -123 -15 -372 l0 -313 -42 21 c-213 109 -468 84 -665 -65 -35 -26 -73 -61 -87 -78 l-23 -30 -644 285 c-354 156 -749 331 -877 388 l-234 104 6 35 c3 19 6 187 6 373 l0 337 183 0 c200 0 271 11 359 56 65 33 164 132 200 200 145 271 -6 610 -307 689 -77 20 -318 20 -392 0z`; + +// Lucide icon SVG paths (24x24 viewBox, stroke-based) +const LUCIDE_ICONS = { + // Feature row icons + music: ``, + arrowLeftRight: ``, + sparkles: ``, + // Button action icons — on-brand for a music app + play: ``, + circleCheck: ``, + sliders: ``, + link2: ``, + headphones: ``, +}; + +// Pre-computed background colors: rgba tint on #081420 navy base +// rgba(56,189,248,0.1) on #081420 -> ~#0e1d2e +// rgba(74,222,128,0.1) on #081420 -> ~#0d1726 +// rgba(125,211,252,0.1) on #081420 -> ~#101e2c +const ICON_BG_COLORS = { + music: "#0e1d2e", + arrowLeftRight: "#0d1726", + sparkles: "#101e2c", +}; + +/** + * Load CalSans font and return base64-encoded data for SVG embedding. + */ +function loadCalSansBase64() { + const fontPath = resolve( + ROOT, + "node_modules/cal-sans/fonts/webfonts/CalSans-SemiBold.ttf" + ); + const fontData = readFileSync(fontPath); + return fontData.toString("base64"); +} + +/** + * Build the Swapify logo icon SVG (brand icon on transparent background). + * @param {number} size - Icon size in pixels + * @param {string} color - Fill color for the icon + */ +function buildLogoIconSvg(size, color = BRAND) { + return ` + + + +`; +} + +/** + * Build a full logo lockup SVG: icon + "Swapify" wordmark side by side. + * Uses embedded CalSans font via base64 @font-face. + * @param {object} opts + * @param {number} opts.iconSize - Icon size in pixels + * @param {string} opts.color - Color for both icon and text + * @param {number} opts.opacity - Overall opacity (1.0 for brand, 0.25 for white footer) + * @param {string} opts.fontBase64 - Base64-encoded CalSans TTF font data + */ +function buildLockupSvg({ iconSize, color, textColor, opacity, fontBase64 }) { + const resolvedTextColor = textColor || color; + const gap = Math.round(iconSize * 0.15); + const fontSize = Math.round(iconSize * 0.5); + // Estimate text width: CalSans at this size, "Swapify" is ~7 chars + const textWidth = Math.round(fontSize * 3.8); + const totalWidth = iconSize + gap + textWidth; + const totalHeight = iconSize; + const textY = Math.round(totalHeight * 0.62); // baseline alignment + + return ` + + + + + + + + + + Swapify + +`; +} + +/** + * Build a feature icon SVG: Lucide icon centered on a rounded-rect background. + * @param {object} opts + * @param {string} opts.iconKey - Key into LUCIDE_ICONS + * @param {number} opts.size - Total image size in pixels (e.g. 88 for 2x retina of 44px) + * @param {number} opts.borderRadius - Corner radius in pixels + */ +function buildFeatureIconSvg({ iconKey, size, borderRadius }) { + const bgColor = ICON_BG_COLORS[iconKey]; + const iconPaths = LUCIDE_ICONS[iconKey]; + + // Center the 24x24 Lucide icon within the square, scaled to ~50% of container + const lucideViewbox = 24; + const iconDisplaySize = Math.round(size * 0.5); + const iconOffset = Math.round((size - iconDisplaySize) / 2); + + return ` + + + ${iconPaths} + +`; +} + +/** + * Build a small inline button icon — Lucide stroke on transparent bg. + * Some icons use fill instead of stroke (like play triangle). + * @param {object} opts + * @param {string} opts.iconKey - Key into LUCIDE_ICONS + * @param {number} opts.size - Output size in pixels + * @param {string} opts.color - Icon color + * @param {boolean} opts.filled - Use fill instead of stroke (for solid icons like play) + */ +function buildButtonIconSvg({ iconKey, size, color, filled = false }) { + const iconPaths = LUCIDE_ICONS[iconKey]; + const attrs = filled + ? `fill="${color}" stroke="none"` + : `fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"`; + return ` + ${iconPaths} + `; +} + +/** + * Build a hand-drawn style underline SVG — slightly wavy/organic path. + * @param {object} opts + * @param {number} opts.width - Output width in pixels + * @param {number} opts.height - Output height in pixels + * @param {string} opts.color - Stroke color + * @param {string} opts.colorEnd - End gradient color (optional) + */ +function buildDrawnUnderlineSvg({ width, height, color, colorEnd }) { + const endColor = colorEnd || color; + // Hand-drawn bezier curve that wobbles slightly + const midY = height / 2; + const path = `M 0 ${midY + 1} C ${width * 0.15} ${midY - 2}, ${width * 0.3} ${midY + 2.5}, ${width * 0.5} ${midY} S ${width * 0.75} ${midY - 1.5}, ${width} ${midY + 0.5}`; + return ` + + + + + + + +`; +} + +async function main() { + mkdirSync(OUT_DIR, { recursive: true }); + + console.log("Loading CalSans font..."); + const fontBase64 = loadCalSansBase64(); + console.log( + `Loaded CalSans-SemiBold.ttf (${Math.round(fontBase64.length / 1024)}KB base64)` + ); + + // 1. logo-lockup.png — Brand icon + white wordmark, 4x retina (192px icon → display ~48px) + { + const svg = buildLockupSvg({ + iconSize: 192, + color: BRAND, + textColor: "#f8fafc", + opacity: 1.0, + fontBase64, + }); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + const trimmed = await sharp(png).trim().png().toBuffer(); + writeFileSync(resolve(OUT_DIR, "logo-lockup.png"), trimmed); + console.log(`Created logo-lockup.png (${trimmed.length} bytes)`); + } + + // 2. logo-white.png — White at 25% opacity, 4x retina (160px icon → display ~40px) + { + const svg = buildLockupSvg({ + iconSize: 160, + color: "#ffffff", + opacity: 0.25, + fontBase64, + }); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + const trimmed = await sharp(png).trim().png().toBuffer(); + writeFileSync(resolve(OUT_DIR, "logo-white.png"), trimmed); + console.log(`Created logo-white.png (${trimmed.length} bytes)`); + } + + // 3. icon-music.png — Lucide Music on brand-tinted bg + { + const svg = buildFeatureIconSvg({ + iconKey: "music", + size: 88, + borderRadius: 24, + }); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, "icon-music.png"), png); + console.log(`Created icon-music.png (${png.length} bytes)`); + } + + // 4. icon-swipe.png — Lucide ArrowLeftRight on green-tinted bg + { + const svg = buildFeatureIconSvg({ + iconKey: "arrowLeftRight", + size: 88, + borderRadius: 24, + }); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, "icon-swipe.png"), png); + console.log(`Created icon-swipe.png (${png.length} bytes)`); + } + + // 5. icon-discover.png — Lucide Sparkles on light-blue-tinted bg + { + const svg = buildFeatureIconSvg({ + iconKey: "sparkles", + size: 88, + borderRadius: 24, + }); + const png = await sharp(Buffer.from(svg)).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, "icon-discover.png"), png); + console.log(`Created icon-discover.png (${png.length} bytes)`); + } + + // 6+. Button action icons — 96px (6x retina for 16px display), both dark & light + const LIGHT = "#f8fafc"; + const DARK = "#0c1929"; + const buttonIconDefs = [ + { key: "play", name: "play", filled: true }, + { key: "circleCheck", name: "check", filled: false }, + { key: "sliders", name: "sliders", filled: false }, + { key: "link2", name: "link", filled: false }, + { key: "headphones", name: "headphones", filled: false }, + ]; + for (const { key, name, filled } of buttonIconDefs) { + // Light variant (white icon — for dark button backgrounds like blue) + const svgLight = buildButtonIconSvg({ iconKey: key, size: 96, color: LIGHT, filled }); + const pngLight = await sharp(Buffer.from(svgLight)).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, `btn-${name}.png`), pngLight); + console.log(`Created btn-${name}.png (${pngLight.length} bytes)`); + + // Dark variant (dark icon — for light button backgrounds like green/orange/lime) + const svgDark = buildButtonIconSvg({ iconKey: key, size: 96, color: DARK, filled }); + const pngDark = await sharp(Buffer.from(svgDark)).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, `btn-${name}-dark.png`), pngDark); + console.log(`Created btn-${name}-dark.png (${pngDark.length} bytes)`); + } + + // 10-13. Hand-drawn underline accents (one per color family) + const underlines = [ + { file: "underline-blue.png", color: BRAND, colorEnd: BRAND_HOVER }, + { file: "underline-green.png", color: ACCENT_GREEN, colorEnd: "#86EFAC" }, + { file: "underline-orange.png", color: "#FB923C", colorEnd: "#FDBA74" }, + { file: "underline-lime.png", color: "#c4f441", colorEnd: "#d9f99d" }, + ]; + for (const { file, color, colorEnd } of underlines) { + const svg = buildDrawnUnderlineSvg({ width: 200, height: 12, color, colorEnd }); + // Render at 2x for retina + const png = await sharp(Buffer.from(svg)).resize(400, 24).png().toBuffer(); + writeFileSync(resolve(OUT_DIR, file), png); + console.log(`Created ${file} (${png.length} bytes)`); + } + + console.log(`\nDone! All email assets generated in ${OUT_DIR}`); +} + +main().catch((err) => { + console.error("Email asset generation failed:", err); + process.exit(1); +}); diff --git a/src/app/api/activity/__tests__/route.test.ts b/src/app/api/activity/__tests__/route.test.ts index 07abfd5..a6fc97c 100644 --- a/src/app/api/activity/__tests__/route.test.ts +++ b/src/app/api/activity/__tests__/route.test.ts @@ -4,7 +4,7 @@ import { NextRequest } from 'next/server'; // ─── Mocks ────────────────────────────────────────────────────────────────── vi.mock('@/lib/auth', () => ({ - getCurrentUser: vi.fn(), + requireAuth: vi.fn(), })); vi.mock('@/db', () => ({ @@ -50,7 +50,7 @@ vi.mock('drizzle-orm', () => ({ // ─── Imports ──────────────────────────────────────────────────────────────── import { GET } from '@/app/api/activity/route'; -import { getCurrentUser } from '@/lib/auth'; +import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; // ─── Test Data ────────────────────────────────────────────────────────────── @@ -120,19 +120,17 @@ function setupDefaultMocks(options?: { memberships?: unknown[]; circleMembership describe('GET /api/activity', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getCurrentUser).mockResolvedValue(mockUser as any); + vi.mocked(requireAuth).mockResolvedValue(mockUser as any); }); // ── 1. Auth ────────────────────────────────────────────────────────────── - it('returns 401 when not authenticated', async () => { - vi.mocked(getCurrentUser).mockResolvedValue(null); + it('throws redirect when not authenticated', async () => { + const redirectError = new Error('NEXT_REDIRECT'); + (redirectError as any).digest = 'NEXT_REDIRECT;replace;/login'; + vi.mocked(requireAuth).mockRejectedValue(redirectError); - const response = await GET(createRequest()); - - expect(response.status).toBe(401); - const data = await response.json(); - expect(data.error).toBe('Unauthorized'); + await expect(GET(createRequest())).rejects.toThrow('NEXT_REDIRECT'); }); // ── 2. Empty memberships ───────────────────────────────────────────────── diff --git a/src/app/api/activity/mark-read/route.ts b/src/app/api/activity/mark-read/route.ts index 5aca8a0..63573ce 100644 --- a/src/app/api/activity/mark-read/route.ts +++ b/src/app/api/activity/mark-read/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth'; +import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlistMembers, playlists } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; export async function POST(request: NextRequest) { - const user = await getCurrentUser(); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await requireAuth(); const body = await request.json().catch(() => ({})); const { playlistId, circleId } = body as { playlistId?: string; circleId?: string }; diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts index 982bed8..4fd053b 100644 --- a/src/app/api/activity/route.ts +++ b/src/app/api/activity/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser } from '@/lib/auth'; +import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlistMembers, @@ -293,8 +293,7 @@ async function fetchEventRows( // ─── Route Handler ────────────────────────────────────────────────────────── export async function GET(request: NextRequest) { - const user = await getCurrentUser(); - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await requireAuth(); const { limit, circleId, offset } = parseQueryParams(request); const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); diff --git a/src/app/api/email-preview/[template]/route.tsx b/src/app/api/email-preview/[template]/route.tsx new file mode 100644 index 0000000..28aeadc --- /dev/null +++ b/src/app/api/email-preview/[template]/route.tsx @@ -0,0 +1,123 @@ +import { NextResponse } from 'next/server'; +import { + renderEmail, + playlistInviteData, + circleInviteData, + emailVerifyData, + disconnectData, + circlePausedHostData, + circlePausedMemberData, + circleOnlineData, + notificationData, +} from '@/lib/email/templates'; +import type { TemplateData } from '@/lib/email/types'; + +const MOCK_URL = 'https://swapify.312.dev/dashboard'; +const MOCK_UNSUB = 'https://swapify.312.dev/api/email/unsubscribe?uid=demo'; + +const templates: Record { subject: string; data: TemplateData }> = { + 'playlist-invite': () => ({ + subject: 'You\'re invited to "Summer Vibes"', + data: { + ...playlistInviteData('Alex', 'Summer Vibes', MOCK_URL), + unsubUrl: MOCK_UNSUB, + }, + }), + 'circle-invite': () => ({ + subject: 'You\'re invited to join "The Crew"', + data: { + ...circleInviteData('Alex', 'The Crew', MOCK_URL), + unsubUrl: MOCK_UNSUB, + }, + }), + 'email-verify': () => ({ + subject: 'Confirm your email', + data: emailVerifyData(MOCK_URL), + }), + disconnect: () => ({ + subject: "You've been disconnected from Swapify", + data: { ...disconnectData(MOCK_URL), unsubUrl: MOCK_UNSUB }, + }), + 'circle-paused-host': () => ({ + subject: 'Your Spotify app needs attention', + data: { + ...circlePausedHostData('The Crew', MOCK_URL), + unsubUrl: MOCK_UNSUB, + }, + }), + 'circle-paused-member': () => ({ + subject: 'Your circle has been paused', + data: { + ...circlePausedMemberData('The Crew', MOCK_URL), + unsubUrl: MOCK_UNSUB, + }, + }), + 'circle-online': () => ({ + subject: 'The Crew is back online', + data: { + ...circleOnlineData('The Crew', MOCK_URL), + unsubUrl: MOCK_UNSUB, + }, + }), + notification: () => ({ + subject: 'Someone reacted to your track', + data: { + ...notificationData( + 'Someone reacted to
your track', + 'Jordan gave a thumbs up to "Espresso" by Sabrina Carpenter on your Swaplist.', + MOCK_URL + ), + unsubUrl: MOCK_UNSUB, + }, + }), +}; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ template: string }> } +) { + if (process.env.NODE_ENV === 'production') { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const { template } = await params; + + // Index page: list all templates + if (template === 'index') { + const links = Object.keys(templates) + .map( + (name) => + `
  • ${name}
  • ` + ) + .join('\n'); + + const html = ` + +Email Previews + +

    Email Template Previews

    +

    Click a template to preview it in the browser.

    +
      ${links}
    + +`; + return new NextResponse(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + const builder = templates[template]; + if (!builder) { + const available = Object.keys(templates).join(', '); + return NextResponse.json( + { error: `Unknown template: ${template}`, available }, + { status: 404 } + ); + } + + const { data } = builder(); + const html = renderEmail(data, { relativeAssets: true }); + + return new NextResponse(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} diff --git a/src/app/api/player/current/route.ts b/src/app/api/player/current/route.ts index 76029b5..daff568 100644 --- a/src/app/api/player/current/route.ts +++ b/src/app/api/player/current/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from 'next/server'; -import { getCurrentUser, getSession } from '@/lib/auth'; -import { db } from '@/db'; -import { circleMembers } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; +import { getCurrentUser, getActiveOrDefaultCircleId } from '@/lib/auth'; import { getCurrentPlayback } from '@/lib/spotify'; import { isCircleRateLimited } from '@/lib/spotify-budget'; @@ -11,24 +8,13 @@ export async function GET() { const user = await getCurrentUser(); if (!user) return NextResponse.json({ trackId: null, isPlaying: false }); - const session = await getSession(); - let circleId = session.activeCircleId ?? null; - if (!circleId) { - const m = await db.query.circleMembers.findFirst({ - where: eq(circleMembers.userId, user.id), - }); - circleId = m?.circleId ?? null; - } else { - const m = await db.query.circleMembers.findFirst({ - where: and(eq(circleMembers.userId, user.id), eq(circleMembers.circleId, circleId)), - }); - if (!m) circleId = null; - } + const resolved = await getActiveOrDefaultCircleId(user.id); + if (!resolved) return NextResponse.json({ trackId: null, isPlaying: false }); - if (!circleId) return NextResponse.json({ trackId: null, isPlaying: false }); + const { circleId } = resolved; // If rate-limited, return no playback — don't waste a call - if (!circleId || isCircleRateLimited(circleId)) + if (isCircleRateLimited(circleId)) return NextResponse.json({ trackId: null, isPlaying: false }); const data = await getCurrentPlayback(user.id, circleId); diff --git a/src/app/api/player/play-all/route.ts b/src/app/api/player/play-all/route.ts index 0accc5d..c29835b 100644 --- a/src/app/api/player/play-all/route.ts +++ b/src/app/api/player/play-all/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; -import { getCurrentUser, getSession } from '@/lib/auth'; +import { getCurrentUser, getActiveOrDefaultCircleId } from '@/lib/auth'; import { db } from '@/db'; -import { circleMembers } from '@/db/schema'; -import { eq, and, sql } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; import { startPlaybackMultiple } from '@/lib/spotify'; export async function POST() { @@ -10,21 +9,10 @@ export async function POST() { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const session = await getSession(); - let circleId = session.activeCircleId ?? null; - if (!circleId) { - const m = await db.query.circleMembers.findFirst({ - where: eq(circleMembers.userId, user.id), - }); - circleId = m?.circleId ?? null; - } else { - const m = await db.query.circleMembers.findFirst({ - where: and(eq(circleMembers.userId, user.id), eq(circleMembers.circleId, circleId)), - }); - if (!m) circleId = null; - } + const resolved = await getActiveOrDefaultCircleId(user.id); + if (!resolved) return NextResponse.json({ error: 'No circle found' }, { status: 400 }); - if (!circleId) return NextResponse.json({ error: 'No circle found' }, { status: 400 }); + const { circleId } = resolved; const userId = user.id; const result = await db.execute(sql` diff --git a/src/app/api/player/play-track/route.ts b/src/app/api/player/play-track/route.ts index b6ab167..958dcae 100644 --- a/src/app/api/player/play-track/route.ts +++ b/src/app/api/player/play-track/route.ts @@ -1,8 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser, getSession } from '@/lib/auth'; -import { db } from '@/db'; -import { circleMembers } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; +import { getCurrentUser, getActiveOrDefaultCircleId } from '@/lib/auth'; import { startPlayback } from '@/lib/spotify'; export async function POST(request: NextRequest) { @@ -10,21 +7,10 @@ export async function POST(request: NextRequest) { const user = await getCurrentUser(); if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const session = await getSession(); - let circleId = session.activeCircleId ?? null; - if (!circleId) { - const m = await db.query.circleMembers.findFirst({ - where: eq(circleMembers.userId, user.id), - }); - circleId = m?.circleId ?? null; - } else { - const m = await db.query.circleMembers.findFirst({ - where: and(eq(circleMembers.userId, user.id), eq(circleMembers.circleId, circleId)), - }); - if (!m) circleId = null; - } + const resolved = await getActiveOrDefaultCircleId(user.id); + if (!resolved) return NextResponse.json({ error: 'No circle found' }, { status: 400 }); - if (!circleId) return NextResponse.json({ error: 'No circle found' }, { status: 400 }); + const { circleId } = resolved; const { trackUri } = (await request.json()) as { trackUri?: string }; if (!trackUri) return NextResponse.json({ error: 'trackUri is required' }, { status: 400 }); diff --git a/src/app/api/playlists/[playlistId]/__tests__/route.test.ts b/src/app/api/playlists/[playlistId]/__tests__/route.test.ts index 9db0703..541a299 100644 --- a/src/app/api/playlists/[playlistId]/__tests__/route.test.ts +++ b/src/app/api/playlists/[playlistId]/__tests__/route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; // ─── Mocks (hoisted before any imports of mocked modules) ──────────────────── @@ -72,6 +72,10 @@ vi.mock('@/lib/spotify', () => ({ }, })); +vi.mock('@/lib/spotify-errors', () => ({ + handleSpotifyError: vi.fn(), +})); + vi.mock('@/lib/utils', () => ({ VALID_REMOVAL_DELAYS: ['immediate', '1h', '12h', '24h', '3d', '1w', '1m'], SORT_MODES: ['order_added', 'energy_desc', 'energy_asc', 'round_robin'], @@ -101,6 +105,7 @@ import { AppInvalidError, SpotifyRateLimitError, } from '@/lib/spotify'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { buildSpotifyDescription } from '@/lib/vibe-name'; import { GET, PATCH, DELETE } from '../route'; @@ -168,6 +173,32 @@ beforeEach(() => { // Default: auth returns test user vi.mocked(requireAuth).mockResolvedValue(TEST_USER as any); + + // Configure handleSpotifyError to work with test error classes + vi.mocked(handleSpotifyError).mockImplementation((err: unknown) => { + if (err instanceof SpotifyRateLimitError) { + return NextResponse.json( + { + error: 'Spotify is a bit busy right now. Please try again in a minute.', + rateLimited: true, + }, + { status: 429 } + ); + } + if (err instanceof AppInvalidError) { + return NextResponse.json( + { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, + { status: 503 } + ); + } + if (err instanceof TokenInvalidError) { + return NextResponse.json( + { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, + { status: 401 } + ); + } + return null; + }); }); // ─── GET Tests ─────────────────────────────────────────────────────────────── diff --git a/src/app/api/playlists/[playlistId]/follow/route.ts b/src/app/api/playlists/[playlistId]/follow/route.ts index 29e993b..70c3f65 100644 --- a/src/app/api/playlists/[playlistId]/follow/route.ts +++ b/src/app/api/playlists/[playlistId]/follow/route.ts @@ -3,7 +3,9 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; -import { followPlaylist, SpotifyRateLimitError } from '@/lib/spotify'; +import { followPlaylist } from '@/lib/spotify'; +import { isCircleRateLimited } from '@/lib/spotify-budget'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; // POST /api/playlists/[playlistId]/follow — follow the Spotify playlist @@ -34,18 +36,19 @@ export async function POST( return NextResponse.json({ error: 'Playlist not found' }, { status: 404 }); } + // Following requires a Spotify call — fail fast if circle is rate-limited + if (isCircleRateLimited(playlist.circleId)) { + return NextResponse.json( + { error: 'Spotify rate-limited — please try again later', rateLimited: true }, + { status: 429 } + ); + } + try { await followPlaylist(user.id, playlist.circleId, playlist.spotifyPlaylistId); } catch (err) { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } + const errorResponse = handleSpotifyError(err); + if (errorResponse) return errorResponse; return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to follow playlist' }, { status: 500 } diff --git a/src/app/api/playlists/[playlistId]/join/__tests__/route.test.ts b/src/app/api/playlists/[playlistId]/join/__tests__/route.test.ts index 6d83908..9ddaf9b 100644 --- a/src/app/api/playlists/[playlistId]/join/__tests__/route.test.ts +++ b/src/app/api/playlists/[playlistId]/join/__tests__/route.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; // ─── Mocks (hoisted before any imports of mocked modules) ──────────────────── @@ -88,6 +88,14 @@ vi.mock('@/lib/spotify', () => ({ }, })); +vi.mock('@/lib/spotify-core', () => ({ + isRateLimited: vi.fn().mockReturnValue(false), +})); + +vi.mock('@/lib/spotify-errors', () => ({ + handleSpotifyError: vi.fn(), +})); + vi.mock('@/lib/utils', () => ({ generateId: vi.fn().mockReturnValue('gen-id'), formatPlaylistName: vi.fn((names: string[]) => names.join('+') + ' Swapify'), @@ -135,6 +143,33 @@ beforeEach(async () => { const { followPlaylist, updatePlaylistDetails } = await import('@/lib/spotify'); vi.mocked(followPlaylist).mockResolvedValue(undefined as any); vi.mocked(updatePlaylistDetails).mockResolvedValue(undefined as any); + + const { isRateLimited } = await import('@/lib/spotify-core'); + vi.mocked(isRateLimited).mockReturnValue(false); + + const { handleSpotifyError } = await import('@/lib/spotify-errors'); + const spotify = await import('@/lib/spotify'); + vi.mocked(handleSpotifyError).mockImplementation((err: unknown) => { + if (err instanceof spotify.SpotifyRateLimitError) { + return NextResponse.json( + { error: 'Spotify is a bit busy right now.', rateLimited: true }, + { status: 429 } + ); + } + if (err instanceof spotify.AppInvalidError) { + return NextResponse.json( + { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, + { status: 503 } + ); + } + if (err instanceof spotify.TokenInvalidError) { + return NextResponse.json( + { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, + { status: 401 } + ); + } + return null; + }); }); // ─── Tests ─────────────────────────────────────────────────────────────────── diff --git a/src/app/api/playlists/[playlistId]/join/route.ts b/src/app/api/playlists/[playlistId]/join/route.ts index ef2e63f..33496c3 100644 --- a/src/app/api/playlists/[playlistId]/join/route.ts +++ b/src/app/api/playlists/[playlistId]/join/route.ts @@ -3,43 +3,13 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers, playlistTracks, circleMembers } from '@/db/schema'; import { eq, and, isNull, isNotNull } from 'drizzle-orm'; -import { - followPlaylist, - updatePlaylistDetails, - TokenInvalidError, - AppInvalidError, - SpotifyRateLimitError, -} from '@/lib/spotify'; +import { followPlaylist, updatePlaylistDetails } from '@/lib/spotify'; +import { isRateLimited } from '@/lib/spotify-core'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { generateId, formatPlaylistName, getFirstName } from '@/lib/utils'; // ─── Helpers ──────────────────────────────────────────────────────────────── -/** Convert known Spotify errors into API error responses. Rethrows unknown errors. */ -function handleSpotifyError(err: unknown): NextResponse { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - throw err; -} - /** Verify join authorization via invite code or circle membership. Returns error response or null. */ async function verifyJoinAuthorization( circleJoin: boolean, @@ -79,7 +49,9 @@ async function updateAutoGeneratedName( name: newName, }); } catch (err) { - return handleSpotifyError(err); + const errorResponse = handleSpotifyError(err); + if (errorResponse) return errorResponse; + throw err; } await db.update(playlists).set({ name: newName }).where(eq(playlists.id, playlistId)); return null; @@ -106,6 +78,11 @@ export async function POST( return NextResponse.json({ error: 'Playlist not found' }, { status: 404 }); } + // Joining requires Spotify calls (follow) — fail fast if rate-limited + if (isRateLimited()) { + return NextResponse.json({ error: 'Spotify rate-limited', rateLimited: true }, { status: 429 }); + } + // Authorization: invite code OR circle membership const authError = await verifyJoinAuthorization( circleJoin, @@ -133,7 +110,9 @@ export async function POST( try { await followPlaylist(user.id, playlist.circleId, playlist.spotifyPlaylistId); } catch (err) { - return handleSpotifyError(err); + const errorResponse = handleSpotifyError(err); + if (errorResponse) return errorResponse; + throw err; } // Reset completedAt on pending tracks — new member hasn't listened yet diff --git a/src/app/api/playlists/[playlistId]/liked-playlist/__tests__/route.test.ts b/src/app/api/playlists/[playlistId]/liked-playlist/__tests__/route.test.ts index 234c68d..383ae78 100644 --- a/src/app/api/playlists/[playlistId]/liked-playlist/__tests__/route.test.ts +++ b/src/app/api/playlists/[playlistId]/liked-playlist/__tests__/route.test.ts @@ -29,32 +29,69 @@ vi.mock('@/db/schema', () => ({ vi.mock('drizzle-orm', () => ({ eq: vi.fn(), and: vi.fn(), + isNull: vi.fn(), })); +// Shared mock error classes — must be the same instances used in both mocks +// so that `instanceof` checks in handleSpotifyError work correctly. +class MockTokenInvalidError extends Error { + constructor(...args: unknown[]) { + super(String(args[0] ?? '')); + this.name = 'TokenInvalidError'; + } +} +class MockAppInvalidError extends Error { + constructor(...args: unknown[]) { + super(String(args[0] ?? '')); + this.name = 'AppInvalidError'; + } +} +class MockSpotifyRateLimitError extends Error { + constructor(...args: unknown[]) { + super(String(args[0] ?? '')); + this.name = 'SpotifyRateLimitError'; + } +} + vi.mock('@/lib/spotify', () => ({ createPlaylist: vi.fn(), addItemsToPlaylist: vi.fn(), getPlaylistDetails: vi.fn(), - TokenInvalidError: class TokenInvalidError extends Error { - constructor(m = '') { - super(m); - this.name = 'TokenInvalidError'; - } - }, - AppInvalidError: class AppInvalidError extends Error { - constructor(m = '') { - super(m); - this.name = 'AppInvalidError'; - } - }, - SpotifyRateLimitError: class SpotifyRateLimitError extends Error { - constructor(m = '') { - super(m); - this.name = 'SpotifyRateLimitError'; - } - }, + TokenInvalidError: MockTokenInvalidError, + AppInvalidError: MockAppInvalidError, + SpotifyRateLimitError: MockSpotifyRateLimitError, })); +vi.mock('@/lib/spotify-errors', async () => { + const { NextResponse } = await import('next/server'); + return { + handleSpotifyError: (err: unknown) => { + if (err instanceof MockSpotifyRateLimitError) { + return NextResponse.json( + { + error: 'Spotify is a bit busy right now. Please try again in a minute.', + rateLimited: true, + }, + { status: 429 } + ); + } + if (err instanceof MockAppInvalidError) { + return NextResponse.json( + { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, + { status: 503 } + ); + } + if (err instanceof MockTokenInvalidError) { + return NextResponse.json( + { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, + { status: 401 } + ); + } + return null; + }, + }; +}); + vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); @@ -73,6 +110,8 @@ const MOCK_MEMBERSHIP = { playlistId: 'playlist-1', userId: 'user-1', likedPlaylistId: null as string | null, + likedSyncMode: null as string | null, + likedPlaylistName: null as string | null, }; const MOCK_PLAYLIST = { @@ -84,10 +123,18 @@ const MOCK_PLAYLIST = { const ASYNC_PARAMS = { params: Promise.resolve({ playlistId: 'playlist-1' }) }; -function makePostRequest() { +function makePostRequest(body?: Record) { return new NextRequest( new URL('https://test.swapify.app/api/playlists/playlist-1/liked-playlist'), - { method: 'POST' } + { + method: 'POST', + ...(body + ? { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + : {}), + } ); } @@ -104,7 +151,7 @@ beforeEach(() => { vi.clearAllMocks(); }); -// ─── POST tests ───────────────────────────────────────────────────────────── +// ─── POST tests (created mode) ───────────────────────────────────────────── describe('POST /api/playlists/[playlistId]/liked-playlist', () => { it('returns 403 when user is not a member', async () => { @@ -148,6 +195,7 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ ...MOCK_MEMBERSHIP, likedPlaylistId: 'existing-liked-sp-id', + likedSyncMode: 'created', } as any); vi.mocked(db.query.playlists.findFirst).mockResolvedValue(MOCK_PLAYLIST as any); @@ -161,12 +209,13 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { const data = await response!.json(); expect(data.spotifyPlaylistId).toBe('existing-liked-sp-id'); expect(data.spotifyPlaylistUrl).toBe('https://open.spotify.com/playlist/existing-liked-sp-id'); + expect(data.mode).toBe('created'); // Should not have called createPlaylist const { createPlaylist } = await import('@/lib/spotify'); expect(createPlaylist).not.toHaveBeenCalled(); }); - it('creates new liked playlist when no existing one', async () => { + it('creates new liked playlist when no existing one (created mode)', async () => { const { requireAuth } = await import('@/lib/auth'); vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); @@ -178,19 +227,22 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { vi.mocked(db.query.playlists.findFirst).mockResolvedValue(MOCK_PLAYLIST as any); vi.mocked(db.query.trackReactions.findMany).mockResolvedValue([]); - const mockUpdate = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })); + const mockUpdate = vi.fn(() => ({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(() => [{ id: 'pm-1' }]) })) })), + })); vi.mocked(db.update).mockImplementation(mockUpdate as any); const { createPlaylist } = await import('@/lib/spotify'); vi.mocked(createPlaylist).mockResolvedValue({ id: 'new-liked-sp-id' } as any); const { POST } = await import('../route'); - const response = await POST(makePostRequest(), ASYNC_PARAMS); + const response = await POST(makePostRequest({ mode: 'created' }), ASYNC_PARAMS); expect(response!.status).toBe(200); const data = await response!.json(); expect(data.spotifyPlaylistId).toBe('new-liked-sp-id'); - expect(data.spotifyPlaylistUrl).toBe('https://open.spotify.com/playlist/new-liked-sp-id'); + expect(data.mode).toBe('created'); + expect(data.playlistName).toBe('Test Playlist Likes'); expect(createPlaylist).toHaveBeenCalledWith( 'user-1', @@ -201,6 +253,35 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { ); }); + it('creates liked playlist with empty body (backward compat)', async () => { + const { requireAuth } = await import('@/lib/auth'); + vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); + + const { db } = await import('@/db'); + vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ + ...MOCK_MEMBERSHIP, + } as any); + vi.mocked(db.query.playlists.findFirst).mockResolvedValue(MOCK_PLAYLIST as any); + vi.mocked(db.query.trackReactions.findMany).mockResolvedValue([]); + + const mockUpdate = vi.fn(() => ({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(() => [{ id: 'pm-1' }]) })) })), + })); + vi.mocked(db.update).mockImplementation(mockUpdate as any); + + const { createPlaylist } = await import('@/lib/spotify'); + vi.mocked(createPlaylist).mockResolvedValue({ id: 'new-sp-id' } as any); + + const { POST } = await import('../route'); + // No body at all — should default to 'created' mode + const response = await POST(makePostRequest(), ASYNC_PARAMS); + + expect(response!.status).toBe(200); + const data = await response!.json(); + expect(data.mode).toBe('created'); + expect(createPlaylist).toHaveBeenCalled(); + }); + it('populates liked playlist with thumbs_up tracks', async () => { const { requireAuth } = await import('@/lib/auth'); vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); @@ -247,7 +328,9 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { }, ] as any); - const mockUpdate = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })); + const mockUpdate = vi.fn(() => ({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(() => [{ id: 'pm-1' }]) })) })), + })); vi.mocked(db.update).mockImplementation(mockUpdate as any); const { createPlaylist, addItemsToPlaylist } = await import('@/lib/spotify'); @@ -345,11 +428,14 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ ...MOCK_MEMBERSHIP, likedPlaylistId: 'deleted-sp-id', + likedSyncMode: 'created', } as any); vi.mocked(db.query.playlists.findFirst).mockResolvedValue(MOCK_PLAYLIST as any); vi.mocked(db.query.trackReactions.findMany).mockResolvedValue([]); - const mockUpdate = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })); + const mockUpdate = vi.fn(() => ({ + set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(() => [{ id: 'pm-1' }]) })) })), + })); vi.mocked(db.update).mockImplementation(mockUpdate as any); const { getPlaylistDetails, createPlaylist } = await import('@/lib/spotify'); @@ -380,6 +466,151 @@ describe('POST /api/playlists/[playlistId]/liked-playlist', () => { }); }); +// ─── POST tests (funnel mode) ────────────────────────────────────────────── + +describe('POST /api/playlists/[playlistId]/liked-playlist (funnel mode)', () => { + it('returns 400 when funnelPlaylistId is missing', async () => { + const { requireAuth } = await import('@/lib/auth'); + vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); + + const { db } = await import('@/db'); + vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ + ...MOCK_MEMBERSHIP, + } as any); + vi.mocked(db.query.playlists.findFirst).mockResolvedValue(MOCK_PLAYLIST as any); + + const { POST } = await import('../route'); + const response = await POST(makePostRequest({ mode: 'funnel' }), ASYNC_PARAMS); + + expect(response!.status).toBe(400); + const data = await response!.json(); + expect(data.error).toContain('funnelPlaylistId is required'); + }); + + it('returns 400 when destination is a Swaplist (loop prevention)', async () => { + const { requireAuth } = await import('@/lib/auth'); + vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); + + const { db } = await import('@/db'); + vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ + ...MOCK_MEMBERSHIP, + } as any); + // First findFirst: playlist lookup for current playlist + // Second findFirst: isSwaplist check — returns a match (it IS a Swaplist) + vi.mocked(db.query.playlists.findFirst) + .mockResolvedValueOnce(MOCK_PLAYLIST as any) // current playlist + .mockResolvedValueOnce({ id: 'other-swaplist', spotifyPlaylistId: 'target-sp-id' } as any); // isSwaplist check + + const { POST } = await import('../route'); + const response = await POST( + makePostRequest({ + mode: 'funnel', + funnelPlaylistId: 'target-sp-id', + funnelPlaylistName: 'My Other Swaplist', + }), + ASYNC_PARAMS + ); + + expect(response!.status).toBe(400); + const data = await response!.json(); + expect(data.error).toContain('Swaplist'); + }); + + it('sets up funnel to a valid destination playlist', async () => { + const { requireAuth } = await import('@/lib/auth'); + vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); + + const { db } = await import('@/db'); + vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ + ...MOCK_MEMBERSHIP, + } as any); + // First findFirst: current playlist; Second: isSwaplist check (not a Swaplist) + vi.mocked(db.query.playlists.findFirst) + .mockResolvedValueOnce(MOCK_PLAYLIST as any) + .mockResolvedValueOnce(undefined); // not a Swaplist + + vi.mocked(db.query.trackReactions.findMany).mockResolvedValue([]); + + const mockUpdate = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })); + vi.mocked(db.update).mockImplementation(mockUpdate as any); + + const { getPlaylistDetails } = await import('@/lib/spotify'); + vi.mocked(getPlaylistDetails).mockResolvedValue({ id: 'funnel-target-sp-id' } as any); + + const { POST } = await import('../route'); + const response = await POST( + makePostRequest({ + mode: 'funnel', + funnelPlaylistId: 'funnel-target-sp-id', + funnelPlaylistName: 'My Chill Playlist', + }), + ASYNC_PARAMS + ); + + expect(response!.status).toBe(200); + const data = await response!.json(); + expect(data.spotifyPlaylistId).toBe('funnel-target-sp-id'); + expect(data.mode).toBe('funnel'); + expect(data.playlistName).toBe('My Chill Playlist'); + + // Should have verified access to the destination + expect(getPlaylistDetails).toHaveBeenCalledWith('user-1', 'circle-1', 'funnel-target-sp-id'); + // Should have updated the membership + expect(db.update).toHaveBeenCalled(); + }); + + it('populates funnel destination with existing liked tracks', async () => { + const { requireAuth } = await import('@/lib/auth'); + vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); + + const { db } = await import('@/db'); + vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ + ...MOCK_MEMBERSHIP, + } as any); + vi.mocked(db.query.playlists.findFirst) + .mockResolvedValueOnce(MOCK_PLAYLIST as any) + .mockResolvedValueOnce(undefined); // not a Swaplist + + vi.mocked(db.query.trackReactions.findMany).mockResolvedValue([ + { + spotifyTrackId: 'track-a', + playlistId: 'playlist-1', + userId: 'user-1', + reaction: 'thumbs_up', + }, + ] as any); + vi.mocked(db.query.playlistTracks.findMany).mockResolvedValue([ + { + spotifyTrackId: 'track-a', + spotifyTrackUri: 'spotify:track:track-a', + playlistId: 'playlist-1', + }, + ] as any); + + const mockUpdate = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn() })) })); + vi.mocked(db.update).mockImplementation(mockUpdate as any); + + const { getPlaylistDetails, addItemsToPlaylist } = await import('@/lib/spotify'); + vi.mocked(getPlaylistDetails).mockResolvedValue({ id: 'funnel-target' } as any); + vi.mocked(addItemsToPlaylist).mockResolvedValue(undefined as any); + + const { POST } = await import('../route'); + const response = await POST( + makePostRequest({ + mode: 'funnel', + funnelPlaylistId: 'funnel-target', + funnelPlaylistName: 'Destination', + }), + ASYNC_PARAMS + ); + + expect(response!.status).toBe(200); + expect(addItemsToPlaylist).toHaveBeenCalledWith('user-1', 'circle-1', 'funnel-target', [ + 'spotify:track:track-a', + ]); + }); +}); + // ─── DELETE tests ─────────────────────────────────────────────────────────── describe('DELETE /api/playlists/[playlistId]/liked-playlist', () => { @@ -398,14 +629,16 @@ describe('DELETE /api/playlists/[playlistId]/liked-playlist', () => { expect(data.error).toBe('Not a member'); }); - it('successfully clears liked playlist ID', async () => { + it('clears all sync columns (likedPlaylistId, likedSyncMode, likedPlaylistName)', async () => { const { requireAuth } = await import('@/lib/auth'); vi.mocked(requireAuth).mockResolvedValue(MOCK_USER as any); const { db } = await import('@/db'); vi.mocked(db.query.playlistMembers.findFirst).mockResolvedValue({ ...MOCK_MEMBERSHIP, - likedPlaylistId: 'some-liked-sp-id', + likedPlaylistId: 'some-sp-id', + likedSyncMode: 'funnel', + likedPlaylistName: 'My Playlist', } as any); const mockWhere = vi.fn(); @@ -420,8 +653,12 @@ describe('DELETE /api/playlists/[playlistId]/liked-playlist', () => { const data = await response!.json(); expect(data.success).toBe(true); - // Verify db.update was called to clear likedPlaylistId + // Verify all three columns are cleared expect(db.update).toHaveBeenCalled(); - expect(mockSet).toHaveBeenCalledWith({ likedPlaylistId: null }); + expect(mockSet).toHaveBeenCalledWith({ + likedPlaylistId: null, + likedSyncMode: null, + likedPlaylistName: null, + }); }); }); diff --git a/src/app/api/playlists/[playlistId]/liked-playlist/route.ts b/src/app/api/playlists/[playlistId]/liked-playlist/route.ts index 2382576..3d2ba6b 100644 --- a/src/app/api/playlists/[playlistId]/liked-playlist/route.ts +++ b/src/app/api/playlists/[playlistId]/liked-playlist/route.ts @@ -2,41 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers, playlistTracks, trackReactions } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; -import { - createPlaylist, - addItemsToPlaylist, - getPlaylistDetails, - TokenInvalidError, - AppInvalidError, - SpotifyRateLimitError, -} from '@/lib/spotify'; - -/** Map known Spotify errors to JSON responses; returns null for unknown errors. */ -function handleSpotifyError(err: unknown): NextResponse | null { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - return null; -} +import { eq, and, isNull } from 'drizzle-orm'; +import { createPlaylist, addItemsToPlaylist, getPlaylistDetails } from '@/lib/spotify'; +import { handleSpotifyError } from '@/lib/spotify-errors'; /** Verify that the user is a playlist member; returns membership or an error response. */ async function requireMembership(playlistId: string, userId: string) { @@ -62,12 +30,14 @@ async function checkExistingLikedPlaylist( return NextResponse.json({ spotifyPlaylistId: membership.likedPlaylistId, spotifyPlaylistUrl: `https://open.spotify.com/playlist/${membership.likedPlaylistId}`, + mode: membership.likedSyncMode ?? 'created', + playlistName: membership.likedPlaylistName, }); } catch { // Playlist was deleted on Spotify — clear and let caller recreate await db .update(playlistMembers) - .set({ likedPlaylistId: null }) + .set({ likedPlaylistId: null, likedSyncMode: null, likedPlaylistName: null }) .where(eq(playlistMembers.id, membership.id)); return null; } @@ -115,9 +85,17 @@ async function populateLikedPlaylist( return null; } -// POST /api/playlists/[playlistId]/liked-playlist — create or sync liked Spotify playlist +/** Check that a Spotify playlist ID is NOT already a managed Swaplist (loop prevention). */ +async function isSwaplist(spotifyPlaylistId: string): Promise { + const match = await db.query.playlists.findFirst({ + where: eq(playlists.spotifyPlaylistId, spotifyPlaylistId), + }); + return !!match; +} + +// POST /api/playlists/[playlistId]/liked-playlist — create/funnel liked Spotify playlist export async function POST( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ playlistId: string }> } ) { const user = await requireAuth(); @@ -138,11 +116,95 @@ export async function POST( const existingResponse = await checkExistingLikedPlaylist(membership, user.id, playlist.circleId); if (existingResponse) return existingResponse; - // Create new Spotify playlist under this user's account + // Parse body to determine mode + const body = await request.json().catch(() => ({})); + const mode = (body.mode as string) || 'created'; + + if (mode === 'funnel') { + return handleFunnelMode(body, user.id, playlist, membership, playlistId); + } + return handleCreatedMode(user.id, playlist, membership, playlistId); +} + +/** Handle "funnel" mode: route liked tracks to an existing Spotify playlist. */ +async function handleFunnelMode( + body: Record, + userId: string, + playlist: typeof playlists.$inferSelect, + membership: typeof playlistMembers.$inferSelect, + playlistId: string +) { + const funnelPlaylistId = body.funnelPlaylistId as string | undefined; + const funnelPlaylistName = body.funnelPlaylistName as string | undefined; + + if (!funnelPlaylistId) { + return NextResponse.json( + { error: 'funnelPlaylistId is required for funnel mode' }, + { status: 400 } + ); + } + + // Loop prevention: destination must not be any Swaplist + if (await isSwaplist(funnelPlaylistId)) { + return NextResponse.json( + { error: 'Cannot funnel to a Swaplist — this would create a sync loop' }, + { status: 400 } + ); + } + + // Verify the user can access the destination playlist + try { + await getPlaylistDetails(userId, playlist.circleId, funnelPlaylistId); + } catch (err) { + const errorResponse = handleSpotifyError(err); + if (errorResponse) return errorResponse; + return NextResponse.json( + { error: 'Cannot access this playlist. Make sure you own it or it is collaborative.' }, + { status: 400 } + ); + } + + // Store on membership + await db + .update(playlistMembers) + .set({ + likedPlaylistId: funnelPlaylistId, + likedSyncMode: 'funnel', + likedPlaylistName: funnelPlaylistName || null, + }) + .where(eq(playlistMembers.id, membership.id)); + + // Initial population: add liked tracks to the destination (additive only) + const uris = await getLikedTrackUris(playlistId, userId); + if (uris.length > 0) { + const populateError = await populateLikedPlaylist( + userId, + playlist.circleId, + funnelPlaylistId, + uris + ); + if (populateError) return populateError; + } + + return NextResponse.json({ + spotifyPlaylistId: funnelPlaylistId, + spotifyPlaylistUrl: `https://open.spotify.com/playlist/${funnelPlaylistId}`, + mode: 'funnel', + playlistName: funnelPlaylistName, + }); +} + +/** Handle "created" mode: create a new dedicated Spotify playlist for likes. */ +async function handleCreatedMode( + userId: string, + playlist: typeof playlists.$inferSelect, + membership: typeof playlistMembers.$inferSelect, + playlistId: string +) { let spotifyPlaylist; try { spotifyPlaylist = await createPlaylist( - user.id, + userId, playlist.circleId, `${playlist.name} Likes`, `Tracks I liked from ${playlist.name} on Swapify`, @@ -154,17 +216,40 @@ export async function POST( throw err; } - // Store on membership - await db + const playlistName = `${playlist.name} Likes`; + + // Store on membership — use conditional update to prevent race condition: + // only set likedPlaylistId if it's still NULL (another request didn't win first) + const updateResult = await db .update(playlistMembers) - .set({ likedPlaylistId: spotifyPlaylist.id }) - .where(eq(playlistMembers.id, membership.id)); + .set({ + likedPlaylistId: spotifyPlaylist.id, + likedSyncMode: 'created', + likedPlaylistName: playlistName, + }) + .where(and(eq(playlistMembers.id, membership.id), isNull(playlistMembers.likedPlaylistId))) + .returning({ id: playlistMembers.id }); + + if (updateResult.length === 0) { + // Another concurrent request already created a liked playlist — return it + const refreshed = await db.query.playlistMembers.findFirst({ + where: eq(playlistMembers.id, membership.id), + }); + if (refreshed?.likedPlaylistId) { + return NextResponse.json({ + spotifyPlaylistId: refreshed.likedPlaylistId, + spotifyPlaylistUrl: `https://open.spotify.com/playlist/${refreshed.likedPlaylistId}`, + mode: refreshed.likedSyncMode ?? 'created', + playlistName: refreshed.likedPlaylistName, + }); + } + } // Initial population: get all liked track URIs and add them - const uris = await getLikedTrackUris(playlistId, user.id); + const uris = await getLikedTrackUris(playlistId, userId); if (uris.length > 0) { const populateError = await populateLikedPlaylist( - user.id, + userId, playlist.circleId, spotifyPlaylist.id, uris @@ -175,6 +260,8 @@ export async function POST( return NextResponse.json({ spotifyPlaylistId: spotifyPlaylist.id, spotifyPlaylistUrl: `https://open.spotify.com/playlist/${spotifyPlaylist.id}`, + mode: 'created', + playlistName, }); } @@ -192,7 +279,7 @@ export async function DELETE( await db .update(playlistMembers) - .set({ likedPlaylistId: null }) + .set({ likedPlaylistId: null, likedSyncMode: null, likedPlaylistName: null }) .where(eq(playlistMembers.id, membership.id)); return NextResponse.json({ success: true }); diff --git a/src/app/api/playlists/[playlistId]/route.ts b/src/app/api/playlists/[playlistId]/route.ts index 177aa9e..6086409 100644 --- a/src/app/api/playlists/[playlistId]/route.ts +++ b/src/app/api/playlists/[playlistId]/route.ts @@ -3,42 +3,11 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers } from '@/db/schema'; import { eq, and } from 'drizzle-orm'; -import { - updatePlaylistDetails, - uploadPlaylistImage, - getPlaylistDetails, - TokenInvalidError, - AppInvalidError, - SpotifyRateLimitError, -} from '@/lib/spotify'; +import { updatePlaylistDetails, uploadPlaylistImage, getPlaylistDetails } from '@/lib/spotify'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { VALID_REMOVAL_DELAYS, SORT_MODES, type SortMode, type RemovalDelay } from '@/lib/utils'; import { buildSpotifyDescription } from '@/lib/vibe-name'; - -/** Map known Spotify errors to JSON responses; returns null for unknown errors. */ -function handleSpotifyError(err: unknown): NextResponse | null { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - return null; -} +import { logger } from '@/lib/logger'; /** Fetch the playlist and verify the requesting user is the owner. */ async function requirePlaylistOwner(playlistId: string, userId: string) { @@ -274,7 +243,9 @@ export async function PATCH( // Re-sort tracks if sort mode changed (fire-and-forget) if (body.sortMode !== undefined && body.sortMode !== playlist.sortMode) { import('@/lib/playlist-sort').then(({ sortPlaylistTracks }) => { - sortPlaylistTracks(playlistId).catch(() => {}); + sortPlaylistTracks(playlistId).catch((err) => { + logger.error({ err }, 'Failed to re-sort playlist tracks after sort mode change'); + }); }); } diff --git a/src/app/api/playlists/[playlistId]/tracks/route.test.ts b/src/app/api/playlists/[playlistId]/tracks/route.test.ts index 3fb83f2..7395994 100644 --- a/src/app/api/playlists/[playlistId]/tracks/route.test.ts +++ b/src/app/api/playlists/[playlistId]/tracks/route.test.ts @@ -56,6 +56,34 @@ vi.mock('@/lib/spotify', () => ({ }, })); +vi.mock('@/lib/spotify-errors', async () => { + const { NextResponse } = await import('next/server'); + const spotify = await import('@/lib/spotify'); + return { + handleSpotifyError: (err: unknown) => { + if (err instanceof spotify.SpotifyRateLimitError) { + return NextResponse.json( + { error: 'Spotify is a bit busy right now.', rateLimited: true }, + { status: 429 } + ); + } + if (err instanceof spotify.AppInvalidError) { + return NextResponse.json( + { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, + { status: 503 } + ); + } + if (err instanceof spotify.TokenInvalidError) { + return NextResponse.json( + { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, + { status: 401 } + ); + } + return null; + }, + }; +}); + vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn(() => null), RATE_LIMITS: { mutation: { maxTokens: 20, refillRate: 0.33 } }, diff --git a/src/app/api/playlists/[playlistId]/tracks/route.ts b/src/app/api/playlists/[playlistId]/tracks/route.ts index 5c38a4b..59fc58b 100644 --- a/src/app/api/playlists/[playlistId]/tracks/route.ts +++ b/src/app/api/playlists/[playlistId]/tracks/route.ts @@ -14,43 +14,18 @@ import { addItemsToPlaylist, getPlaylistItems, checkSavedTracks, - TokenInvalidError, AppInvalidError, - SpotifyRateLimitError, + TokenInvalidError, } from '@/lib/spotify'; import { isCircleRateLimited } from '@/lib/spotify-budget'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { generateId } from '@/lib/utils'; +import { logger } from '@/lib/logger'; // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- -/** Map known Spotify errors to JSON responses; returns null for unknown errors. */ -function handleSpotifyError(err: unknown): NextResponse | null { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - return null; -} - interface MemberInfo { id: string; displayName: string; @@ -94,21 +69,19 @@ async function sortTracksBySpotifyOrder( return posA - posB; }); } catch (err) { - const appError = err instanceof AppInvalidError; - const tokenError = err instanceof TokenInvalidError; - if (appError) { + if (err instanceof AppInvalidError) { return NextResponse.json( { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, { status: 503 } ); } - if (tokenError) { + if (err instanceof TokenInvalidError) { return NextResponse.json( { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, { status: 401 } ); } - // Fall back to addedAt order (including rate limits) + // Fall back to addedAt order for other errors (including rate limits) return [...dbTracks].sort( (a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime() ); @@ -478,6 +451,8 @@ export async function GET( likedTracks, outcastTracks, likedPlaylistId: membership.likedPlaylistId ?? null, + likedSyncMode: membership.likedSyncMode ?? null, + likedPlaylistName: membership.likedPlaylistName ?? null, vibeName: playlist?.vibeName ?? null, }); } @@ -586,7 +561,9 @@ export async function POST( // Auto-sort playlist tracks according to configured sort mode (fire-and-forget) import('@/lib/playlist-sort').then(({ sortPlaylistTracks }) => { - sortPlaylistTracks(playlistId).catch(() => {}); + sortPlaylistTracks(playlistId).catch((err) => { + logger.error({ err }, 'Failed to auto-sort playlist tracks after adding track'); + }); }); // Auto-like: check if other members already have this track saved in their library diff --git a/src/app/api/playlists/[playlistId]/tracks/sync/route.ts b/src/app/api/playlists/[playlistId]/tracks/sync/route.ts index f2eba12..b374866 100644 --- a/src/app/api/playlists/[playlistId]/tracks/sync/route.ts +++ b/src/app/api/playlists/[playlistId]/tracks/sync/route.ts @@ -3,41 +3,11 @@ import { requireAuth } from '@/lib/auth'; import { db } from '@/db'; import { playlists, playlistMembers, playlistTracks } from '@/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; -import { - getPlaylistItems, - getPlaylistDetails, - TokenInvalidError, - AppInvalidError, - SpotifyRateLimitError, -} from '@/lib/spotify'; +import { getPlaylistItems, getPlaylistDetails } from '@/lib/spotify'; import { isCircleRateLimited } from '@/lib/spotify-budget'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { generateId } from '@/lib/utils'; - -/** Map a Spotify error to an appropriate JSON response, or return null to re-throw. */ -function handleSpotifyError(err: unknown): NextResponse | null { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - return null; -} +import { logger } from '@/lib/logger'; /** Compute metadata diff between Spotify details and local playlist record. */ function buildMetadataDiff( @@ -178,7 +148,9 @@ export async function POST( // Auto-sort playlist tracks if tracks changed (fire-and-forget) if (added > 0 || removed > 0) { import('@/lib/playlist-sort').then(({ sortPlaylistTracks }) => { - sortPlaylistTracks(playlistId).catch(() => {}); + sortPlaylistTracks(playlistId).catch((err) => { + logger.error({ err }, 'Failed to auto-sort playlist tracks after sync'); + }); }); } diff --git a/src/app/api/playlists/__tests__/route.test.ts b/src/app/api/playlists/__tests__/route.test.ts index 1fac0b9..a75a3bb 100644 --- a/src/app/api/playlists/__tests__/route.test.ts +++ b/src/app/api/playlists/__tests__/route.test.ts @@ -25,11 +25,15 @@ vi.mock('@/db', () => ({ playlistTracks: { findFirst: vi.fn(), findMany: vi.fn() }, trackListens: { findMany: vi.fn() }, trackReactions: { findMany: vi.fn() }, - circleMembers: { findMany: vi.fn() }, + circleMembers: { findFirst: vi.fn(), findMany: vi.fn() }, }, insert: vi.fn(() => ({ values: vi.fn() })), select: vi.fn(() => ({ - from: vi.fn(() => ({ where: vi.fn().mockResolvedValue([{ count: 0 }]) })), + from: vi.fn(() => ({ + where: vi.fn(() => + Object.assign(Promise.resolve([]), { groupBy: vi.fn().mockResolvedValue([]) }) + ), + })), })), }, })); @@ -43,14 +47,18 @@ vi.mock('@/db/schema', () => ({ circleMembers: {}, })); -vi.mock('drizzle-orm', () => ({ - eq: vi.fn(), - and: vi.fn(), - desc: vi.fn(), - isNull: vi.fn(), - isNotNull: vi.fn(), - sql: vi.fn(), -})); +vi.mock('drizzle-orm', () => { + const asMethod = { as: vi.fn().mockReturnThis() }; + return { + eq: vi.fn(), + and: vi.fn(), + desc: vi.fn(), + isNull: vi.fn(), + isNotNull: vi.fn(), + sql: Object.assign(vi.fn().mockReturnValue(asMethod), asMethod), + max: vi.fn().mockReturnValue({ as: vi.fn() }), + }; +}); // Mock Spotify vi.mock('@/lib/spotify', () => ({ @@ -79,6 +87,10 @@ vi.mock('@/lib/notifications', () => ({ })); vi.mock('@/lib/playlist-sort', () => ({ sortPlaylistTracks: vi.fn() })); vi.mock('@/lib/polling', () => ({ setAutoReaction: vi.fn() })); +vi.mock('@/lib/spotify-budget', () => ({ + isCircleRateLimited: vi.fn().mockReturnValue(false), +})); + vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, })); @@ -105,6 +117,11 @@ describe('POST /api/playlists', () => { vi.mocked(requireAuth).mockResolvedValue(mockUser as any); vi.mocked(getSession).mockResolvedValue({ activeCircleId: 'circle-1' } as any); vi.mocked(checkRateLimit).mockReturnValue(null); + vi.mocked(db.query.circleMembers.findFirst).mockResolvedValue({ + id: 'cm-1', + circleId: 'circle-1', + userId: 'user-1', + } as any); }); it('returns 400 when no active circle is selected', async () => { @@ -214,6 +231,7 @@ describe('GET /api/playlists', () => { // Mock memberships query vi.mocked(db.query.playlistMembers.findMany).mockResolvedValue([ { + playlistId: 'playlist-1', playlist: { id: 'playlist-1', name: 'Test Playlist', @@ -265,6 +283,41 @@ describe('GET /api/playlists', () => { { playlistId: 'playlist-1', spotifyTrackId: 'track-1', userId: 'user-1' }, ] as any); + // Mock active tracks query (used for unplayed count calculation) + vi.mocked(db.query.playlistTracks.findMany).mockResolvedValue([ + { + playlistId: 'playlist-1', + spotifyTrackId: 'track-1', + addedByUserId: 'user-2', + addedAt: new Date('2025-01-02T00:00:00Z'), + removedAt: null, + }, + { + playlistId: 'playlist-1', + spotifyTrackId: 'track-2', + addedByUserId: 'user-1', + addedAt: new Date('2025-01-03T00:00:00Z'), + removedAt: null, + }, + ] as any); + + // Mock aggregation query (activeTrackCount + lastTrackDate per playlist) + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn(() => + Object.assign(Promise.resolve([]), { + groupBy: vi.fn().mockResolvedValue([ + { + playlistId: 'playlist-1', + activeTrackCount: 2, + lastTrackDate: new Date('2025-01-03T00:00:00Z'), + }, + ]), + }) + ), + }), + } as any); + const response = await GET(); expect(response.status).toBe(200); diff --git a/src/app/api/playlists/route.ts b/src/app/api/playlists/route.ts index c435be3..0091853 100644 --- a/src/app/api/playlists/route.ts +++ b/src/app/api/playlists/route.ts @@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, getSession } from '@/lib/auth'; import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; import { db } from '@/db'; -import { playlists, playlistMembers, trackListens, playlistTracks } from '@/db/schema'; -import { eq, desc, and } from 'drizzle-orm'; +import { + playlists, + playlistMembers, + trackListens, + playlistTracks, + circleMembers, +} from '@/db/schema'; +import { eq, desc, and, isNull, sql, max } from 'drizzle-orm'; import { createPlaylist, uploadPlaylistImage, @@ -11,11 +17,9 @@ import { updatePlaylistDetails, getPlaylistItems, addItemsToPlaylist, - TokenInvalidError, - AppInvalidError, - SpotifyRateLimitError, } from '@/lib/spotify'; import { isCircleRateLimited } from '@/lib/spotify-budget'; +import { handleSpotifyError } from '@/lib/spotify-errors'; import { generateId, generateInviteCode, getFirstName, formatPlaylistName } from '@/lib/utils'; import { logger } from '@/lib/logger'; @@ -23,32 +27,6 @@ import { logger } from '@/lib/logger'; // Shared helpers // --------------------------------------------------------------------------- -/** Map a Spotify error to an appropriate JSON response, or return null to re-throw. */ -function handleSpotifyError(err: unknown): NextResponse | null { - if (err instanceof SpotifyRateLimitError) { - return NextResponse.json( - { - error: 'Spotify is a bit busy right now. Please try again in a minute.', - rateLimited: true, - }, - { status: 429 } - ); - } - if (err instanceof AppInvalidError) { - return NextResponse.json( - { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, - { status: 503 } - ); - } - if (err instanceof TokenInvalidError) { - return NextResponse.json( - { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, - { status: 401 } - ); - } - return null; -} - /** Fire-and-forget notification to circle members about a new swaplist. */ function notifyCircleAboutNewSwaplist( circleId: string, @@ -227,12 +205,9 @@ async function handleImportPlaylist( ); } - // Check not already imported in this circle + // Check not already imported in ANY circle (prevents cross-circle Swaplist linking) const alreadyImported = await db.query.playlists.findFirst({ - where: and( - eq(playlists.spotifyPlaylistId, importSpotifyPlaylistId), - eq(playlists.circleId, circleId) - ), + where: eq(playlists.spotifyPlaylistId, importSpotifyPlaylistId), }); if (alreadyImported) { return NextResponse.json({ error: 'This playlist is already a Swaplist' }, { status: 409 }); @@ -345,7 +320,7 @@ async function handleCreatePlaylist( export async function GET() { const user = await requireAuth(); - const [memberships, userListens] = await Promise.all([ + const [memberships, userListens, trackAggregates] = await Promise.all([ db.query.playlistMembers.findMany({ where: eq(playlistMembers.userId, user.id), with: { @@ -353,7 +328,6 @@ export async function GET() { with: { owner: true, members: { with: { user: true } }, - tracks: true, }, }, }, @@ -362,27 +336,69 @@ export async function GET() { db.query.trackListens.findMany({ where: eq(trackListens.userId, user.id), }), + // Single aggregation query: compute activeTrackCount and lastTrackDate per playlist + db + .select({ + playlistId: playlistTracks.playlistId, + activeTrackCount: sql`count(*)`.as('active_track_count'), + lastTrackDate: max(playlistTracks.addedAt).as('last_track_date'), + }) + .from(playlistTracks) + .where(isNull(playlistTracks.removedAt)) + .groupBy(playlistTracks.playlistId), ]); + // Build lookup maps for efficient access + const trackStatsMap = new Map( + trackAggregates.map((row) => [ + row.playlistId, + { + activeTrackCount: Number(row.activeTrackCount), + lastTrackDate: row.lastTrackDate ? new Date(row.lastTrackDate) : null, + }, + ]) + ); + const listenedSet = new Set(userListens.map((l) => `${l.playlistId}:${l.spotifyTrackId}`)); + // Compute unplayedCount per playlist: we need the active track details for this. + // Fetch active tracks for all user playlists in a single query. + const userPlaylistIds = memberships.map((m) => m.playlistId); + const allActiveTracks = + userPlaylistIds.length > 0 + ? await db.query.playlistTracks.findMany({ + where: and( + sql`${playlistTracks.playlistId} IN ${userPlaylistIds}`, + isNull(playlistTracks.removedAt) + ), + }) + : []; + + // Group active tracks by playlist for unplayed count calculation + const activeTracksByPlaylist = new Map(); + for (const track of allActiveTracks) { + const existing = activeTracksByPlaylist.get(track.playlistId) ?? []; + existing.push(track); + activeTracksByPlaylist.set(track.playlistId, existing); + } + const result = memberships.map((m) => { - const activeTracks = m.playlist.tracks.filter((t) => !t.removedAt); - const unplayedCount = activeTracks.filter( + const stats = trackStatsMap.get(m.playlist.id); + const activeTrackCount = stats?.activeTrackCount ?? 0; + const lastTrackDate = stats?.lastTrackDate ?? null; + + const playlistActiveTracks = activeTracksByPlaylist.get(m.playlist.id) ?? []; + const unplayedCount = playlistActiveTracks.filter( (t) => t.addedByUserId !== user.id && !listenedSet.has(`${m.playlist.id}:${t.spotifyTrackId}`) ).length; - const lastTrackDate = activeTracks.reduce((latest, t) => { - const d = t.addedAt; - return !latest || d > latest ? d : latest; - }, null); const lastUpdatedAt = (lastTrackDate ?? m.playlist.createdAt).toISOString(); return { ...m.playlist, creatorName: m.playlist.owner?.displayName ?? null, memberCount: m.playlist.members.length, - activeTrackCount: activeTracks.length, + activeTrackCount, unplayedCount, lastUpdatedAt, members: m.playlist.members.map((mem) => ({ @@ -409,6 +425,14 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'No active circle selected' }, { status: 400 }); } + // Verify user is actually a member of the active circle + const circleMembership = await db.query.circleMembers.findFirst({ + where: and(eq(circleMembers.circleId, circleId), eq(circleMembers.userId, user.id)), + }); + if (!circleMembership) { + return NextResponse.json({ error: 'Not a member of this circle' }, { status: 403 }); + } + // Creating/importing a playlist always requires Spotify — fail fast if rate-limited if (isCircleRateLimited(circleId)) { return NextResponse.json( diff --git a/src/app/api/profile/preferences/route.ts b/src/app/api/profile/preferences/route.ts index feffe22..ba7d351 100644 --- a/src/app/api/profile/preferences/route.ts +++ b/src/app/api/profile/preferences/route.ts @@ -26,6 +26,8 @@ function extractToggles(body: Record, updates: Record p.spotifyPlaylistId)); @@ -70,7 +68,7 @@ export async function GET() { ); } const message = err instanceof Error ? err.message : 'Failed to load playlists'; - console.error('[spotify/playlists] Error:', err); + logger.error({ err }, '[spotify/playlists] Error'); return NextResponse.json({ error: message }, { status: 502 }); } } diff --git a/src/app/api/unplayed-tracks/route.ts b/src/app/api/unplayed-tracks/route.ts index 56e13b9..70640e0 100644 --- a/src/app/api/unplayed-tracks/route.ts +++ b/src/app/api/unplayed-tracks/route.ts @@ -1,8 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getCurrentUser, getSession } from '@/lib/auth'; +import { getCurrentUser, getActiveOrDefaultCircleId } from '@/lib/auth'; import { db } from '@/db'; -import { circleMembers } from '@/db/schema'; -import { eq, and, sql } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; type UnplayedTrack = { id: string; @@ -26,24 +25,13 @@ export async function GET(request: NextRequest) { if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); // Resolve circleId: prefer active circle from session, fall back to first membership - const session = await getSession(); - let circleId = session.activeCircleId ?? null; - if (!circleId) { - const firstMembership = await db.query.circleMembers.findFirst({ - where: eq(circleMembers.userId, user.id), - }); - circleId = firstMembership?.circleId ?? null; - } else { - const membership = await db.query.circleMembers.findFirst({ - where: and(eq(circleMembers.userId, user.id), eq(circleMembers.circleId, circleId)), - }); - if (!membership) circleId = null; - } - - if (!circleId) { + const resolved = await getActiveOrDefaultCircleId(user.id); + if (!resolved) { return NextResponse.json({ tracks: [], total: 0, hasMore: false }); } + const { circleId } = resolved; + const rawLimit = parseInt(request.nextUrl.searchParams.get('limit') ?? '5', 10); const limit = Math.min(isNaN(rawLimit) || rawLimit < 1 ? 5 : rawLimit, 100); const rawOffset = parseInt(request.nextUrl.searchParams.get('offset') ?? '0', 10); diff --git a/src/app/dashboard/ActivityFeedSection.tsx b/src/app/dashboard/ActivityFeedSection.tsx new file mode 100644 index 0000000..888ad6f --- /dev/null +++ b/src/app/dashboard/ActivityFeedSection.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { m } from 'motion/react'; +import { ChevronRight, ListMusic, Headphones, UserPlus, Plus, Disc3 } from 'lucide-react'; +import { springs } from '@/lib/motion'; +import ActivityEventCard from '@/components/ActivityEventCard'; +import { groupActivityEvents } from '@/lib/activity-utils'; +import type { ActivityEvent } from '@/lib/activity-utils'; + +/** Number of raw events to fetch per server request. */ +const FETCH_SIZE = 20; +/** Number of grouped rows shown initially. */ +const INITIAL_GROUPS = 3; +/** Number of grouped rows revealed per "load more" click. */ +const MORE_GROUPS = 5; + +interface ActivityFeedSectionProps { + activeCircleId: string | null; + /** Contextual hints so empty-state CTAs adapt to the user's situation. */ + context: { + hasSwaplists: boolean; + totalTracks: number; + totalUnplayed: number; + circleMemberCount: number; + firstPlaylistId: string | null; + }; +} + +export default function ActivityFeedSection({ activeCircleId, context }: ActivityFeedSectionProps) { + const [allFetchedEvents, setAllFetchedEvents] = useState([]); + const [activityLoading, setActivityLoading] = useState(true); + const [fetchOffset, setFetchOffset] = useState(0); + const [hasMoreFromServer, setHasMoreFromServer] = useState(true); + const [visibleGroupCount, setVisibleGroupCount] = useState(INITIAL_GROUPS); + const [loadingMore, setLoadingMore] = useState(false); + + const allGroups = groupActivityEvents(allFetchedEvents); + const visibleGroups = allGroups.slice(0, visibleGroupCount); + const hasMoreActivity = visibleGroupCount < allGroups.length || hasMoreFromServer; + + useEffect(() => { + setActivityLoading(true); + setAllFetchedEvents([]); + setFetchOffset(0); + setHasMoreFromServer(true); + setVisibleGroupCount(INITIAL_GROUPS); + const params = new URLSearchParams({ limit: String(FETCH_SIZE) }); + if (activeCircleId) params.set('circleId', activeCircleId); + fetch(`/api/activity?${params}`) + .then((res) => res.json()) + .then((data) => { + const events: ActivityEvent[] = data.events || []; + setAllFetchedEvents(events); + setFetchOffset(events.length); + setHasMoreFromServer(events.length >= FETCH_SIZE); + setActivityLoading(false); + }) + .catch(() => setActivityLoading(false)); + }, [activeCircleId]); + + async function loadMoreActivity() { + if (loadingMore) return; + const newCount = visibleGroupCount + MORE_GROUPS; + + // Already have enough fetched groups -- just reveal more + if (newCount <= allGroups.length) { + setVisibleGroupCount(newCount); + return; + } + + // No more on server -- reveal what we have + if (!hasMoreFromServer) { + setVisibleGroupCount(newCount); + return; + } + + // Fetch more raw events from server, then reveal + setLoadingMore(true); + const params = new URLSearchParams({ limit: String(FETCH_SIZE), offset: String(fetchOffset) }); + if (activeCircleId) params.set('circleId', activeCircleId); + try { + const res = await fetch(`/api/activity?${params}`); + const data = await res.json(); + const newEvents: ActivityEvent[] = data.events || []; + setAllFetchedEvents((prev) => [...prev, ...newEvents]); + setFetchOffset((prev) => prev + newEvents.length); + setHasMoreFromServer(newEvents.length >= FETCH_SIZE); + setVisibleGroupCount(newCount); + } catch { + toast.error('Failed to load more activity'); + } finally { + setLoadingMore(false); + } + } + + return ( + +
    +
    +

    The Feed

    +

    What your circle's been up to

    +
    + + See all + + +
    + + {activityLoading ? ( +
    + {[0, 1, 2].map((i) => ( +
    +
    +
    +
    +
    +
    +
    + ))} +
    + ) : allFetchedEvents.length === 0 ? ( + + ) : ( +
    + {visibleGroups.map((g, i) => ( + + ))} + {hasMoreActivity && + (() => { + const remaining = Math.max(0, allGroups.length - visibleGroupCount); + const loadCount = + !hasMoreFromServer && remaining < MORE_GROUPS ? remaining : MORE_GROUPS; + return ( + + ); + })()} +
    + )} + + ); +} + +/* ---- Contextual empty-state CTAs ---- */ + +interface CtaRow { + key: string; + href: string; + icon: React.ReactNode; + iconBg: string; + title: string; + subtitle: string; +} + +function getEmptyFeedHeadline(isSolo: boolean, hasSwaplists: boolean): string { + if (isSolo) return 'Your circle is just you for now \u2014 here\u2019s how to liven things up:'; + if (!hasSwaplists) return 'Get started by setting up your first Swaplist:'; + return 'Things are quiet right now. Here\u2019s what to do next:'; +} + +function buildCtaList(context: ActivityFeedSectionProps['context']): CtaRow[] { + const { hasSwaplists, totalTracks, totalUnplayed, circleMemberCount, firstPlaylistId } = context; + const isSolo = circleMemberCount <= 1; + const playlistHref = firstPlaylistId ? `/playlist/${firstPlaylistId}` : '/swaplists'; + const ctas: CtaRow[] = []; + + if (isSolo) { + ctas.push({ + key: 'invite', + href: '/profile', + icon: , + iconBg: 'bg-purple-500/15', + title: 'Invite friends to your circle', + subtitle: 'Activity shows up once others join', + }); + } + + if (!hasSwaplists) { + ctas.push({ + key: 'create', + href: '/swaplists?action=create', + icon: , + iconBg: 'bg-brand/15', + title: 'Create your first Swaplist', + subtitle: 'Start a shared playlist with your circle', + }); + } + + if (hasSwaplists && totalTracks === 0) { + ctas.push({ + key: 'add-tracks', + href: playlistHref, + icon: , + iconBg: 'bg-brand/15', + title: 'Add tracks to a Swaplist', + subtitle: 'Drop some songs for your crew', + }); + } + + if (totalUnplayed > 0) { + ctas.push({ + key: 'listen', + href: playlistHref, + icon: , + iconBg: 'bg-accent-green/15', + title: `${totalUnplayed} unplayed track${totalUnplayed === 1 ? '' : 's'} waiting`, + subtitle: 'Listen and your activity shows up here', + }); + } + + if (hasSwaplists && totalTracks > 0 && totalUnplayed === 0) { + ctas.push({ + key: 'react', + href: playlistHref, + icon: , + iconBg: 'bg-amber-400/15', + title: 'Swipe on tracks', + subtitle: 'React to your crew\u2019s picks', + }); + } + + if (!isSolo && circleMemberCount < 5) { + ctas.push({ + key: 'invite-more', + href: '/profile', + icon: , + iconBg: 'bg-purple-500/15', + title: 'Grow your circle', + subtitle: 'More friends means more music to discover', + }); + } + + return ctas; +} + +function EmptyFeedCtas({ context }: { context: ActivityFeedSectionProps['context'] }) { + const isSolo = context.circleMemberCount <= 1; + const shown = buildCtaList(context).slice(0, 3); + + return ( +
    +

    + {getEmptyFeedHeadline(isSolo, context.hasSwaplists)} +

    +
    + {shown.map((cta) => ( + +
    + {cta.icon} +
    +
    +

    {cta.title}

    +

    {cta.subtitle}

    +
    + + + ))} +
    +
    + ); +} diff --git a/src/app/dashboard/HomeClient.tsx b/src/app/dashboard/HomeClient.tsx index b9b8df1..3b6a538 100644 --- a/src/app/dashboard/HomeClient.tsx +++ b/src/app/dashboard/HomeClient.tsx @@ -1,26 +1,24 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { m } from 'motion/react'; -import { Sunrise, Sun, Moon, ChevronRight } from 'lucide-react'; -import Image from 'next/image'; +import { Sunrise, Sun, Moon, ChevronRight, Plus, Download } from 'lucide-react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; import { extractColors, rgbaCss, darken } from '@/lib/color-extract'; import PlaylistCard from '@/components/PlaylistCard'; import type { PlaylistData } from '@/components/PlaylistCard'; -import ActivityEventCard from '@/components/ActivityEventCard'; import SpotifySetupWizard from '@/components/SpotifySetupWizard'; import ReauthOverlay from '@/components/ReauthOverlay'; import SpotlightTour from '@/components/SpotlightTour'; import NotificationPrompt from '@/components/NotificationPrompt'; -import { groupActivityEvents } from '@/lib/activity-utils'; -import type { ActivityEvent } from '@/lib/activity-utils'; import UnplayedTracksWidget from '@/components/UnplayedTracksWidget'; import UnplayedTracksModal from '@/components/UnplayedTracksModal'; import { PlayerProvider } from '@/hooks/usePlayerState'; +import ActivityFeedSection from './ActivityFeedSection'; +import ReactionsSection from './ReactionsSection'; interface HomeClientProps { playlists: PlaylistData[]; @@ -81,14 +79,7 @@ export default function HomeClient({ const router = useRouter(); const searchParams = useSearchParams(); - const [greeting, setGreeting] = useState<{ - text: string; - Icon: typeof Sunrise; - color: string; - } | null>(null); - useEffect(() => { - setGreeting(getGreeting()); - }, []); + const [greeting] = useState(() => getGreeting()); // Show error toasts from URL params useEffect(() => { @@ -114,40 +105,34 @@ export default function HomeClient({ const [showTour, setShowTour] = useState(!hasCompletedTour); const [showSetupWizard, setShowSetupWizard] = useState(false); const [showUnplayedModal, setShowUnplayedModal] = useState(false); - const [needsReauth, setNeedsReauth] = useState(false); - const [reauthReason, setReauthReason] = useState<'token_expired' | 'app_invalid' | 'migrating'>( - 'token_expired' - ); - // Circle photo tint const activeCircle = circles.find((c) => c.id === activeCircleId) ?? null; // Detect circle health issues and trigger reauth overlay - useEffect(() => { - if (!activeCircle) return; - if (activeCircle.appStatus === 'invalid') { - setReauthReason('app_invalid'); - setNeedsReauth(true); - } else if (activeCircle.appStatus === 'migrating') { - setReauthReason('migrating'); - setNeedsReauth(true); - } else if (activeCircle.tokenStatus === 'needs_reauth') { - setReauthReason('token_expired'); - setNeedsReauth(true); - } else { - setNeedsReauth(false); - } + const { needsReauth, reauthReason } = useMemo(() => { + if (!activeCircle) return { needsReauth: false, reauthReason: 'token_expired' as const }; + if (activeCircle.appStatus === 'invalid') + return { needsReauth: true, reauthReason: 'app_invalid' as const }; + if (activeCircle.appStatus === 'migrating') + return { needsReauth: true, reauthReason: 'migrating' as const }; + if (activeCircle.tokenStatus === 'needs_reauth') + return { needsReauth: true, reauthReason: 'token_expired' as const }; + return { needsReauth: false, reauthReason: 'token_expired' as const }; }, [activeCircle]); const [circleTint, setCircleTint] = useState(''); const circleImageUrl = activeCircle?.imageUrl ?? null; useEffect(() => { + let cancelled = false; if (!circleImageUrl) { - setCircleTint(''); - return; + queueMicrotask(() => { + if (!cancelled) setCircleTint(''); + }); + return () => { + cancelled = true; + }; } - let cancelled = false; extractColors(circleImageUrl).then((colors) => { if (cancelled || !colors) return; const darkPrimary = darken(colors.primary, 0.35); @@ -165,93 +150,6 @@ export default function HomeClient({ }; }, [circleImageUrl]); - // Activity feed — client-side fetching scoped to circle - const FETCH_SIZE = 20; // raw events per server request - const INITIAL_GROUPS = 3; // grouped rows shown initially - const MORE_GROUPS = 5; // grouped rows revealed per "load more" - - const [allFetchedEvents, setAllFetchedEvents] = useState([]); - const [activityLoading, setActivityLoading] = useState(true); - const [fetchOffset, setFetchOffset] = useState(0); - const [hasMoreFromServer, setHasMoreFromServer] = useState(true); - const [visibleGroupCount, setVisibleGroupCount] = useState(INITIAL_GROUPS); - const [loadingMore, setLoadingMore] = useState(false); - - const allGroups = groupActivityEvents(allFetchedEvents); - const visibleGroups = allGroups.slice(0, visibleGroupCount); - const hasMoreActivity = visibleGroupCount < allGroups.length || hasMoreFromServer; - - useEffect(() => { - setActivityLoading(true); - setAllFetchedEvents([]); - setFetchOffset(0); - setHasMoreFromServer(true); - setVisibleGroupCount(INITIAL_GROUPS); - const params = new URLSearchParams({ limit: String(FETCH_SIZE) }); - if (activeCircleId) params.set('circleId', activeCircleId); - fetch(`/api/activity?${params}`) - .then((res) => res.json()) - .then((data) => { - const events: ActivityEvent[] = data.events || []; - setAllFetchedEvents(events); - setFetchOffset(events.length); - setHasMoreFromServer(events.length >= FETCH_SIZE); - setActivityLoading(false); - }) - .catch(() => setActivityLoading(false)); - }, [activeCircleId]); - - async function loadMoreActivity() { - if (loadingMore) return; - const newCount = visibleGroupCount + MORE_GROUPS; - - // Already have enough fetched groups — just reveal more - if (newCount <= allGroups.length) { - setVisibleGroupCount(newCount); - return; - } - - // No more on server — reveal what we have - if (!hasMoreFromServer) { - setVisibleGroupCount(newCount); - return; - } - - // Fetch more raw events from server, then reveal - setLoadingMore(true); - const params = new URLSearchParams({ limit: String(FETCH_SIZE), offset: String(fetchOffset) }); - if (activeCircleId) params.set('circleId', activeCircleId); - try { - const res = await fetch(`/api/activity?${params}`); - const data = await res.json(); - const newEvents: ActivityEvent[] = data.events || []; - setAllFetchedEvents((prev) => [...prev, ...newEvents]); - setFetchOffset((prev) => prev + newEvents.length); - setHasMoreFromServer(newEvents.length >= FETCH_SIZE); - setVisibleGroupCount(newCount); - } catch { - toast.error('Failed to load more activity'); - } finally { - setLoadingMore(false); - } - } - - // Reaction colors - const reactionMap: Record = { - fire: { emoji: '\u{1F525}', bg: 'rgba(251,146,60,0.1)', border: 'rgba(251,146,60,0.15)' }, - heart: { - emoji: '\u{2764}\u{FE0F}', - bg: 'rgba(217,70,239,0.1)', - border: 'rgba(217,70,239,0.15)', - }, - thumbs_up: { emoji: '\u{1F44D}', bg: 'rgba(196,244,65,0.08)', border: 'rgba(196,244,65,0.12)' }, - thumbs_down: { - emoji: '\u{1F44E}', - bg: 'rgba(255,255,255,0.04)', - border: 'rgba(255,255,255,0.08)', - }, - }; - // Sort playlists by most recent activity const sortedPlaylists = [...playlists] .filter((p) => p.isMember !== false) @@ -260,7 +158,7 @@ export default function HomeClient({ ) .slice(0, 8); - // No circles at all — user needs to create one + // No circles at all -- user needs to create one if (circles.length === 0) { return (
    @@ -329,126 +227,19 @@ export default function HomeClient({ {/* Activity feed section */} - -
    -
    -

    The Feed

    -

    What your circle's been up to

    -
    - - See all - - -
    - - {activityLoading ? ( -
    - {[0, 1, 2].map((i) => ( -
    -
    -
    -
    -
    -
    -
    - ))} -
    - ) : allFetchedEvents.length === 0 ? ( -
    -

    No recent activity in this circle

    -
    - ) : ( -
    - {visibleGroups.map((g, i) => ( - - ))} - {hasMoreActivity && - (() => { - const remaining = Math.max(0, allGroups.length - visibleGroupCount); - const loadCount = - !hasMoreFromServer && remaining < MORE_GROUPS ? remaining : MORE_GROUPS; - return ( - - ); - })()} -
    - )} - - - {/* Reactions section — horizontal scroll */} - {reactionRecap.length > 0 && ( - -
    -

    Hits Different

    -

    People are feeling your picks rn

    -
    -
    - {reactionRecap.map((recap, i) => { - const accent = reactionMap[recap.reaction] ?? { - emoji: recap.reaction, - bg: 'rgba(255,255,255,0.04)', - border: 'rgba(255,255,255,0.08)', - }; - return ( - -
    - {recap.albumImageUrl ? ( - - ) : ( -
    - )} - - {accent.emoji} - -
    -
    -

    - {recap.reactorName} -

    -

    {recap.trackName}

    -
    - - ); - })} -
    - - )} + 0, + totalTracks: playlists.reduce((sum, p) => sum + p.activeTrackCount, 0), + totalUnplayed: playlists.reduce((sum, p) => sum + p.unplayedCount, 0), + circleMemberCount: activeCircle?.memberCount ?? 0, + firstPlaylistId: sortedPlaylists[0]?.id ?? null, + }} + /> + + {/* Reactions section */} + {/* Unplayed tracks widget + modal share player state */} @@ -467,43 +258,78 @@ export default function HomeClient({ /> - {/* Swaplists carousel */} - {sortedPlaylists.length > 0 && ( - -
    -
    -

    Your Swaplists

    -

    - Jump back in, the queue's waiting + {/* Swaplists carousel / empty CTA */} + + {sortedPlaylists.length > 0 ? ( + <> +

    +
    +

    Your Swaplists

    +

    + Jump back in, the queue's waiting +

    +
    + + Show all + + +
    +
    + {sortedPlaylists.map((playlist, i) => ( + + + + ))} +
    + + ) : ( +
    +
    +
    + + + +
    +

    + {(activeCircle?.memberCount ?? 0) <= 1 + ? 'Create a Swaplist and invite friends' + : 'Start your first Swaplist'} +

    +

    + {(activeCircle?.memberCount ?? 0) <= 1 + ? 'Set up a shared playlist, then invite your crew to start swapping tracks.' + : 'Your circle is ready \u2014 create a shared playlist or import one from Spotify.'}

    +
    + + + Create + + + + Import + +
    - - Show all - - -
    -
    - {sortedPlaylists.map((playlist, i) => ( - - - - ))}
    - - )} + )} + {/* Spotlight onboarding tour */} {showTour && circles.length > 0 && setShowTour(false)} />} diff --git a/src/app/dashboard/ReactionsSection.tsx b/src/app/dashboard/ReactionsSection.tsx new file mode 100644 index 0000000..9c83b80 --- /dev/null +++ b/src/app/dashboard/ReactionsSection.tsx @@ -0,0 +1,99 @@ +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import { m } from 'motion/react'; +import { springs } from '@/lib/motion'; + +interface ReactionRecap { + reactorName: string; + reactorAvatar: string | null; + reaction: string; + trackName: string; + albumImageUrl: string | null; + playlistId: string; + createdAt: string; +} + +interface ReactionsSectionProps { + reactionRecap: ReactionRecap[]; +} + +/** Maps reaction keys to their emoji, background color, and border color. */ +const REACTION_MAP: Record = { + fire: { emoji: '\u{1F525}', bg: 'rgba(251,146,60,0.1)', border: 'rgba(251,146,60,0.15)' }, + heart: { + emoji: '\u{2764}\u{FE0F}', + bg: 'rgba(217,70,239,0.1)', + border: 'rgba(217,70,239,0.15)', + }, + thumbs_up: { emoji: '\u{1F44D}', bg: 'rgba(196,244,65,0.08)', border: 'rgba(196,244,65,0.12)' }, + thumbs_down: { + emoji: '\u{1F44E}', + bg: 'rgba(255,255,255,0.04)', + border: 'rgba(255,255,255,0.08)', + }, +}; + +const DEFAULT_ACCENT = { + emoji: '', + bg: 'rgba(255,255,255,0.04)', + border: 'rgba(255,255,255,0.08)', +}; + +export default function ReactionsSection({ reactionRecap }: ReactionsSectionProps) { + if (reactionRecap.length === 0) return null; + + return ( + +
    +

    Hits Different

    +

    People are feeling your picks rn

    +
    +
    + {reactionRecap.map((recap, i) => { + const accent = REACTION_MAP[recap.reaction] ?? { + ...DEFAULT_ACCENT, + emoji: recap.reaction, + }; + return ( + +
    + {recap.albumImageUrl ? ( + + ) : ( +
    + )} + + {accent.emoji} + +
    +
    +

    + {recap.reactorName} +

    +

    {recap.trackName}

    +
    + + ); + })} +
    + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 5004b24..e188494 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,11 +32,11 @@ --glass-border: rgba(255, 255, 255, 0.06); --glass-bg-hover: rgba(255, 255, 255, 0.08); - /* Text */ + /* Text — brightened for readability on deep-dark backgrounds */ --text-primary: #f1f5f9; - --text-secondary: #94a3b8; - --text-tertiary: #64748b; - --text-muted: #475569; + --text-secondary: #b0bfd0; + --text-tertiary: #8494a7; + --text-muted: #64748b; --text-on-accent: #0a0a0a; --bottom-nav-height: 5rem; @@ -165,13 +165,14 @@ body { letter-spacing: 0.01em; } -/* Content text uses the lighter body font; UI chrome stays in Gabarito */ +/* Content text uses the body font at medium weight; UI chrome stays in Gabarito */ p, li, dd, blockquote, .font-body { font-family: var(--font-jakarta), sans-serif; + font-weight: 500; } /* Glassmorphism utility */ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 568bb9a..31f62ae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -34,7 +34,7 @@ const montserrat = Montserrat({ const plusJakarta = Plus_Jakarta_Sans({ variable: '--font-jakarta', subsets: ['latin'], - weight: ['300', '400', '500'], + weight: ['400', '500', '600'], }); const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; diff --git a/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx b/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx index 9b0c0ed..ebe5446 100644 --- a/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx +++ b/src/app/playlist/[playlistId]/PlaylistDetailClient.tsx @@ -1,27 +1,22 @@ 'use client'; -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import { useAlbumColors } from '@/hooks/useAlbumColors'; import ShareSheet from '@/components/ShareSheet'; import EditDetailsModal from '@/components/EditDetailsModal'; import PlaylistTabs from '@/components/PlaylistTabs'; import LikedTracksView from '@/components/LikedTracksView'; import OutcastTracksView from '@/components/OutcastTracksView'; -import { toast } from 'sonner'; import ReauthOverlay from '@/components/ReauthOverlay'; -import PlaylistSortControl, { type ClientSortMode } from '@/components/PlaylistSortControl'; -import { useUnreadActivity } from '@/components/UnreadActivityProvider'; -import type { - PlaylistDetailClientProps, - TrackData, - LikedTrack, - OutcastTrack, - PlaylistMember, -} from './types'; +import PlaylistSortControl from '@/components/PlaylistSortControl'; +import type { PlaylistDetailClientProps } from './types'; import PlaylistHeader from './PlaylistHeader'; import InboxTabContent from './InboxTabContent'; import FollowGateBanner from './FollowGateBanner'; +import { useCurrentTrack } from './use-current-track'; +import { useSortedTracks } from './use-sorted-tracks'; +import { usePlaylistActions } from './use-playlist-actions'; export default function PlaylistDetailClient({ playlistId, @@ -40,290 +35,73 @@ export default function PlaylistDetailClient({ circleMemberCount, }: PlaylistDetailClientProps) { const router = useRouter(); - const searchParams = useSearchParams(); - const [needsReauth, setNeedsReauth] = useState(false); - const [tracks, setTracks] = useState([]); - const [members, setMembers] = useState([]); const [activeTab, setActiveTab] = useState<'inbox' | 'liked' | 'outcasts'>('inbox'); - const [likedTracks, setLikedTracks] = useState([]); - const [outcastTracks, setOutcastTracks] = useState([]); - const [likedPlaylistId, setLikedPlaylistId] = useState(null); - const [showShare, setShowShare] = useState(false); - const [showEditDetails, setShowEditDetails] = useState(false); - const [currentName, setCurrentName] = useState(playlistName); - const [currentDescription, setCurrentDescription] = useState(playlistDescription); - const [currentImageUrl, setCurrentImageUrl] = useState(playlistImageUrl); - const [currentVibeName, setCurrentVibeName] = useState(vibeName); - const [syncing, setSyncing] = useState(false); - const [initialLoading, setInitialLoading] = useState(true); - const [isDragOver, setIsDragOver] = useState(false); - const [isFollowing, setIsFollowing] = useState(null); - const [followLoading, setFollowLoading] = useState(false); - const [clientSort, setClientSort] = useState('default'); - const [energyScores, setEnergyScores] = useState | null>(null); - const [loadingEnergy, setLoadingEnergy] = useState(false); - const [playingTrackId, setPlayingTrackId] = useState(null); - const dropRef = useRef(null); - const albumColors = useAlbumColors(currentImageUrl); - const { markRead } = useUnreadActivity(); - - // Mark this playlist's activity as read on mount - useEffect(() => { - markRead({ playlistId }); - }, [markRead, playlistId]); - - // Poll current player state for play head indicator - useEffect(() => { - async function fetchCurrentTrack() { - try { - const res = await fetch('/api/player/current'); - if (!res.ok) return; - const data = await res.json(); - setPlayingTrackId(data.trackId ?? null); - } catch { - // Keep existing state - } - } - fetchCurrentTrack(); - const interval = setInterval(fetchCurrentTrack, 15_000); - return () => clearInterval(interval); - }, []); - - // Fetch energy scores lazily when user selects energy sort - const fetchEnergyScores = useCallback(async () => { - if (energyScores || loadingEnergy) return; - setLoadingEnergy(true); - try { - const res = await fetch(`/api/playlists/${playlistId}/audio-features`); - if (res.ok) { - const data = await res.json(); - setEnergyScores(data.energyScores); - } - } catch { - // Silently fail - } finally { - setLoadingEnergy(false); - } - }, [playlistId, energyScores, loadingEnergy]); - - function handleSortChange(mode: ClientSortMode) { - if ((mode === 'energy_asc' || mode === 'energy_desc') && !energyScores) { - fetchEnergyScores(); - } - setClientSort(mode); - } - - // Client-side sorted tracks (temporary, resets on refresh) - const sortedTracks = useMemo(() => { - if (clientSort === 'default') return tracks; - return sortTracksByMode([...tracks], clientSort, energyScores); - }, [tracks, clientSort, energyScores]); - - // Tracks added by others that the current user hasn't listened to - const unheardTracks = useMemo( - () => - tracks.filter((track) => { - if (track.addedBy.id === currentUserId) return false; - const myProgress = track.progress.find((p) => p.id === currentUserId); - return !myProgress?.hasListened; - }), - [tracks, currentUserId] - ); - - const unheardIds = useMemo(() => new Set(unheardTracks.map((t) => t.id)), [unheardTracks]); - const unplayedSorted = useMemo( - () => sortedTracks.filter((t) => unheardIds.has(t.id)), - [sortedTracks, unheardIds] - ); - const playedSorted = useMemo( - () => sortedTracks.filter((t) => !unheardIds.has(t.id)), - [sortedTracks, unheardIds] - ); - - const fetchTracks = useCallback(async () => { - try { - const res = await fetch(`/api/playlists/${playlistId}/tracks`); - if (!res.ok) { - handleApiError(res, setNeedsReauth); - return; - } - const data = await res.json(); - setTracks(data.tracks); - setMembers(data.members); - setLikedTracks(data.likedTracks ?? []); - setOutcastTracks(data.outcastTracks ?? []); - setLikedPlaylistId(data.likedPlaylistId ?? null); - if (data.vibeName !== undefined) setCurrentVibeName(data.vibeName); - } catch { - // Silently fail on refresh - } finally { - setInitialLoading(false); - } - }, [playlistId]); - - // Check Spotify follow status on mount - useEffect(() => { - fetch(`/api/playlists/${playlistId}/follow-status`) - .then((res) => (res.ok ? res.json() : { isFollowing: true })) - .then((data) => setIsFollowing(data.isFollowing)) - .catch(() => setIsFollowing(true)); // Fail open - }, [playlistId]); - - async function handleFollow() { - setFollowLoading(true); - try { - const res = await fetch(`/api/playlists/${playlistId}/follow`, { method: 'POST' }); - if (!res.ok) throw new Error(); - setIsFollowing(true); - toast.success('Playlist followed on Spotify!'); - } catch { - toast.error('Failed to follow playlist'); - } finally { - setFollowLoading(false); - } - } - - // Initial fetch + auto-refresh every 10s - useEffect(() => { - fetchTracks(); - const interval = setInterval(fetchTracks, 10000); - return () => clearInterval(interval); - }, [fetchTracks]); - - // Auto-open share sheet when arriving from playlist creation (host only, solo circle) - const shareTriggered = useRef(false); - useEffect(() => { - if (isOwner && searchParams.get('share') === '1' && !shareTriggered.current) { - shareTriggered.current = true; - window.history.replaceState({}, '', `/playlist/${playlistId}`); - if (circleMemberCount <= 1) { - setShowShare(true); - } - } - }, [searchParams, playlistId, isOwner, circleMemberCount]); - - async function syncFromSpotify() { - setSyncing(true); - try { - const res = await fetch(`/api/playlists/${playlistId}/tracks/sync`, { method: 'POST' }); - if (!res.ok) { - handleApiError(res, setNeedsReauth); - return; - } - const data = await res.json(); - applySyncMetadata(data.metadata, setCurrentName, setCurrentDescription, setCurrentImageUrl); - await fetchTracks(); - } catch { - // Silently fail - } finally { - setSyncing(false); - } - } - - // Play a track on Spotify (optimistic playingTrackId update) - const handlePlayTrack = useCallback( - async (spotifyTrackUri: string, spotifyTrackId: string) => { - const prev = playingTrackId; - setPlayingTrackId(spotifyTrackId); - try { - const res = await fetch('/api/spotify/play', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - trackUri: spotifyTrackUri, - contextUri: `spotify:playlist:${spotifyPlaylistId}`, - }), - }); - if (!res.ok) { - setPlayingTrackId(prev); - handlePlaybackError(res); - } - } catch { - setPlayingTrackId(prev); - toast.error('Could not reach server'); - } - }, - [playingTrackId, spotifyPlaylistId] - ); - async function handleReaction(spotifyTrackId: string, reaction: string) { - try { - await fetch(`/api/playlists/${playlistId}/reactions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ spotifyTrackId, reaction }), - }); - fetchTracks(); - } catch { - // Silently fail - } - } + // Player polling + const { playingTrackId, setPlayingTrackId } = useCurrentTrack(); + + // Playlist data, actions, and UI state + const { + tracks, + members, + likedTracks, + outcastTracks, + likedPlaylistId, + setLikedPlaylistId, + likedSyncMode, + setLikedSyncMode, + likedPlaylistName, + setLikedPlaylistName, + initialLoading, + syncing, + needsReauth, + isDragOver, + isFollowing, + followLoading, + showShare, + setShowShare, + showEditDetails, + setShowEditDetails, + currentName, + setCurrentName, + currentDescription, + setCurrentDescription, + currentImageUrl, + setCurrentImageUrl, + currentVibeName, + syncFromSpotify, + handlePlayTrack, + handleReaction, + handleFollow, + handleDragOver, + handleDragLeave, + handleDrop, + dropRef, + } = usePlaylistActions({ + playlistId, + currentUserId, + spotifyPlaylistId, + isOwner, + circleMemberCount, + playlistName, + playlistDescription, + playlistImageUrl, + vibeName, + playingTrackId, + setPlayingTrackId, + }); + + // Track sorting and filtering + const { + unheardTracks, + unplayedSorted, + playedSorted, + clientSort, + handleSortChange, + loadingEnergy, + } = useSortedTracks(tracks, currentUserId, playlistId); - // Drag and drop from Spotify - function handleDragOver(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(true); - } - - function handleDragLeave(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - } - - async function handleDrop(e: React.DragEvent) { - e.preventDefault(); - setIsDragOver(false); - - const text = e.dataTransfer.getData('text/plain') || e.dataTransfer.getData('text/uri-list'); - if (!text) return; - - const match = text.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/); - if (!match) { - toast.info('Drop a Spotify track link to add it.'); - return; - } - - await addDroppedTrack(match[1]!); - } - - async function addDroppedTrack(trackId: string) { - try { - const searchRes = await fetch(`/api/spotify/search?q=track:${trackId}`); - if (!searchRes.ok) { - const searchErr = await searchRes.json().catch(() => null); - throw new Error(searchErr?.error || 'Search failed'); - } - const searchData = await searchRes.json(); - const track = searchData.tracks?.find((t: { id: string }) => t.id === trackId); - if (!track) { - toast.error('Could not find that track on Spotify.'); - return; - } - - const res = await fetch(`/api/playlists/${playlistId}/tracks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - spotifyTrackUri: track.uri, - spotifyTrackId: track.id, - trackName: track.name, - artistName: track.artists.map((a: { name: string }) => a.name).join(', '), - albumName: track.album.name, - albumImageUrl: track.album.images[0]?.url || null, - durationMs: track.duration_ms, - }), - }); - - if (res.ok) { - fetchTracks(); - } else { - const data = await res.json(); - toast.error(data.error || 'Failed to add track'); - } - } catch { - toast.error('Failed to add the dropped track.'); - } - } + const albumColors = useAlbumColors(currentImageUrl); return (
    setLikedPlaylistId(id)} + likedSyncMode={likedSyncMode} + likedPlaylistName={likedPlaylistName} + onSyncConfigured={(config) => { + setLikedPlaylistId(config.spotifyPlaylistId); + setLikedSyncMode(config.mode); + if (config.playlistName) setLikedPlaylistName(config.playlistName); + }} + onSyncDisconnected={() => { + setLikedPlaylistId(null); + setLikedSyncMode(null); + setLikedPlaylistName(null); + }} playingTrackId={playingTrackId} onPlay={handlePlayTrack} /> @@ -454,72 +243,3 @@ export default function PlaylistDetailClient({
    ); } - -// -- Helper functions extracted to reduce cognitive complexity -- - -function sortTracksByMode( - sorted: TrackData[], - mode: ClientSortMode, - energyScores: Record | null -): TrackData[] { - switch (mode) { - case 'date_asc': - sorted.sort((a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime()); - break; - case 'date_desc': - sorted.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()); - break; - case 'energy_desc': - if (energyScores) { - sorted.sort( - (a, b) => (energyScores[b.spotifyTrackId] ?? -1) - (energyScores[a.spotifyTrackId] ?? -1) - ); - } - break; - case 'energy_asc': - if (energyScores) { - sorted.sort( - (a, b) => (energyScores[a.spotifyTrackId] ?? 2) - (energyScores[b.spotifyTrackId] ?? 2) - ); - } - break; - case 'creator_asc': - sorted.sort((a, b) => a.addedBy.displayName.localeCompare(b.addedBy.displayName)); - break; - case 'creator_desc': - sorted.sort((a, b) => b.addedBy.displayName.localeCompare(a.addedBy.displayName)); - break; - } - return sorted; -} - -async function handlePlaybackError(res: Response) { - const data = await res.json().catch(() => null); - if (res.status === 404) { - toast.error('Open Spotify on a device first'); - } else if (res.status === 403) { - toast.error('Spotify Premium required'); - } else { - toast.error(data?.error || 'Playback failed'); - } -} - -/** Parse error responses from our API and trigger reauth or toast as needed. */ -async function handleApiError(res: Response, setNeedsReauth: (v: boolean) => void) { - const data = await res.json().catch(() => null); - if (data?.needsReauth) setNeedsReauth(true); - else if (data?.rateLimited) toast.error(data.error); -} - -/** Apply sync metadata updates from the sync response. */ -function applySyncMetadata( - metadata: Record | undefined, - setName: (v: string) => void, - setDescription: (v: string | null) => void, - setImageUrl: (v: string | null) => void -) { - if (!metadata) return; - if (metadata.name !== undefined) setName(metadata.name as string); - if (metadata.description !== undefined) setDescription(metadata.description as string | null); - if (metadata.imageUrl !== undefined) setImageUrl(metadata.imageUrl as string | null); -} diff --git a/src/app/playlist/[playlistId]/types.ts b/src/app/playlist/[playlistId]/types.ts index 64a1cc3..7c62b4c 100644 --- a/src/app/playlist/[playlistId]/types.ts +++ b/src/app/playlist/[playlistId]/types.ts @@ -1,3 +1,5 @@ +export type LikedSyncMode = 'created' | 'funnel' | null; + export interface PlaylistDetailClientProps { playlistId: string; playlistName: string; diff --git a/src/app/playlist/[playlistId]/use-current-track.ts b/src/app/playlist/[playlistId]/use-current-track.ts new file mode 100644 index 0000000..8853181 --- /dev/null +++ b/src/app/playlist/[playlistId]/use-current-track.ts @@ -0,0 +1,35 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +/** Polling interval for fetching the current player track (ms). */ +export const PLAYER_POLL_INTERVAL_MS = 15_000; + +/** + * Polls the current Spotify player state at a fixed interval and returns + * the currently-playing track ID (or null). + */ +export function useCurrentTrack(): { + playingTrackId: string | null; + setPlayingTrackId: (id: string | null) => void; +} { + const [playingTrackId, setPlayingTrackId] = useState(null); + + useEffect(() => { + async function fetchCurrentTrack() { + try { + const res = await fetch('/api/player/current'); + if (!res.ok) return; + const data = await res.json(); + setPlayingTrackId(data.trackId ?? null); + } catch { + // Keep existing state + } + } + fetchCurrentTrack(); + const interval = setInterval(fetchCurrentTrack, PLAYER_POLL_INTERVAL_MS); + return () => clearInterval(interval); + }, []); + + return { playingTrackId, setPlayingTrackId }; +} diff --git a/src/app/playlist/[playlistId]/use-playlist-actions.ts b/src/app/playlist/[playlistId]/use-playlist-actions.ts new file mode 100644 index 0000000..9bd2286 --- /dev/null +++ b/src/app/playlist/[playlistId]/use-playlist-actions.ts @@ -0,0 +1,367 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { useUnreadActivity } from '@/components/UnreadActivityProvider'; +import type { TrackData, LikedTrack, OutcastTrack, PlaylistMember, LikedSyncMode } from './types'; + +/** Interval for auto-refreshing the track list (ms). */ +export const TRACK_REFRESH_INTERVAL_MS = 10_000; + +export interface UsePlaylistActionsParams { + playlistId: string; + currentUserId: string; + spotifyPlaylistId: string; + isOwner: boolean; + circleMemberCount: number; + playlistName: string; + playlistDescription: string | null; + playlistImageUrl: string | null; + vibeName: string | null; + playingTrackId: string | null; + setPlayingTrackId: (id: string | null) => void; +} + +export interface UsePlaylistActionsReturn { + tracks: TrackData[]; + members: PlaylistMember[]; + likedTracks: LikedTrack[]; + outcastTracks: OutcastTrack[]; + likedPlaylistId: string | null; + setLikedPlaylistId: (id: string | null) => void; + likedSyncMode: LikedSyncMode; + setLikedSyncMode: (mode: LikedSyncMode) => void; + likedPlaylistName: string | null; + setLikedPlaylistName: (name: string | null) => void; + initialLoading: boolean; + syncing: boolean; + needsReauth: boolean; + isDragOver: boolean; + isFollowing: boolean | null; + followLoading: boolean; + showShare: boolean; + setShowShare: (v: boolean) => void; + showEditDetails: boolean; + setShowEditDetails: (v: boolean) => void; + currentName: string; + setCurrentName: (v: string) => void; + currentDescription: string | null; + setCurrentDescription: (v: string | null) => void; + currentImageUrl: string | null; + setCurrentImageUrl: (v: string | null) => void; + currentVibeName: string | null; + syncFromSpotify: () => Promise; + handlePlayTrack: (spotifyTrackUri: string, spotifyTrackId: string) => Promise; + handleReaction: (spotifyTrackId: string, reaction: string) => Promise; + handleFollow: () => Promise; + handleDragOver: (e: React.DragEvent) => void; + handleDragLeave: (e: React.DragEvent) => void; + handleDrop: (e: React.DragEvent) => Promise; + dropRef: React.RefObject; +} + +/** + * Encapsulates all playlist action handlers, data fetching, polling, + * and drag-and-drop logic for the playlist detail view. + */ +export function usePlaylistActions({ + playlistId, + spotifyPlaylistId, + isOwner, + circleMemberCount, + playlistName, + playlistDescription, + playlistImageUrl, + vibeName, + playingTrackId, + setPlayingTrackId, +}: UsePlaylistActionsParams): UsePlaylistActionsReturn { + const searchParams = useSearchParams(); + const { markRead } = useUnreadActivity(); + + const [tracks, setTracks] = useState([]); + const [members, setMembers] = useState([]); + const [likedTracks, setLikedTracks] = useState([]); + const [outcastTracks, setOutcastTracks] = useState([]); + const [likedPlaylistId, setLikedPlaylistId] = useState(null); + const [likedSyncMode, setLikedSyncMode] = useState<'created' | 'funnel' | null>(null); + const [likedPlaylistName, setLikedPlaylistName] = useState(null); + const [needsReauth, setNeedsReauth] = useState(false); + const [showShare, setShowShare] = useState(false); + const [showEditDetails, setShowEditDetails] = useState(false); + const [currentName, setCurrentName] = useState(playlistName); + const [currentDescription, setCurrentDescription] = useState(playlistDescription); + const [currentImageUrl, setCurrentImageUrl] = useState(playlistImageUrl); + const [currentVibeName, setCurrentVibeName] = useState(vibeName); + const [syncing, setSyncing] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [isDragOver, setIsDragOver] = useState(false); + const [isFollowing, setIsFollowing] = useState(null); + const [followLoading, setFollowLoading] = useState(false); + const dropRef = useRef(null); + + // Mark this playlist's activity as read on mount + useEffect(() => { + markRead({ playlistId }); + }, [markRead, playlistId]); + + // Fetch tracks + const fetchTracks = useCallback(async () => { + try { + const res = await fetch(`/api/playlists/${playlistId}/tracks`); + if (!res.ok) { + handleApiError(res, setNeedsReauth); + return; + } + const data = await res.json(); + setTracks(data.tracks); + setMembers(data.members); + setLikedTracks(data.likedTracks ?? []); + setOutcastTracks(data.outcastTracks ?? []); + setLikedPlaylistId(data.likedPlaylistId ?? null); + setLikedSyncMode(data.likedSyncMode ?? null); + setLikedPlaylistName(data.likedPlaylistName ?? null); + if (data.vibeName !== undefined) setCurrentVibeName(data.vibeName); + } catch { + // Silently fail on refresh + } finally { + setInitialLoading(false); + } + }, [playlistId]); + + // Initial fetch + auto-refresh + useEffect(() => { + fetchTracks(); + const interval = setInterval(fetchTracks, TRACK_REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchTracks]); + + // Check Spotify follow status on mount + useEffect(() => { + fetch(`/api/playlists/${playlistId}/follow-status`) + .then((res) => (res.ok ? res.json() : { isFollowing: true })) + .then((data) => setIsFollowing(data.isFollowing)) + .catch(() => setIsFollowing(true)); // Fail open + }, [playlistId]); + + // Auto-open share sheet when arriving from playlist creation (host only, solo circle) + const shareTriggered = useRef(false); + useEffect(() => { + if (isOwner && searchParams.get('share') === '1' && !shareTriggered.current) { + shareTriggered.current = true; + window.history.replaceState({}, '', `/playlist/${playlistId}`); + if (circleMemberCount <= 1) { + setShowShare(true); + } + } + }, [searchParams, playlistId, isOwner, circleMemberCount]); + + async function handleFollow() { + setFollowLoading(true); + try { + const res = await fetch(`/api/playlists/${playlistId}/follow`, { method: 'POST' }); + if (!res.ok) throw new Error(); + setIsFollowing(true); + toast.success('Playlist followed on Spotify!'); + } catch { + toast.error('Failed to follow playlist'); + } finally { + setFollowLoading(false); + } + } + + async function syncFromSpotify() { + setSyncing(true); + try { + const res = await fetch(`/api/playlists/${playlistId}/tracks/sync`, { method: 'POST' }); + if (!res.ok) { + handleApiError(res, setNeedsReauth); + return; + } + const data = await res.json(); + applySyncMetadata(data.metadata, setCurrentName, setCurrentDescription, setCurrentImageUrl); + await fetchTracks(); + } catch { + // Silently fail + } finally { + setSyncing(false); + } + } + + // Play a track on Spotify (optimistic playingTrackId update) + const handlePlayTrack = useCallback( + async (spotifyTrackUri: string, spotifyTrackId: string) => { + const prev = playingTrackId; + setPlayingTrackId(spotifyTrackId); + try { + const res = await fetch('/api/spotify/play', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + trackUri: spotifyTrackUri, + contextUri: `spotify:playlist:${spotifyPlaylistId}`, + }), + }); + if (!res.ok) { + setPlayingTrackId(prev); + handlePlaybackError(res); + } + } catch { + setPlayingTrackId(prev); + toast.error('Could not reach server'); + } + }, + [playingTrackId, spotifyPlaylistId, setPlayingTrackId] + ); + + async function handleReaction(spotifyTrackId: string, reaction: string) { + try { + await fetch(`/api/playlists/${playlistId}/reactions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spotifyTrackId, reaction }), + }); + fetchTracks(); + } catch { + // Silently fail + } + } + + // Drag and drop from Spotify + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(true); + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + } + + async function handleDrop(e: React.DragEvent) { + e.preventDefault(); + setIsDragOver(false); + + const text = e.dataTransfer.getData('text/plain') || e.dataTransfer.getData('text/uri-list'); + if (!text) return; + + const match = text.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/); + if (!match) { + toast.info('Drop a Spotify track link to add it.'); + return; + } + + await addDroppedTrack(match[1]!); + } + + async function addDroppedTrack(trackId: string) { + try { + const searchRes = await fetch(`/api/spotify/search?q=track:${trackId}`); + if (!searchRes.ok) { + const searchErr = await searchRes.json().catch(() => null); + throw new Error(searchErr?.error || 'Search failed'); + } + const searchData = await searchRes.json(); + const track = searchData.tracks?.find((t: { id: string }) => t.id === trackId); + if (!track) { + toast.error('Could not find that track on Spotify.'); + return; + } + + const res = await fetch(`/api/playlists/${playlistId}/tracks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + spotifyTrackUri: track.uri, + spotifyTrackId: track.id, + trackName: track.name, + artistName: track.artists.map((a: { name: string }) => a.name).join(', '), + albumName: track.album.name, + albumImageUrl: track.album.images[0]?.url || null, + durationMs: track.duration_ms, + }), + }); + + if (res.ok) { + fetchTracks(); + } else { + const data = await res.json(); + toast.error(data.error || 'Failed to add track'); + } + } catch { + toast.error('Failed to add the dropped track.'); + } + } + + return { + tracks, + members, + likedTracks, + outcastTracks, + likedPlaylistId, + setLikedPlaylistId, + likedSyncMode, + setLikedSyncMode, + likedPlaylistName, + setLikedPlaylistName, + initialLoading, + syncing, + needsReauth, + isDragOver, + isFollowing, + followLoading, + showShare, + setShowShare, + showEditDetails, + setShowEditDetails, + currentName, + setCurrentName, + currentDescription, + setCurrentDescription, + currentImageUrl, + setCurrentImageUrl, + currentVibeName, + syncFromSpotify, + handlePlayTrack, + handleReaction, + handleFollow, + handleDragOver, + handleDragLeave, + handleDrop, + dropRef, + }; +} + +// -- Helper functions -- + +async function handlePlaybackError(res: Response) { + const data = await res.json().catch(() => null); + if (res.status === 404) { + toast.error('Open Spotify on a device first'); + } else if (res.status === 403) { + toast.error('Spotify Premium required'); + } else { + toast.error(data?.error || 'Playback failed'); + } +} + +/** Parse error responses from our API and trigger reauth or toast as needed. */ +async function handleApiError(res: Response, setNeedsReauth: (v: boolean) => void) { + const data = await res.json().catch(() => null); + if (data?.needsReauth) setNeedsReauth(true); + else if (data?.rateLimited) toast.error(data.error); +} + +/** Apply sync metadata updates from the sync response. */ +function applySyncMetadata( + metadata: Record | undefined, + setName: (v: string) => void, + setDescription: (v: string | null) => void, + setImageUrl: (v: string | null) => void +) { + if (!metadata) return; + if (metadata.name !== undefined) setName(metadata.name as string); + if (metadata.description !== undefined) setDescription(metadata.description as string | null); + if (metadata.imageUrl !== undefined) setImageUrl(metadata.imageUrl as string | null); +} diff --git a/src/app/playlist/[playlistId]/use-sorted-tracks.ts b/src/app/playlist/[playlistId]/use-sorted-tracks.ts new file mode 100644 index 0000000..a5497e7 --- /dev/null +++ b/src/app/playlist/[playlistId]/use-sorted-tracks.ts @@ -0,0 +1,135 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import type { ClientSortMode } from '@/components/PlaylistSortControl'; +import type { TrackData } from './types'; + +export interface UseSortedTracksReturn { + /** Tracks sorted by the current client sort mode. */ + sortedTracks: TrackData[]; + /** Tracks added by others that the current user hasn't listened to. */ + unheardTracks: TrackData[]; + /** Unheard tracks in sorted order. */ + unplayedSorted: TrackData[]; + /** Heard tracks in sorted order. */ + playedSorted: TrackData[]; + /** Current client-side sort mode. */ + clientSort: ClientSortMode; + /** Change the sort mode (triggers lazy energy fetch if needed). */ + handleSortChange: (mode: ClientSortMode) => void; + /** Whether energy scores are currently being fetched. */ + loadingEnergy: boolean; +} + +/** + * Manages client-side sorting and filtering of playlist tracks. + * Includes lazy-loading of energy scores when the user selects energy-based sorts. + */ +export function useSortedTracks( + tracks: TrackData[], + currentUserId: string, + playlistId: string +): UseSortedTracksReturn { + const [clientSort, setClientSort] = useState('default'); + const [energyScores, setEnergyScores] = useState | null>(null); + const [loadingEnergy, setLoadingEnergy] = useState(false); + + // Fetch energy scores lazily when user selects energy sort + const fetchEnergyScores = useCallback(async () => { + if (energyScores || loadingEnergy) return; + setLoadingEnergy(true); + try { + const res = await fetch(`/api/playlists/${playlistId}/audio-features`); + if (res.ok) { + const data = await res.json(); + setEnergyScores(data.energyScores); + } + } catch { + // Silently fail + } finally { + setLoadingEnergy(false); + } + }, [playlistId, energyScores, loadingEnergy]); + + function handleSortChange(mode: ClientSortMode) { + if ((mode === 'energy_asc' || mode === 'energy_desc') && !energyScores) { + fetchEnergyScores(); + } + setClientSort(mode); + } + + // Client-side sorted tracks (temporary, resets on refresh) + const sortedTracks = useMemo(() => { + if (clientSort === 'default') return tracks; + return sortTracksByMode([...tracks], clientSort, energyScores); + }, [tracks, clientSort, energyScores]); + + // Tracks added by others that the current user hasn't listened to + const unheardTracks = useMemo( + () => + tracks.filter((track) => { + if (track.addedBy.id === currentUserId) return false; + const myProgress = track.progress.find((p) => p.id === currentUserId); + return !myProgress?.hasListened; + }), + [tracks, currentUserId] + ); + + const unheardIds = useMemo(() => new Set(unheardTracks.map((t) => t.id)), [unheardTracks]); + const unplayedSorted = useMemo( + () => sortedTracks.filter((t) => unheardIds.has(t.id)), + [sortedTracks, unheardIds] + ); + const playedSorted = useMemo( + () => sortedTracks.filter((t) => !unheardIds.has(t.id)), + [sortedTracks, unheardIds] + ); + + return { + sortedTracks, + unheardTracks, + unplayedSorted, + playedSorted, + clientSort, + handleSortChange, + loadingEnergy, + }; +} + +// -- Pure sorting helper -- + +function sortTracksByMode( + sorted: TrackData[], + mode: ClientSortMode, + energyScores: Record | null +): TrackData[] { + switch (mode) { + case 'date_asc': + sorted.sort((a, b) => new Date(a.addedAt).getTime() - new Date(b.addedAt).getTime()); + break; + case 'date_desc': + sorted.sort((a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()); + break; + case 'energy_desc': + if (energyScores) { + sorted.sort( + (a, b) => (energyScores[b.spotifyTrackId] ?? -1) - (energyScores[a.spotifyTrackId] ?? -1) + ); + } + break; + case 'energy_asc': + if (energyScores) { + sorted.sort( + (a, b) => (energyScores[a.spotifyTrackId] ?? 2) - (energyScores[b.spotifyTrackId] ?? 2) + ); + } + break; + case 'creator_asc': + sorted.sort((a, b) => a.addedBy.displayName.localeCompare(b.addedBy.displayName)); + break; + case 'creator_desc': + sorted.sort((a, b) => b.addedBy.displayName.localeCompare(a.addedBy.displayName)); + break; + } + return sorted; +} diff --git a/src/app/profile/ProfileClient.tsx b/src/app/profile/ProfileClient.tsx index ce72ac1..9fefc80 100644 --- a/src/app/profile/ProfileClient.tsx +++ b/src/app/profile/ProfileClient.tsx @@ -6,7 +6,7 @@ import { m } from 'motion/react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; import { useAlbumColors } from '@/hooks/useAlbumColors'; import { darken, rgbaCss } from '@/lib/color-extract'; -import { LogOut, Wand2 } from 'lucide-react'; +import { LogOut, Zap } from 'lucide-react'; import ProfileHero from './ProfileHero'; import NotificationSettings from './NotificationSettings'; import { SectionHeader, ToggleRow } from './ProfileToggle'; @@ -98,7 +98,7 @@ function ProfileContent({ user, stats }: ProfileClientProps) {
    } + icon={} label="Auto-reactions" description="Skip = thumbs down, save to library = thumbs up" enabled={user.autoNegativeReactions} diff --git a/src/app/swaplists/SwaplistsClient.tsx b/src/app/swaplists/SwaplistsClient.tsx index 67cb59f..248837b 100644 --- a/src/app/swaplists/SwaplistsClient.tsx +++ b/src/app/swaplists/SwaplistsClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { m } from 'motion/react'; @@ -13,14 +13,17 @@ import DiscoverSection from './DiscoverSection'; import SwaplistsEmptyState from './SwaplistsEmptyState'; import CreateSwaplistDialog from './CreateSwaplistDialog'; import ImportDrawer from './ImportDrawer'; +import SwaplistsHelpBanner from './SwaplistsHelpBanner'; export default function SwaplistsClient({ myPlaylists, otherPlaylists, spotifyId, activeCircle, + helpBannerDismissed, }: SwaplistsClientProps) { const router = useRouter(); + const searchParams = useSearchParams(); // View mode -- grid or list, persisted in localStorage const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); @@ -37,6 +40,14 @@ export default function SwaplistsClient({ const [showCreate, setShowCreate] = useState(false); const [showImport, setShowImport] = useState(false); + // Auto-open create/import from ?action= deep link + useEffect(() => { + const action = searchParams.get('action'); + if (action === 'create') setShowCreate(true); + else if (action === 'import') setShowImport(true); + if (action) router.replace('/swaplists', { scroll: false }); + }, [searchParams, router]); + // Reauth state const [needsReauth, setNeedsReauth] = useState(false); @@ -117,6 +128,8 @@ export default function SwaplistsClient({
    + + {/* Sort + View controls */} {}); + } + + return ( + + {!dismissed && ( + +
    +
    + +
    +
    +

    What's a Swaplist?

    +

    + Think of it as a shared music inbox. Drop tracks for your friends, listen to theirs, + and react. Once everyone's heard a track it clears out, making room for fresh + picks. +

    +
    + +
    +
    + )} +
    + ); +} diff --git a/src/app/swaplists/page.tsx b/src/app/swaplists/page.tsx index c90771b..e49c492 100644 --- a/src/app/swaplists/page.tsx +++ b/src/app/swaplists/page.tsx @@ -140,6 +140,7 @@ export default async function SwaplistsPage() { myPlaylists={myPlaylists} otherPlaylists={otherPlaylists} spotifyId={user.spotifyId} + helpBannerDismissed={user.helpBannerDismissed} activeCircle={ activeCircle ? { diff --git a/src/app/swaplists/swaplists-types.ts b/src/app/swaplists/swaplists-types.ts index 3e7b28f..6703988 100644 --- a/src/app/swaplists/swaplists-types.ts +++ b/src/app/swaplists/swaplists-types.ts @@ -33,6 +33,7 @@ export interface SwaplistsClientProps { otherPlaylists: (PlaylistData & { isMember: false })[]; spotifyId: string; activeCircle: ActiveCircle | null; + helpBannerDismissed: boolean; } export type SortField = 'modified' | 'created'; diff --git a/src/components/ActivityEventCard.tsx b/src/components/ActivityEventCard.tsx index 075cce0..2ec109c 100644 --- a/src/components/ActivityEventCard.tsx +++ b/src/components/ActivityEventCard.tsx @@ -1,10 +1,10 @@ 'use client'; import Link from 'next/link'; -import Image from 'next/image'; import { motion } from 'motion/react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; import AlbumArt from '@/components/AlbumArt'; +import { UserAvatar } from '@/components/ui/user-avatar'; import { formatTimeAgo, getEventDescription, @@ -135,19 +135,16 @@ export default function ActivityEventCard({ > {/* Avatar with badge */}
    - {event.user.avatarUrl ? ( - {event.user.displayName} - ) : ( -
    - {event.user.displayName[0]} -
    - )} +
    {getEventBadge(event.type)}
    @@ -201,19 +198,16 @@ export default function ActivityEventCard({ {/* Top row: avatar + name + timestamp */}
    - {event.user.avatarUrl ? ( - {event.user.displayName} - ) : ( -
    - {event.user.displayName[0]} -
    - )} +
    {getEventBadge(event.type)}
    diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx index 5106546..d1c1fad 100644 --- a/src/components/ActivityFeed.tsx +++ b/src/components/ActivityFeed.tsx @@ -10,6 +10,7 @@ import { springs, STAGGER_DELAY } from '@/lib/motion'; import { useUnreadActivity } from '@/components/UnreadActivityProvider'; import AlbumArt from '@/components/AlbumArt'; import AllActivityModal from '@/components/AllActivityModal'; +import { SkeletonRows } from '@/components/ui/skeleton-row'; import { formatTimeAgo, getEventRingColor, @@ -17,8 +18,8 @@ import { getEventBgAccent, getEventLabel, getDetailDescription, -} from '@/components/activity-feed-utils'; -import type { ActivityEvent } from '@/lib/activity-utils'; + type ActivityEvent, +} from '@/lib/activity-utils'; // --- Bubble (horizontal scroll item) --- @@ -79,7 +80,7 @@ function EventBubble({ {firstName}

    - {label.text} · {formatTimeAgo(new Date(event.timestamp))} + {label.text} · {formatTimeAgo(new Date(event.timestamp), { compact: true })}

    @@ -148,7 +149,7 @@ function DetailCard({ event, onClose }: Readonly<{ event: ActivityEvent; onClose {event.user.displayName}

    - {formatTimeAgo(new Date(event.timestamp))} ago + {formatTimeAgo(new Date(event.timestamp))}

    @@ -237,17 +238,13 @@ export default function ActivityFeed() { return (
    - {loading - ? [0, 1, 2, 3].map((i) => ( -
    -
    -
    -
    -
    - )) - : events.map((event, i) => ( - - ))} + {loading ? ( + + ) : ( + events.map((event, i) => ( + + )) + )} {/* "See all" button */} {!loading && events.length > 0 && ( diff --git a/src/components/ActivitySnippet.tsx b/src/components/ActivitySnippet.tsx index b56259c..9f345e5 100644 --- a/src/components/ActivitySnippet.tsx +++ b/src/components/ActivitySnippet.tsx @@ -2,11 +2,12 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import Image from 'next/image'; import { m } from 'motion/react'; import { ChevronRight } from 'lucide-react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; import AlbumArt from '@/components/AlbumArt'; +import { SkeletonRows } from '@/components/ui/skeleton-row'; +import { UserAvatar } from '@/components/ui/user-avatar'; import { formatTimeAgo, getEventDescription, getEventSubtext } from '@/lib/activity-utils'; import type { ActivityEvent } from '@/lib/activity-utils'; @@ -48,15 +49,7 @@ export default function ActivitySnippet() { {loading ? (
    - {[0, 1, 2].map((i) => ( -
    -
    -
    -
    -
    -
    -
    - ))} +
    ) : (
    @@ -78,19 +71,14 @@ export default function ActivitySnippet() { className="flex items-center gap-3 p-2.5 rounded-xl hover:bg-white/[0.03] transition-colors" > {/* User avatar */} - {event.user.avatarUrl ? ( - {event.user.displayName} - ) : ( -
    - {event.user.displayName[0]} -
    - )} + {/* Event text */}
    diff --git a/src/components/AllActivityModal.tsx b/src/components/AllActivityModal.tsx index a50d1ba..5f90761 100644 --- a/src/components/AllActivityModal.tsx +++ b/src/components/AllActivityModal.tsx @@ -7,13 +7,14 @@ import { m, AnimatePresence } from 'motion/react'; import { X } from 'lucide-react'; import { springs } from '@/lib/motion'; import AlbumArt from '@/components/AlbumArt'; +import { SkeletonRows } from '@/components/ui/skeleton-row'; import { getEventTextColor, getEventLabel, getListDescription, formatTimeAgo, -} from '@/components/activity-feed-utils'; -import type { ActivityEvent } from '@/lib/activity-utils'; + type ActivityEvent, +} from '@/lib/activity-utils'; export default function AllActivityModal({ onClose }: Readonly<{ onClose: () => void }>) { const router = useRouter(); @@ -66,15 +67,7 @@ export default function AllActivityModal({ onClose }: Readonly<{ onClose: () => {loadingAll ? (
    - {[0, 1, 2, 3, 4, 5].map((i) => ( -
    -
    -
    -
    -
    -
    -
    - ))} +
    ) : allEvents.length === 0 ? (
    @@ -130,7 +123,7 @@ export default function AllActivityModal({ onClose }: Readonly<{ onClose: () => {event.user.displayName}

    - {formatTimeAgo(new Date(event.timestamp))} + {formatTimeAgo(new Date(event.timestamp), { compact: true })}

    diff --git a/src/components/CircleCard.tsx b/src/components/CircleCard.tsx index 8c4677f..1abf1e4 100644 --- a/src/components/CircleCard.tsx +++ b/src/components/CircleCard.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { m } from 'motion/react'; import { Check, Crown, Users, Settings } from 'lucide-react'; import { STAGGER_DELAY } from '@/lib/motion'; +import { UserAvatar } from '@/components/ui/user-avatar'; interface CircleMember { id: string; @@ -175,15 +176,14 @@ function MemberAvatarStack({ members }: Readonly<{ members: CircleMember[] }>) {

    - {member.avatarUrl ? ( - {member.displayName} - ) : ( -
    - {member.displayName[0]} -
    - )} +
    ))} {members.length > 5 && ( diff --git a/src/components/LikedTracksView.tsx b/src/components/LikedTracksView.tsx index 19e3697..f61acbc 100644 --- a/src/components/LikedTracksView.tsx +++ b/src/components/LikedTracksView.tsx @@ -1,13 +1,15 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Image from 'next/image'; import { motion, AnimatePresence } from 'motion/react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; -import { Music, Play } from 'lucide-react'; -import { formatDuration, NowPlayingBars } from '@/components/UnplayedTrackRow'; +import { Search, ExternalLink, Unlink, ArrowRightLeft, Plus } from 'lucide-react'; +import { TrackListRow, TRACK_ROW_GRID_COLUMNS } from '@/components/ui/track-list-row'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; import { toast } from 'sonner'; +import GlassDrawer from '@/components/ui/glass-drawer'; +import type { LikedSyncMode } from '@/app/playlist/[playlistId]/types'; interface LikedTrack { spotifyTrackId: string; @@ -29,36 +31,69 @@ interface LikedTrack { }>; } +interface SpotifyPlaylistOption { + id: string; + name: string; + imageUrl: string | null; + trackCount: number; + ownerId: string; + alreadyImported: boolean; +} + interface LikedTracksViewProps { playlistId: string; likedTracks: LikedTrack[]; likedPlaylistId: string | null; - onPlaylistCreated: (spotifyPlaylistId: string) => void; + likedSyncMode: LikedSyncMode; + likedPlaylistName: string | null; + onSyncConfigured: (config: { + spotifyPlaylistId: string; + mode: LikedSyncMode; + playlistName?: string; + }) => void; + onSyncDisconnected: () => void; playingTrackId: string | null; onPlay: (spotifyTrackUri: string, spotifyTrackId: string) => void; } +// ─── Spotify icon SVG (reused) ───────────────────────────────────────────── + +function SpotifyIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +// ─── Main Component ───────────────────────────────────────────────────────── + export default function LikedTracksView({ playlistId, likedTracks, likedPlaylistId, - onPlaylistCreated, + likedSyncMode, + likedPlaylistName, + onSyncConfigured, + onSyncDisconnected, playingTrackId, onPlay, }: LikedTracksViewProps) { const [creating, setCreating] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + const [showFunnelPicker, setShowFunnelPicker] = useState(false); const [hoveredTrackId, setHoveredTrackId] = useState(null); - async function handleOpenInSpotify() { - if (likedPlaylistId) { - window.open(`https://open.spotify.com/playlist/${likedPlaylistId}`, '_blank'); - return; - } + const hasSync = !!likedPlaylistId; + const effectiveMode = likedSyncMode ?? (hasSync ? 'created' : null); + async function handleSaveToSpotify() { setCreating(true); try { const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'created' }), }); if (!res.ok) { const data = await res.json(); @@ -66,7 +101,11 @@ export default function LikedTracksView({ return; } const data = await res.json(); - onPlaylistCreated(data.spotifyPlaylistId); + onSyncConfigured({ + spotifyPlaylistId: data.spotifyPlaylistId, + mode: 'created', + playlistName: data.playlistName, + }); window.open(data.spotifyPlaylistUrl, '_blank'); toast.success('Liked playlist created on Spotify!'); } catch { @@ -76,6 +115,63 @@ export default function LikedTracksView({ } } + async function handleFunnelSelected(playlist: SpotifyPlaylistOption) { + setShowFunnelPicker(false); + setCreating(true); + try { + const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: 'funnel', + funnelPlaylistId: playlist.id, + funnelPlaylistName: playlist.name, + }), + }); + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || 'Failed to set up funnel'); + return; + } + const data = await res.json(); + onSyncConfigured({ + spotifyPlaylistId: data.spotifyPlaylistId, + mode: 'funnel', + playlistName: data.playlistName, + }); + toast.success(`Liked tracks will funnel to "${playlist.name}"`); + } catch { + toast.error('Failed to set up funnel'); + } finally { + setCreating(false); + } + } + + async function handleDisconnect() { + setDisconnecting(true); + try { + const res = await fetch(`/api/playlists/${playlistId}/liked-playlist`, { + method: 'DELETE', + }); + if (!res.ok) { + toast.error('Failed to disconnect'); + return; + } + onSyncDisconnected(); + toast.success('Sync disconnected'); + } catch { + toast.error('Failed to disconnect'); + } finally { + setDisconnecting(false); + } + } + + function handleOpenInSpotify() { + if (likedPlaylistId) { + window.open(`https://open.spotify.com/playlist/${likedPlaylistId}`, '_blank'); + } + } + if (likedTracks.length === 0) { return (
    @@ -88,30 +184,41 @@ export default function LikedTracksView({ return (
    - {/* Open in Spotify button */} - + {/* Sync configuration area */} + {!hasSync && !creating && ( + setShowFunnelPicker(true)} + /> + )} + + {creating && ( +
    +
    + Setting up sync... +
    + )} - {likedPlaylistId && ( -

    - Your liked playlist syncs automatically -

    + {hasSync && !creating && ( + { + handleDisconnect(); + }} + onDisconnect={handleDisconnect} + /> )} + {/* Funnel playlist picker drawer */} + setShowFunnelPicker(false)} + onSelect={handleFunnelSelected} + /> + {/* Track list */}
    @@ -130,136 +237,297 @@ export default function LikedTracksView({ transition={{ ...springs.gentle, delay: Math.min(index, 10) * STAGGER_DELAY }} className="relative grid items-center gap-3 py-2 px-3 rounded-xl hover:bg-white/4 transition-colors cursor-pointer group" style={{ - gridTemplateColumns: '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto', + gridTemplateColumns: TRACK_ROW_GRID_COLUMNS, }} onClick={() => onPlay(track.spotifyTrackUri, track.spotifyTrackId)} onMouseEnter={() => setHoveredTrackId(track.spotifyTrackId)} onMouseLeave={() => setHoveredTrackId(null)} > - {/* Album art */} -
    - {track.albumImageUrl ? ( - {track.trackName} - ) : ( -
    - -
    - )} + 0 ? ( + + + e.stopPropagation()}> + + {totalPlays}x + + + + {track.memberListenCounts + .filter((m) => m.listenCount > 0) + .map((m) => `${m.displayName}: ${m.listenCount}x`) + .join(', ')} + + + + ) : undefined + } + /> + + ); + })} + +
    +
    + ); +} - {isHovered && ( -
    - -
    - )} +// ─── Sync Mode Selector (no sync configured) ─────────────────────────────── + +function SyncModeSelector({ + onSaveToSpotify, + onFunnel, +}: { + onSaveToSpotify: () => void; + onFunnel: () => void; +}) { + return ( +
    + + + +
    + ); +} + +// ─── Sync Status Banner (sync active) ────────────────────────────────────── + +function SyncStatusBanner({ + mode, + playlistName, + disconnecting, + onOpenInSpotify, + onChange, + onDisconnect, +}: { + mode: 'created' | 'funnel'; + playlistName: string | null; + disconnecting: boolean; + onOpenInSpotify: () => void; + onChange: () => void; + onDisconnect: () => void; +}) { + return ( +
    +
    +
    + {mode === 'funnel' ? ( + + ) : ( + + )} +
    +
    +

    + {mode === 'funnel' ? playlistName || 'Funneling' : playlistName || 'Liked playlist'} +

    +

    + {mode === 'funnel' ? 'New likes funnel here automatically' : 'Syncs automatically'} +

    +
    + +
    + +
    + {mode === 'funnel' && ( + + )} + {mode === 'funnel' && |} + +
    +
    + ); +} + +// ─── Funnel Playlist Picker Drawer ───────────────────────────────────────── + +function FunnelPlaylistPicker({ + open, + onClose, + onSelect, +}: { + open: boolean; + onClose: () => void; + onSelect: (playlist: SpotifyPlaylistOption) => void; +}) { + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(''); + const fetchingRef = useRef(false); - {!isHovered && isPlaying && ( -
    - + useEffect(() => { + if (open && playlists.length === 0 && !fetchingRef.current) { + fetchingRef.current = true; + queueMicrotask(() => setLoading(true)); + fetch('/api/spotify/playlists') + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => null); + if (data?.rateLimited) { + toast.error(data.error || 'Spotify is busy. Try again soon.'); + onClose(); + return; + } + toast.error(data?.error || 'Failed to load playlists'); + return; + } + const data = await res.json(); + setPlaylists(data.playlists ?? []); + }) + .catch(() => toast.error('Could not reach the server.')) + .finally(() => { + fetchingRef.current = false; + setLoading(false); + }); + } + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Filter out Swaplists (marked as alreadyImported) and apply search + const available = playlists + .filter((p) => !p.alreadyImported) + .filter((p) => p.name.toLowerCase().includes(filter.toLowerCase())); + + function handleClose() { + onClose(); + setFilter(''); + } + + return ( + +
    + {/* Search */} +
    +
    + + setFilter(e.target.value)} + placeholder="Filter playlists..." + className="w-full input-glass backdrop-blur-xl" + style={{ paddingLeft: '2.5rem' }} + enterKeyHint="search" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> +
    +
    + + {loading ? ( + + ) : available.length === 0 ? ( +
    +

    + {filter ? 'No playlists match your search' : 'No eligible playlists found'} +

    +
    + ) : ( +
    + {available.map((playlist) => ( + + ))} +
    + )}
    +
    + ); +} + +function PickerSkeleton() { + return ( +
    + {Array.from({ length: 6 }).map((_, i) => ( +
    +
    +
    +
    +
    +
    +
    + ))}
    ); } diff --git a/src/components/OutcastTracksView.tsx b/src/components/OutcastTracksView.tsx index 216f922..70d9eff 100644 --- a/src/components/OutcastTracksView.tsx +++ b/src/components/OutcastTracksView.tsx @@ -1,11 +1,10 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; -import Image from 'next/image'; import { motion, AnimatePresence } from 'motion/react'; import { springs, STAGGER_DELAY } from '@/lib/motion'; -import { Music, Play, Plus, Minus } from 'lucide-react'; -import { formatDuration, NowPlayingBars } from '@/components/UnplayedTrackRow'; +import { Plus, Minus } from 'lucide-react'; +import { TrackListRow, TRACK_ROW_GRID_COLUMNS } from '@/components/ui/track-list-row'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; // --------------------------------------------------------------------------- @@ -130,144 +129,62 @@ export default function OutcastTracksView({ transition={{ ...springs.gentle, delay: Math.min(index, 10) * STAGGER_DELAY }} className="grid items-center gap-3 py-2 px-3 rounded-xl hover:bg-white/4 transition-colors cursor-pointer group" style={{ - gridTemplateColumns: '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto', + gridTemplateColumns: TRACK_ROW_GRID_COLUMNS, }} onClick={() => onPlay(track.spotifyTrackUri, track.spotifyTrackId)} onMouseEnter={() => setHoveredTrackId(track.spotifyTrackId)} onMouseLeave={() => setHoveredTrackId(null)} > - {/* Album art — always grayscale for outcasts */} -
    - {track.albumImageUrl ? ( - {track.trackName} - ) : ( -
    - -
    - )} - - {isHovered && ( -
    - -
    - )} - - {!isHovered && isPlaying && ( -
    - -
    - )} -
    - - {/* Track name + artist */} - - - {/* Album name */} - {track.albumName ? ( - e.stopPropagation()} - > - {track.albumName} - - ) : ( - - )} - - {/* Contributor avatar */} - - - e.stopPropagation()}> -
    - {track.addedBy.avatarUrl ? ( -
    - {track.addedBy.displayName} -
    - ) : track.addedBy.displayName ? ( -
    - - {track.addedBy.displayName.charAt(0).toUpperCase()} - -
    - ) : null} -
    -
    - {track.addedBy.displayName && ( - - Added by {track.addedBy.displayName} - - )} -
    -
    - - {/* Reaction emoji + save button */} -
    - {track.reaction === 'thumbs_down' && ( - - 👎 - - )} - - - - - - - {isSaved ? 'Remove from library' : 'Save to library'} - - - -
    - - {/* Duration */} - - {formatDuration(track.durationMs)} - + + {track.reaction === 'thumbs_down' && ( + + 👎 + + )} + + + + + + + {isSaved ? 'Remove from library' : 'Save to library'} + + + + + } + /> ); })} diff --git a/src/components/PlaylistCard.tsx b/src/components/PlaylistCard.tsx index 1a968d4..ac893d6 100644 --- a/src/components/PlaylistCard.tsx +++ b/src/components/PlaylistCard.tsx @@ -6,24 +6,7 @@ import { motion } from 'motion/react'; import { springs } from '@/lib/motion'; import { useCardTint } from '@/hooks/useAlbumColors'; import { cn } from '@/lib/utils'; - -function timeAgo(dateStr: string): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diff = now - then; - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d ago`; - const weeks = Math.floor(days / 7); - if (weeks < 5) return `${weeks}w ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.floor(days / 365)}y ago`; -} +import { formatTimeAgo } from '@/lib/activity-utils'; function MusicIcon({ className }: { className?: string }) { return ( @@ -351,7 +334,9 @@ function CardContent({ · {playlist.unplayedCount} unheard
    )} - {playlist.lastUpdatedAt && · {timeAgo(playlist.lastUpdatedAt)}} + {playlist.lastUpdatedAt && ( + · {formatTimeAgo(new Date(playlist.lastUpdatedAt))} + )}

    diff --git a/src/components/SpotlightTour.tsx b/src/components/SpotlightTour.tsx index 74068d0..089449a 100644 --- a/src/components/SpotlightTour.tsx +++ b/src/components/SpotlightTour.tsx @@ -76,6 +76,14 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void } const [direction, setDirection] = useState(1); const [targetRect, setTargetRect] = useState(null); const completedRef = useRef(false); + // Track whether we've already mounted — React Strict Mode double-mounts in + // dev, which replays the initial→animate Motion transition a second time. + // Refs persist across the unmount/remount cycle, so the second mount skips + // the entrance animation by passing `initial={false}`. + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => { + setHasMounted(true); // eslint-disable-line react-hooks/set-state-in-effect -- track mount for Strict Mode double-mount + }, []); const currentStep = TOUR_STEPS[step]!; @@ -186,8 +194,8 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void } return ( void } borderRadius: SPOTLIGHT_RADIUS, boxShadow: '0 0 0 2px rgba(56, 189, 248, 0.5), 0 0 20px 4px rgba(56, 189, 248, 0.15)', }} - initial={{ opacity: 0, scale: 0.95 }} + initial={hasMounted ? false : { opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={springs.smooth} key={`ring-${step}`} @@ -245,11 +253,11 @@ export default function SpotlightTour({ onComplete }: { onComplete: () => void } key={step} custom={direction} variants={slideVariants} - initial="enter" + initial={hasMounted ? false : 'enter'} animate="center" exit="exit" transition={springs.smooth} - className="glass rounded-2xl p-5 border border-white/[0.08] max-w-sm mx-auto" + className="glass rounded-2xl p-5 border border-white/8 max-w-sm mx-auto" style={{ background: 'linear-gradient(135deg, rgba(30, 30, 30, 0.95), rgba(20, 20, 20, 0.98))', backdropFilter: 'blur(24px)', diff --git a/src/components/TrackCard.tsx b/src/components/TrackCard.tsx index 48aa019..05dd30b 100644 --- a/src/components/TrackCard.tsx +++ b/src/components/TrackCard.tsx @@ -1,10 +1,14 @@ 'use client'; -import { useState } from 'react'; -import Image from 'next/image'; -import { MessageCircleHeart, Music, Play } from 'lucide-react'; -import { formatDuration, NowPlayingBars, REACTION_EMOJI } from '@/components/UnplayedTrackRow'; +import { MessageCircleHeart } from 'lucide-react'; +import { REACTION_EMOJI } from '@/lib/activity-utils'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; +import { UserAvatar } from '@/components/ui/user-avatar'; +import { + TrackListRow, + TrackListRowContainer, + formatDuration, +} from '@/components/ui/track-list-row'; import type { ActiveListener } from '@/components/NowPlayingIndicator'; interface TrackCardProps { @@ -52,84 +56,18 @@ interface TrackCardProps { onPlay?: () => void; } -function AlbumArtButton({ - albumImageUrl, - trackName, - isPlaying, - onPlay, -}: { - albumImageUrl: string | null; - trackName: string; - isPlaying: boolean; - onPlay?: () => void; -}) { - const [hovered, setHovered] = useState(false); - const [imgError, setImgError] = useState(false); - - return ( -
    { - if (e.key === 'Enter' || e.key === ' ') onPlay?.(); - }} - className="relative w-10 h-10 rounded-lg shrink-0 overflow-hidden cursor-pointer" - data-play-button - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {albumImageUrl && !imgError ? ( - {trackName} setImgError(true)} - /> - ) : ( -
    - -
    - )} - - {hovered && ( -
    - -
    - )} - - {!hovered && isPlaying && ( -
    - -
    - )} -
    - ); -} - function ContributorAvatar({ addedBy }: { addedBy: TrackCardProps['track']['addedBy'] }) { return ( e.stopPropagation()}>
    - {addedBy.avatarUrl ? ( -
    - {addedBy.displayName} -
    - ) : ( -
    - - {addedBy.displayName.charAt(0).toUpperCase()} - -
    - )} +
    Added by {addedBy.displayName} @@ -229,81 +167,38 @@ export default function TrackCard({ }, {}); return ( -
    - {/* Album art with play overlay + NowPlayingBars */} - + - - {/* Track name + artist */} -
    -
    -

    - {track.trackName} -

    - {isPendingReaction && ( + selfManagedAlbumHover + onPlayClick={onPlay} + contributorContent={} + trackNameExtra={ + isPendingReaction ? ( - )} -
    -

    - e.stopPropagation()} - > - {track.artistName} - -

    -
    - - {/* Album name */} - {track.albumName ? ( - e.stopPropagation()} - > - {track.albumName} - - ) : ( - - )} - - {/* Contributor avatar */} - - - {/* Reaction pills + progress */} -
    - -
    - - {/* Duration */} - - {formatDuration(track.durationMs)} - -
    + ) : undefined + } + rightContent={ + + } + /> + ); } + +// Re-export formatDuration for any remaining consumers +export { formatDuration }; diff --git a/src/components/UnplayedTrackRow.tsx b/src/components/UnplayedTrackRow.tsx index 91745e0..7725724 100644 --- a/src/components/UnplayedTrackRow.tsx +++ b/src/components/UnplayedTrackRow.tsx @@ -1,9 +1,8 @@ 'use client'; import { useState, useEffect } from 'react'; -import NextImage from 'next/image'; -import { Play, Music } from 'lucide-react'; -import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; +import { REACTION_EMOJI } from '@/lib/activity-utils'; +import { TrackListRow, TrackListRowContainer } from '@/components/ui/track-list-row'; // --------------------------------------------------------------------------- // Type @@ -26,24 +25,6 @@ export interface UnplayedTrack { reactions: Array<{ reaction: string; count: number }>; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -export const REACTION_EMOJI: Record = { - thumbs_up: '👍', - thumbs_down: '👎', - fire: '🔥', - heart: '❤️', -}; - -export function formatDuration(ms: number | null): string { - if (ms === null) return '—'; - const minutes = Math.floor(ms / 60000); - const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); - return `${minutes}:${seconds}`; -} - // --------------------------------------------------------------------------- // Dominant color extraction from album art // --------------------------------------------------------------------------- @@ -127,43 +108,6 @@ function useDominantColor(imageUrl: string | null): [number, number, number] | n return color; } -// --------------------------------------------------------------------------- -// NowPlayingBars -// --------------------------------------------------------------------------- - -export function NowPlayingBars() { - return ( - <> - - - {( - [ - [0, 60], - [0.2, 100], - [0.4, 40], - ] as const - ).map(([delay, height]) => ( - - ))} - - - ); -} - // --------------------------------------------------------------------------- // UnplayedTrackRow // --------------------------------------------------------------------------- @@ -186,7 +130,6 @@ export function UnplayedTrackRow({ onPlay, }: UnplayedTrackRowProps) { const [hovered, setHovered] = useState(false); - const [imgError, setImgError] = useState(false); const dominantColor = useDominantColor(track.albumImageUrl); // Subtle solid tint from dominant album color @@ -200,12 +143,11 @@ export function UnplayedTrackRow({ : { animation: 'unplayed-thump 3.5s ease-in-out infinite', animationDelay: `${index * 0.2}s` }; return ( -
    setHovered(true)} onMouseLeave={() => setHovered(false)} > - {/* Album art */} -
    - {track.albumImageUrl && !imgError ? ( - setImgError(true)} - /> - ) : ( -
    - -
    - )} - - {hovered && ( -
    - -
    - )} - - {!hovered && isPlaying && ( -
    - -
    - )} -
    - - {/* Track name + artist */} - - - {/* Album name */} - {track.albumName ? ( - e.stopPropagation()} - > - {track.albumName} - - ) : ( - - )} - - {/* Contributor avatar */} - - - e.stopPropagation()}> -
    - {track.addedByAvatarUrl ? ( -
    - -
    - ) : track.addedByName ? ( -
    - - {track.addedByName.charAt(0).toUpperCase()} - -
    - ) : null} -
    -
    - {track.addedByName && ( - Added by {track.addedByName} - )} -
    -
    - - {/* Reactions or swaplist pill */} -
    - {track.reactions.length > 0 ? ( - track.reactions.slice(0, 2).map(({ reaction, count }) => ( - 0 ? ( + track.reactions.slice(0, 2).map(({ reaction, count }) => ( + + {REACTION_EMOJI[reaction] ?? reaction} + {count > 1 && {count}} + + )) + ) : ( + e.stopPropagation()} > - {REACTION_EMOJI[reaction] ?? reaction} - {count > 1 && {count}} - - )) - ) : ( - e.stopPropagation()} - > - {track.playlistName} - - )} -
    - - {/* Duration */} - - {formatDuration(track.durationMs)} - -
    + {track.playlistName} + + ) + } + /> + ); } + +// Re-export shared utilities for backward compatibility +export { formatDuration, NowPlayingBars } from '@/components/ui/track-list-row'; +export { REACTION_EMOJI } from '@/lib/activity-utils'; diff --git a/src/components/UnplayedTracksModal.tsx b/src/components/UnplayedTracksModal.tsx index c2b20dd..78b579f 100644 --- a/src/components/UnplayedTracksModal.tsx +++ b/src/components/UnplayedTracksModal.tsx @@ -6,6 +6,7 @@ import { Play } from 'lucide-react'; import { springs } from '@/lib/motion'; import GlassDrawer from '@/components/ui/glass-drawer'; import { UnplayedTrack, UnplayedTrackRow } from '@/components/UnplayedTrackRow'; +import { SkeletonRows } from '@/components/ui/skeleton-row'; import { usePlayerState } from '@/hooks/usePlayerState'; const PAGE_SIZE = 20; @@ -190,9 +191,7 @@ export default function UnplayedTracksModal({ isOpen, onClose }: UnplayedTracksM {/* Initial loading skeleton */} {loading && (
    - {[0, 1, 2, 3, 4].map((i) => ( -
    - ))} +
    )} @@ -234,9 +233,7 @@ export default function UnplayedTracksModal({ isOpen, onClose }: UnplayedTracksM {/* Loading more skeletons */} {loadingMore && (
    - {[0, 1, 2].map((i) => ( -
    - ))} +
    )}
    diff --git a/src/components/UnplayedTracksWidget.tsx b/src/components/UnplayedTracksWidget.tsx index 22eb0ac..a5559dd 100644 --- a/src/components/UnplayedTracksWidget.tsx +++ b/src/components/UnplayedTracksWidget.tsx @@ -5,28 +5,9 @@ import { m, AnimatePresence } from 'motion/react'; import { Play, ChevronRight } from 'lucide-react'; import { springs } from '@/lib/motion'; import { UnplayedTrack, UnplayedTrackRow } from '@/components/UnplayedTrackRow'; +import { SkeletonRows } from '@/components/ui/skeleton-row'; import { usePlayerState } from '@/hooks/usePlayerState'; -// --------------------------------------------------------------------------- -// Skeleton rows -// --------------------------------------------------------------------------- - -function SkeletonRows({ count }: { count: number }) { - return ( - <> - {Array.from({ length: count }).map((_, i) => ( -
    -
    -
    -
    -
    -
    -
    - ))} - - ); -} - // --------------------------------------------------------------------------- // UnplayedTracksWidget // --------------------------------------------------------------------------- diff --git a/src/components/ui/skeleton-row.tsx b/src/components/ui/skeleton-row.tsx new file mode 100644 index 0000000..91165e6 --- /dev/null +++ b/src/components/ui/skeleton-row.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// SkeletonRow +// --------------------------------------------------------------------------- + +interface SkeletonRowProps { + /** 'track' — album art square + two text lines + duration (used in track lists) + * 'track-simple' — single rounded skeleton bar (used in modals) + * 'activity' — avatar circle + two text lines (used in activity lists) + * 'activity-bubble' — circle + small text bars (used in horizontal bubble scroll) */ + variant?: 'track' | 'track-simple' | 'activity' | 'activity-bubble'; + className?: string; +} + +export function SkeletonRow({ variant = 'track', className }: SkeletonRowProps) { + if (variant === 'track-simple') { + return
    ; + } + + if (variant === 'activity-bubble') { + return ( +
    +
    +
    +
    +
    + ); + } + + if (variant === 'activity') { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + // variant === 'track' (default) + return ( +
    +
    +
    +
    +
    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// SkeletonRows — renders multiple SkeletonRow instances +// --------------------------------------------------------------------------- + +interface SkeletonRowsProps { + count?: number; + variant?: SkeletonRowProps['variant']; + className?: string; +} + +export function SkeletonRows({ count = 3, variant = 'track', className }: SkeletonRowsProps) { + return ( + <> + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..8723600 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,11 @@ +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface SpinnerProps { + size?: number; + className?: string; +} + +export function Spinner({ size = 16, className }: SpinnerProps) { + return ; +} diff --git a/src/components/ui/track-list-row.tsx b/src/components/ui/track-list-row.tsx new file mode 100644 index 0000000..abe5a59 --- /dev/null +++ b/src/components/ui/track-list-row.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useState, type ReactNode } from 'react'; +import Image from 'next/image'; +import { Music, Play } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; + +// --------------------------------------------------------------------------- +// formatDuration — consolidated from UnplayedTrackRow +// --------------------------------------------------------------------------- + +export function formatDuration(ms: number | null): string { + if (ms === null) return '\u2014'; + const minutes = Math.floor(ms / 60000); + const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); + return `${minutes}:${seconds}`; +} + +// --------------------------------------------------------------------------- +// NowPlayingBars — relocated from UnplayedTrackRow +// --------------------------------------------------------------------------- + +export function NowPlayingBars() { + return ( + <> + + + {( + [ + [0, 60], + [0.2, 100], + [0.4, 40], + ] as const + ).map(([delay, height]) => ( + + ))} + + + ); +} + +// --------------------------------------------------------------------------- +// AlbumArt sub-component +// --------------------------------------------------------------------------- + +function AlbumArtCell({ + albumImageUrl, + trackName, + isPlaying, + isHovered: externalHovered, + selfManagedHover, + onPlayClick, + grayscale, + albumImageClassName, +}: { + albumImageUrl: string | null; + trackName: string; + isPlaying: boolean; + isHovered: boolean; + /** When true, the album art cell manages its own hover state (for standalone play buttons) */ + selfManagedHover?: boolean; + /** Click handler for when selfManagedHover is true */ + onPlayClick?: () => void; + grayscale?: boolean; + albumImageClassName?: string; +}) { + const [imgError, setImgError] = useState(false); + const [internalHovered, setInternalHovered] = useState(false); + const isHovered = selfManagedHover ? internalHovered : externalHovered; + + const grayscaleClasses = grayscale ? 'grayscale opacity-60' : ''; + + const interactiveProps = selfManagedHover + ? { + role: 'button' as const, + tabIndex: 0, + 'data-play-button': true, + onClick: onPlayClick, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') onPlayClick?.(); + }, + onMouseEnter: () => setInternalHovered(true), + onMouseLeave: () => setInternalHovered(false), + } + : {}; + + return ( +
    + {albumImageUrl && !imgError ? ( + {trackName} setImgError(true)} + /> + ) : ( +
    + +
    + )} + + {isHovered && ( +
    + +
    + )} + + {!isHovered && isPlaying && ( +
    + +
    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// ContributorAvatar sub-component +// --------------------------------------------------------------------------- + +function ContributorAvatarCell({ + displayName, + avatarUrl, +}: { + displayName: string; + avatarUrl: string | null; +}) { + return ( + + + e.stopPropagation()}> +
    + {avatarUrl ? ( +
    + {displayName} +
    + ) : displayName ? ( +
    + + {displayName.charAt(0).toUpperCase()} + +
    + ) : null} +
    +
    + {displayName && Added by {displayName}} +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// TrackListRow +// --------------------------------------------------------------------------- + +export interface TrackListRowProps { + /** Track name */ + trackName: string; + /** Artist name */ + artistName: string; + /** Album name (nullable) */ + albumName: string | null; + /** Album art URL (nullable) */ + albumImageUrl: string | null; + /** Duration in ms (nullable) */ + durationMs: number | null; + + /** Whether this track is currently playing */ + isPlaying?: boolean; + /** Whether this row is currently hovered (caller-managed state for rows inside motion.div) */ + isHovered?: boolean; + + /** Apply grayscale filter to album art */ + grayscale?: boolean; + /** Extra className applied to the album art Image element */ + albumImageClassName?: string; + /** When true, the album art cell manages its own hover for play overlay (e.g. TrackCard) */ + selfManagedAlbumHover?: boolean; + /** Click handler for play button on album art (used with selfManagedAlbumHover) */ + onPlayClick?: () => void; + + /** Contributor display name for the avatar column */ + contributorName?: string; + /** Contributor avatar URL for the avatar column */ + contributorAvatarUrl?: string | null; + /** Override the contributor column with custom content */ + contributorContent?: ReactNode; + + /** Content to render in the right actions area (between contributor and duration) */ + rightContent?: ReactNode; + /** Extra content to render inside the track name area (e.g. pending reaction icon) */ + trackNameExtra?: ReactNode; +} + +export function TrackListRow({ + trackName, + artistName, + albumName, + albumImageUrl, + durationMs, + isPlaying = false, + isHovered = false, + grayscale, + albumImageClassName, + selfManagedAlbumHover, + onPlayClick, + contributorName, + contributorAvatarUrl, + contributorContent, + rightContent, + trackNameExtra, +}: TrackListRowProps) { + return ( + <> + {/* Album art */} + + + {/* Track name + artist */} +
    +
    +

    + {trackName} +

    + {trackNameExtra} +
    +

    + e.stopPropagation()} + > + {artistName} + +

    +
    + + {/* Album name */} + {albumName ? ( + e.stopPropagation()} + > + {albumName} + + ) : ( + + )} + + {/* Contributor avatar */} + {contributorContent ?? + (contributorName ? ( + + ) : ( +
    + ))} + + {/* Right content / actions */} +
    {rightContent}
    + + {/* Duration */} + + {formatDuration(durationMs)} + + + ); +} + +// --------------------------------------------------------------------------- +// TrackListRowContainer — optional wrapper that provides the grid layout +// --------------------------------------------------------------------------- + +export const TRACK_ROW_GRID_COLUMNS = '40px minmax(0, 2fr) minmax(0, 1fr) 20px auto auto'; + +export interface TrackListRowContainerProps { + children: ReactNode; + className?: string; + style?: React.CSSProperties; + onClick?: (e: React.MouseEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + role?: string; + tabIndex?: number; +} + +export function TrackListRowContainer({ + children, + className, + style, + onClick, + onKeyDown, + onMouseEnter, + onMouseLeave, + role, + tabIndex, +}: TrackListRowContainerProps) { + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role is set dynamically based on onClick +
    + {children} +
    + ); +} diff --git a/src/components/ui/user-avatar.tsx b/src/components/ui/user-avatar.tsx new file mode 100644 index 0000000..e91e8ad --- /dev/null +++ b/src/components/ui/user-avatar.tsx @@ -0,0 +1,33 @@ +'use client'; +import Image from 'next/image'; +import { cn } from '@/lib/utils'; + +interface UserAvatarProps { + src?: string | null; + name?: string | null; + size?: number; // pixel size, default 24 + className?: string; +} + +export function UserAvatar({ src, name, size = 24, className }: UserAvatarProps) { + const initial = name?.[0]?.toUpperCase() || '?'; + return src ? ( + {name + ) : ( +
    + {initial} +
    + ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index cebe8c1..dd4d109 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -26,6 +26,7 @@ export const users = pgTable('users', { autoNegativeReactions: boolean('auto_negative_reactions').notNull().default(true), recentEmojis: text('recent_emojis'), // JSON array of last 3 custom emojis used hasCompletedTour: boolean('has_completed_tour').notNull().default(false), + helpBannerDismissed: boolean('help_banner_dismissed').notNull().default(false), lastDisconnectEmailAt: timestamp('last_disconnect_email_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); @@ -117,6 +118,8 @@ export const playlistMembers = pgTable( .notNull() .references(() => users.id), likedPlaylistId: text('liked_playlist_id'), + likedSyncMode: text('liked_sync_mode'), // 'created' | 'funnel' | null + likedPlaylistName: text('liked_playlist_name'), joinedAt: timestamp('joined_at', { withTimezone: true }).notNull().defaultNow(), lastActivitySeenAt: timestamp('last_activity_seen_at', { withTimezone: true }), }, diff --git a/src/lib/__tests__/polling.integration.test.ts b/src/lib/__tests__/polling.integration.test.ts index ef72b4b..2dfa63f 100644 --- a/src/lib/__tests__/polling.integration.test.ts +++ b/src/lib/__tests__/polling.integration.test.ts @@ -43,7 +43,6 @@ vi.mock('@/lib/spotify-config', () => ({ likedSyncEveryNCycles: 999, pollIntervalMs: 30000, }, - isOverBudget: vi.fn().mockReturnValue(false), })); vi.mock('@/lib/notifications', () => ({ diff --git a/src/lib/__tests__/spotify-budget.test.ts b/src/lib/__tests__/spotify-budget.test.ts index 22d163f..d806212 100644 --- a/src/lib/__tests__/spotify-budget.test.ts +++ b/src/lib/__tests__/spotify-budget.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock logger (pino may not be available in tests) vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); +beforeEach(async () => { + const { _resetAllCircleState } = await import('@/lib/spotify-budget'); + _resetAllCircleState(); +}); + // ─── Rate Limiting (per-circle) ───────────────────────────────────────────── describe('isCircleRateLimited', () => { diff --git a/src/lib/activity-utils.ts b/src/lib/activity-utils.ts index 3bf6213..4b2bee9 100644 --- a/src/lib/activity-utils.ts +++ b/src/lib/activity-utils.ts @@ -38,16 +38,22 @@ export function reactionToEmoji(reaction: string): string { return REACTION_EMOJI[reaction] ?? reaction; } -export function formatTimeAgo(date: Date): string { +export function formatTimeAgo(date: Date, options?: { compact?: boolean }): string { + const compact = options?.compact ?? false; + const suffix = compact ? '' : ' ago'; const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1) return compact ? 'now' : 'just now'; + if (diffMins < 60) return `${diffMins}m${suffix}`; const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; + if (diffHours < 24) return `${diffHours}h${suffix}`; const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 7) return `${diffDays}d${suffix}`; + const diffWeeks = Math.floor(diffDays / 7); + if (diffWeeks < 5) return `${diffWeeks}w${suffix}`; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths}mo${suffix}`; return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } @@ -160,3 +166,127 @@ export function getEventSubtext(event: ActivityEvent): string { return ''; } } + +// --- Event styling helpers (ring colors, text colors, bg accents) --- + +export function getEventRingColor(type: string): string { + switch (type) { + case 'track_added': + return 'ring-green-400/50'; + case 'reaction': + return 'ring-amber-400/50'; + case 'member_joined': + case 'circle_joined': + return 'ring-sky-400/50'; + case 'track_removed': + return 'ring-red-400/30'; + case 'swaplist_created': + case 'circle_created': + return 'ring-purple-400/50'; + default: + return 'ring-white/10'; + } +} + +export function getEventTextColor(type: string): string { + switch (type) { + case 'track_added': + return 'text-green-400'; + case 'reaction': + return 'text-amber-400'; + case 'member_joined': + case 'circle_joined': + return 'text-sky-400'; + case 'track_removed': + return 'text-red-400/70'; + case 'swaplist_created': + case 'circle_created': + return 'text-purple-400'; + default: + return 'text-text-tertiary'; + } +} + +export function getEventBgAccent(type: string): string { + switch (type) { + case 'track_added': + return 'bg-green-400/8'; + case 'reaction': + return 'bg-amber-400/8'; + case 'member_joined': + case 'circle_joined': + return 'bg-sky-400/8'; + case 'track_removed': + return 'bg-red-400/5'; + case 'swaplist_created': + case 'circle_created': + return 'bg-purple-400/8'; + default: + return 'bg-white/5'; + } +} + +// --- Event label & description helpers --- + +export function getEventLabel(event: ActivityEvent): { emoji: string; text: string } { + switch (event.type) { + case 'track_added': + return { emoji: '\uD83C\uDFB5', text: 'added' }; + case 'reaction': + return { emoji: reactionToEmoji(event.data.reaction ?? ''), text: 'reacted' }; + case 'member_joined': + return { emoji: '\uD83D\uDC4B', text: 'joined' }; + case 'track_removed': + return { emoji: '', text: 'removed' }; + case 'swaplist_created': + return { emoji: '\u2728', text: 'new list' }; + case 'circle_joined': + return { emoji: '\uD83D\uDC4B', text: 'joined' }; + case 'circle_created': + return { emoji: '\u2728', text: 'new circle' }; + default: + return { emoji: '', text: '' }; + } +} + +export function getDetailDescription(event: ActivityEvent): string { + switch (event.type) { + case 'track_added': + return `Added a track to ${event.data.playlistName ?? 'a swaplist'}`; + case 'reaction': + return `Reacted ${reactionToEmoji(event.data.reaction ?? '')} to \u201c${event.data.trackName}\u201d`; + case 'member_joined': + return `Joined ${event.data.playlistName ?? 'a swaplist'}`; + case 'track_removed': + return `Removed \u201c${event.data.trackName}\u201d from ${event.data.playlistName ?? 'a swaplist'}`; + case 'swaplist_created': + return `Created a new swaplist`; + case 'circle_joined': + return `Joined ${event.data.circleName ?? 'your circle'}`; + case 'circle_created': + return `Created ${event.data.circleName ?? 'a new circle'}`; + default: + return ''; + } +} + +export function getListDescription(event: ActivityEvent): string { + switch (event.type) { + case 'track_added': + return `${event.data.trackName} \u00b7 ${event.data.artistName}`; + case 'reaction': + return `reacted ${reactionToEmoji(event.data.reaction ?? '')} to \u201c${event.data.trackName}\u201d`; + case 'member_joined': + return `joined ${event.data.playlistName}`; + case 'track_removed': + return `removed \u201c${event.data.trackName}\u201d`; + case 'swaplist_created': + return `created \u201c${event.data.playlistName}\u201d`; + case 'circle_joined': + return `joined ${event.data.circleName ?? 'your circle'}`; + case 'circle_created': + return `created ${event.data.circleName ?? 'a circle'}`; + default: + return ''; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8b124c7..6df8c32 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -114,6 +114,33 @@ export async function requireCircle() { return { user, membership }; } +/** + * Resolve the active circle for a user: prefer the session's activeCircleId + * (if the user is actually a member), otherwise fall back to the user's first + * circle membership. + * + * Returns `{ circleId: string }` or `null` if the user has no memberships. + */ +export async function getActiveOrDefaultCircleId( + userId: string +): Promise<{ circleId: string } | null> { + const session = await getSession(); + const activeId = session.activeCircleId ?? null; + + if (activeId) { + const membership = await db.query.circleMembers.findFirst({ + where: and(eq(circleMembers.userId, userId), eq(circleMembers.circleId, activeId)), + }); + if (membership) return { circleId: activeId }; + } + + // Fall back to first circle membership + const first = await db.query.circleMembers.findFirst({ + where: eq(circleMembers.userId, userId), + }); + return first ? { circleId: first.circleId } : null; +} + /** * Get all circle memberships for a given user, including circle details and host info. */ diff --git a/src/lib/circle-health.ts b/src/lib/circle-health.ts index 1e7ed77..7545acd 100644 --- a/src/lib/circle-health.ts +++ b/src/lib/circle-health.ts @@ -8,6 +8,7 @@ import { circles, circleMembers, users } from '@/db/schema'; import { eq, and, ne } from 'drizzle-orm'; import { logger } from '@/lib/logger'; import { sendEmail } from '@/lib/email'; +import { clearCircleRateLimit } from '@/lib/spotify-budget'; const appUrl = () => process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; @@ -122,12 +123,16 @@ export async function markCircleInvalid(circleId: string): Promise { * Called when the host enters a new Client ID in circle settings. */ export async function beginCircleMigration(circleId: string, newClientId: string): Promise { + // Clear any Spotify rate limit for this circle — rate limits are per Client ID, + // so the old limit doesn't apply to the new Client ID. + clearCircleRateLimit(circleId); + await db .update(circles) .set({ spotifyClientId: newClientId, appStatus: 'migrating' }) .where(eq(circles.id, circleId)); - logger.info({ circleId, newClientId }, '[Swapify] Circle migration started'); + logger.info({ circleId, newClientId }, '[Swapify] Circle migration started — rate limit cleared'); } /** diff --git a/src/lib/email-mockups/DESIGN-NOTES.md b/src/lib/email-mockups/DESIGN-NOTES.md new file mode 100644 index 0000000..de7b88d --- /dev/null +++ b/src/lib/email-mockups/DESIGN-NOTES.md @@ -0,0 +1,73 @@ +# Email Template Design Notes + +## Reference Analysis + +Studied a set of modern email templates with these key design traits: + +### What makes them work + +1. **No container border** — content floats freely on the background. No card outlines, no box shadows framing the whole email. This feels more editorial and less "transactional SaaS." + +2. **Oversized bold headings** — 28-36px, font-weight 800. The heading IS the design. It dominates the email and communicates the message even if nothing else is read. + +3. **Large pill-shaped CTA buttons** — full-width or near-full-width, 18-20px padding, heavy font weight. The button is unmissable and feels tappable on mobile. + +4. **Generous whitespace** — 40-56px gaps between sections. Breathing room makes the few elements feel intentional, not crammed. + +5. **Minimal footer** — just a line of text and an unsubscribe link. No heavy footer boxes or dark backgrounds. + +6. **Typography hierarchy** — only 3 levels: massive heading, medium body, tiny footer. No subheadings, no labels, no visual noise. + +7. **Accent badges/tags** — small colored pill badges above headings to provide context ("Playlist Invite", "New Activity"). + +8. **Bold names in body text** — sender names and key nouns are `` with a lighter color, making body text scannable. + +9. **Gradient accents** — thin gradient lines or gradient buttons add visual interest without adding complexity. + +10. **Logo as punctuation, not hero** — logo is small and either at the very top or very bottom. It doesn't compete with the heading. + +## Our 3 Mockup Approaches + +### Mockup A: "Bold Dark" +- Full dark bg, no card at all +- Logo at top, thin gradient divider below +- 32px bold heading, generous spacing +- Full-width pill CTA button (solid brand blue) +- Closest to the center (dark) template in reference + +### Mockup B: "Soft Glass" +- Dark bg with a subtle frosted-glass panel (no hard border) +- Green accent badge above heading +- Brand-blue colored accent on key word in heading +- Structured detail rows (From/Playlist/Members) — editorial feel +- Rounded-rectangle CTA (16px radius, not full pill) +- Closest to the right template in reference + +### Mockup C: "Clean Contrast" +- Deepest minimalism — no card, no panel, no glass +- Brand pill tag at very top instead of logo +- 36px heading, the largest — with underline accent +- Gradient CTA button (sky blue → lighter blue) +- Logo at bottom as a sign-off +- Secondary "copy link" below button +- Closest to the left template in reference + +## Color Palette Used (Arctic Aurora) + +| Token | Value | Usage | +|-------|-------|-------| +| Background | `#081420` | Email body | +| Glass panel | `rgba(17,28,46,0.6)` | Mockup B panel | +| Brand | `#38BDF8` | Buttons, accents, underlines | +| Brand hover | `#7DD3FC` | Gradient end | +| Accent green | `#4ADE80` | Badge in Mockup B | +| Text primary | `#f1f5f9` / `#f8fafc` | Headings | +| Text body | `#94a3b8` | Body paragraphs | +| Text bold | `#cbd5e1` / `#e2e8f0` | Strong text in body | +| Text muted | `#64748b` / `#475569` | Footer, labels | + +## Font Stack + +Same as app: `-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif` + +Logo wordmark uses `letter-spacing: -0.5px` (tight tracking, logo only). diff --git a/src/lib/email-mockups/iteration-1.html b/src/lib/email-mockups/iteration-1.html new file mode 100644 index 0000000..c9b5082 --- /dev/null +++ b/src/lib/email-mockups/iteration-1.html @@ -0,0 +1,84 @@ + + + + + + Swapify Email - Iteration 1 + + + + + +
    + + + + + +
    + + +

    + You're in.
    + Welcome to
    + "Summer Vibes" +

    + + +

    + Alex just added you to their Swaplist. Drop your best tracks, swipe on theirs, and see who actually has taste. +

    + + + + + +

    + + or copy: swapify.312.dev/join/abc123 + +

    + + +
    + + +
    +

    + You're getting this because you're on Swapify. +

    + Unsubscribe +

    + © 2026 Swapify +

    +
    + +
    + + \ No newline at end of file diff --git a/src/lib/email-mockups/iteration-2.html b/src/lib/email-mockups/iteration-2.html new file mode 100644 index 0000000..da8f30a --- /dev/null +++ b/src/lib/email-mockups/iteration-2.html @@ -0,0 +1,124 @@ + + + + + + Swapify Email - Iteration 2 + + + + +
    + + + + + +
    + + +

    + Alex wants you on
    + Summer Vibes +

    + + +

    + They started a Swaplist and want you in. Drop your favorite tracks, explore theirs, and discover something new together. +

    + + + + + +

    + (don't leave them hanging) +

    + + +
    +

    How it works

    + + +
    +
    + +
    +
    +

    Drop your tracks

    +

    Add songs you've been loving lately

    +
    +
    + + +
    +
    + +
    +
    +

    Swipe & react

    +

    Swipe right if you vibe, left if you don't

    +
    +
    + + +
    +
    + +
    +
    +

    Discover together

    +

    Explore each other's taste and find new favorites

    +
    +
    +
    + + +
    + + +
    + + +

    + You signed up for Swapify, so here we are. Don't want these? Unsubscribe +

    +

    + © 2026 312.dev +

    +
    + +
    + + \ No newline at end of file diff --git a/src/lib/email-mockups/iteration-3.html b/src/lib/email-mockups/iteration-3.html new file mode 100644 index 0000000..08ed64e --- /dev/null +++ b/src/lib/email-mockups/iteration-3.html @@ -0,0 +1,91 @@ + + + + + + Swapify Email - Iteration 3 + + + + +
    + + + + + +
    + + +
    + + New Swaplist + +
    + + +
    +

    + You just got
    added to
    + Summer Vibes +

    +
    + + +

    + Alex added you. Now it's your turn to show up. Drop tracks, swipe on picks, see who's got the range. +

    + + + + + +

    + trust, it's giving good taste already +

    + + +
    + + +
    +

    + You're getting this because you're on Swapify. No spam, we promise. +

    + Unsubscribe +

    + © 2026 Swapify +

    +
    + +
    + + \ No newline at end of file diff --git a/src/lib/email-mockups/mockup-a.html b/src/lib/email-mockups/mockup-a.html new file mode 100644 index 0000000..24c3481 --- /dev/null +++ b/src/lib/email-mockups/mockup-a.html @@ -0,0 +1,75 @@ + + + + + + Swapify Email - Mockup A (Bold Dark) + + + +
    + + + + + +
    + + +

    + You're invited to join
    "Summer Vibes Swaplist" +

    + + +

    + Alex invited you to collaborate on a playlist. Add your favorite tracks, react to theirs, and discover new music together. +

    + + + + + +
    + + +
    +

    + You're receiving this because you have notifications enabled on Swapify. +

    + Unsubscribe +
    + + +

    + © 2026 Swapify +

    + +
    + + \ No newline at end of file diff --git a/src/lib/email-mockups/mockup-b.html b/src/lib/email-mockups/mockup-b.html new file mode 100644 index 0000000..d51808e --- /dev/null +++ b/src/lib/email-mockups/mockup-b.html @@ -0,0 +1,92 @@ + + + + + + Swapify Email - Mockup B (Soft Glass) + + + +
    + + + + + +
    + + +
    + + Playlist Invite + +
    + + +

    + Join "Summer Vibes" on Swapify +

    + + +

    + Alex wants you to collaborate on their Swaplist. Add tracks, swipe on picks, and vibe together. +

    + + +
    +
    + From + Alex Johnson +
    +
    + Playlist + Summer Vibes +
    +
    + Members + 3 people +
    +
    + + + + Accept Invite + +
    + + +
    +

    + You're receiving this because you have notifications enabled. +

    + Unsubscribe +

    + © 2026 Swapify +

    +
    + +
    + + \ No newline at end of file diff --git a/src/lib/email-mockups/mockup-c.html b/src/lib/email-mockups/mockup-c.html new file mode 100644 index 0000000..b9c113f --- /dev/null +++ b/src/lib/email-mockups/mockup-c.html @@ -0,0 +1,86 @@ + + + + + + Swapify Email - Mockup C (Clean Contrast) + + + +
    + + +
    + + Swapify + +
    + + +

    + You've been
    + invited to
    + Summer Vibes +

    + + +

    + Alex added you to a Swaplist. Jump in, add your tracks, and start swiping. +

    + + + + + +

    + + or copy link: swapify.312.dev/join/abc123 + +

    + + +
    + + +
    + +

    + You're receiving this because you enabled notifications. +

    + Unsubscribe +

    + © 2026 Swapify +

    +
    + +
    + + \ No newline at end of file diff --git a/src/lib/email.ts b/src/lib/email.ts deleted file mode 100644 index b01fef4..0000000 --- a/src/lib/email.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Resend } from 'resend'; - -const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; - -export async function sendEmail( - to: string, - subject: string, - body: string, - url?: string, - userId?: string, - buttonLabel?: string -): Promise { - if (!resend) { - console.warn('[Swapify] Resend not configured, skipping email'); - return; - } - - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; - const unsubUrl = userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null; - - try { - await resend.emails.send({ - from: 'Swapify ', - to, - subject: `Swapify: ${subject}`, - html: emailTemplate(subject, body, url, unsubUrl, buttonLabel), - ...(unsubUrl - ? { - headers: { - 'List-Unsubscribe': `<${unsubUrl}>`, - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }, - } - : {}), - }); - } catch (error) { - console.error('[Swapify] Email send failed:', error); - throw error; - } -} - -const LOGO_SVG = ``; - -function emailTemplate( - title: string, - body: string, - url?: string, - unsubUrl?: string | null, - buttonLabel?: string -): string { - const year = new Date().getFullYear(); - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; - return ` - - - - - - - -
    - - -
    - - -
    - - - - - -
    - - -
    -

    ${title}

    -

    ${body}

    - ${url ? `` : ''} -
    - - -
    -

    - You're receiving this because you enabled notifications on Swapify.${unsubUrl ? `
    Unsubscribe` : ''} -

    -
    - -
    - - -

    - © ${year} Swapify -

    - -
    - -`; -} diff --git a/src/lib/email/assets.ts b/src/lib/email/assets.ts new file mode 100644 index 0000000..f88ff39 --- /dev/null +++ b/src/lib/email/assets.ts @@ -0,0 +1,41 @@ +const BASE_PATH = '/email-assets'; + +/** + * Build a full asset URL for use in emails. + * When `relative` is true (for dev preview), returns just the path. + */ +function assetUrl(filename: string, relative = false): string { + if (relative) return `${BASE_PATH}/${filename}`; + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; + return `${baseUrl}${BASE_PATH}/${filename}`; +} + +export function getEmailAssets(relative = false) { + return { + logoLockup: assetUrl('logo-lockup.png', relative), + logoWhite: assetUrl('logo-white.png', relative), + // Feature row icons + iconMusic: assetUrl('icon-music.png', relative), + iconSwipe: assetUrl('icon-swipe.png', relative), + iconDiscover: assetUrl('icon-discover.png', relative), + // Button action icons (light = white, dark = dark text) + btnPlay: assetUrl('btn-play.png', relative), + btnPlayDark: assetUrl('btn-play-dark.png', relative), + btnCheck: assetUrl('btn-check.png', relative), + btnCheckDark: assetUrl('btn-check-dark.png', relative), + btnSliders: assetUrl('btn-sliders.png', relative), + btnSlidersDark: assetUrl('btn-sliders-dark.png', relative), + btnLink: assetUrl('btn-link.png', relative), + btnLinkDark: assetUrl('btn-link-dark.png', relative), + btnHeadphones: assetUrl('btn-headphones.png', relative), + btnHeadphonesDark: assetUrl('btn-headphones-dark.png', relative), + // Hand-drawn underline accents + underlineBlue: assetUrl('underline-blue.png', relative), + underlineGreen: assetUrl('underline-green.png', relative), + underlineOrange: assetUrl('underline-orange.png', relative), + underlineLime: assetUrl('underline-lime.png', relative), + }; +} + +/** Default assets with full URLs (for real emails) */ +export const emailAssets = getEmailAssets(false); diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts new file mode 100644 index 0000000..c6f52b3 --- /dev/null +++ b/src/lib/email/index.ts @@ -0,0 +1,13 @@ +export { sendEmail, sendTypedEmail } from './send'; +export { renderEmail } from './templates'; +export { + playlistInviteData, + circleInviteData, + emailVerifyData, + disconnectData, + circlePausedHostData, + circlePausedMemberData, + circleOnlineData, + notificationData, +} from './templates'; +export type { EmailType, TemplateData } from './types'; diff --git a/src/lib/email/send.ts b/src/lib/email/send.ts new file mode 100644 index 0000000..9d97a54 --- /dev/null +++ b/src/lib/email/send.ts @@ -0,0 +1,81 @@ +import { Resend } from 'resend'; +import { renderEmail } from './templates'; +import type { TemplateData } from './types'; + +const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; + +/** + * Send an email using the new template engine. + * Drop-in replacement for the old sendEmail() — same signature. + */ +export async function sendEmail( + to: string, + subject: string, + body: string, + url?: string, + userId?: string, + buttonLabel?: string +): Promise { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; + const unsubUrl = userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null; + + const data: TemplateData = { + title: subject, + body, + url, + buttonLabel, + unsubUrl, + }; + + await sendRenderedEmail(to, subject, data, unsubUrl); +} + +/** + * Send a typed email with full TemplateData control. + */ +export async function sendTypedEmail( + to: string, + subject: string, + data: TemplateData, + userId?: string +): Promise { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; + const unsubUrl = + data.unsubUrl ?? (userId ? `${baseUrl}/api/email/unsubscribe?uid=${userId}` : null); + + await sendRenderedEmail(to, subject, { ...data, unsubUrl }, unsubUrl); +} + +async function sendRenderedEmail( + to: string, + subject: string, + data: TemplateData, + unsubUrl: string | null +): Promise { + if (!resend) { + console.warn('[Swapify] Resend not configured, skipping email'); + return; + } + + const html = renderEmail(data); + + try { + await resend.emails.send({ + from: 'Swapify ', + to, + subject: `Swapify: ${subject}`, + html, + ...(unsubUrl + ? { + headers: { + 'List-Unsubscribe': `<${unsubUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, + } + : {}), + }); + } catch (error) { + console.error('[Swapify] Email send failed:', error); + throw error; + } +} diff --git a/src/lib/email/templates.ts b/src/lib/email/templates.ts new file mode 100644 index 0000000..0781575 --- /dev/null +++ b/src/lib/email/templates.ts @@ -0,0 +1,375 @@ +import type { TemplateData } from './types'; +import { emailAssets, getEmailAssets } from './assets'; + +type Assets = ReturnType; + +interface RenderOptions { + /** Use relative asset URLs (for dev preview) */ + relativeAssets?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function hexToRgba(hex: string, alpha: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +/** + * Returns true if the accent color is "light" enough to need dark text on a + * button (orange, lime, light green, etc.). + */ +function needsDarkText(hex: string): boolean { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + // Simple perceived-brightness check (ITU-R BT.601) + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 140; +} + +// --------------------------------------------------------------------------- +// renderEmail — produces the full HTML document for any email type +// --------------------------------------------------------------------------- + +export function renderEmail(data: TemplateData, options?: RenderOptions): string { + const year = new Date().getFullYear(); + const assets: Assets = options?.relativeAssets ? getEmailAssets(true) : emailAssets; + + const accentColor = data.accentColor || '#38BDF8'; + const accentColorLight = data.accentColorLight || '#7DD3FC'; + const buttonTextColor = needsDarkText(accentColor) ? '#0c1929' : '#f8fafc'; + + const buttonLabel = data.buttonLabel || 'Open Swapify'; + const useDarkIcon = needsDarkText(accentColor); + const btnIconMap: Record = { + play: useDarkIcon ? assets.btnPlayDark : assets.btnPlay, + check: useDarkIcon ? assets.btnCheckDark : assets.btnCheck, + sliders: useDarkIcon ? assets.btnSlidersDark : assets.btnSliders, + link: useDarkIcon ? assets.btnLinkDark : assets.btnLink, + headphones: useDarkIcon ? assets.btnHeadphonesDark : assets.btnHeadphones, + }; + const btnIconUrl = btnIconMap[data.buttonIcon || 'play']; + const ctaButton = data.url + ? ` + + + +
    + ${buttonLabel}  +
    ` + : ''; + + const aside = data.aside + ? `

    ${data.aside}

    ` + : ''; + + const featureRows = data.showFeatureRows ? buildFeatureRows(assets) : ''; + + const unsubLink = data.unsubUrl + ? ` Don't want these? Unsubscribe` + : ''; + + const badgeHtml = data.badge + ? ` + + + ${data.badge} + + ` + : ''; + + let html = ` + + + + + + + + Swapify + + + + + + +
    + + + + + + + + + + ${badgeHtml} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Swapify +
    +
    +
    +

    ${data.title}

    +
    +

    ${data.body}

    +
    ${ctaButton}
    ${aside}
    ${featureRows}
    +
    +
    + Swapify +

    You signed up for Swapify, so here we are.${unsubLink}

    +

    © ${year} 312.dev

    +
    +
    + +`; + + // Resolve inline asset paths (underline/icon images in title HTML) + // In dev preview keep relative; in production make absolute + if (!options?.relativeAssets) { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://swapify.312.dev'; + html = html.replaceAll('/email-assets/', baseUrl + '/email-assets/'); + } + return html; +} + +// --------------------------------------------------------------------------- +// Feature rows — "How it works" section (table-based for Outlook compat) +// --------------------------------------------------------------------------- + +function buildFeatureRows(assets: Assets): string { + const rows = [ + { + icon: assets.iconMusic, + title: 'Drop your tracks', + description: "Add songs you've been loving lately", + }, + { + icon: assets.iconSwipe, + title: 'Swipe & react', + description: "Swipe right if you vibe, left if you don't", + }, + { + icon: assets.iconDiscover, + title: 'Discover together', + description: "Explore each other's taste and find new favorites", + }, + ]; + + const rowsHtml = rows + .map( + (row) => ` + + + + +

    ${row.title}

    +

    ${row.description}

    + + ` + ) + .join('\n '); + + return ` + + + + + + +
    +
    +
    + + ${rowsHtml} +
    +
    `; +} + +// --------------------------------------------------------------------------- +// Per-type content builders +// --------------------------------------------------------------------------- + +export function playlistInviteData( + inviterName: string, + playlistName: string, + url: string, + description?: string +): TemplateData { + const accentColor = '#38BDF8'; + const accentColorLight = '#7DD3FC'; + const descHtml = description ? ' ' + description + '' : ''; + + return { + title: + '' + + inviterName + + ' wants you on
    ' + + playlistName + + '', + body: + 'They started a Swaplist and want you in. Drop your favorite tracks, explore theirs, and discover something new together.' + + descHtml, + url, + buttonLabel: 'Jump in', + showFeatureRows: true, + aside: "(don't leave them hanging)", + badge: 'SWAPLIST INVITE', + accentColor, + accentColorLight, + }; +} + +export function circleInviteData( + inviterName: string, + circleName: string, + url: string +): TemplateData { + const accentColor = '#38BDF8'; + const accentColorLight = '#7DD3FC'; + + return { + title: + '' + + inviterName + + ' invited you to
    ' + + circleName + + '', + body: "You're in. Join the circle, start sharing playlists, and discover what everyone's been listening to.", + url, + buttonLabel: 'Jump in', + showFeatureRows: true, + aside: "(they're waiting for you)", + badge: 'CIRCLE INVITE', + accentColor, + accentColorLight, + }; +} + +export function emailVerifyData(url: string): TemplateData { + const accentColor = '#4ADE80'; + const accentColorLight = '#86EFAC'; + + return { + title: 'Confirm your
    email address', + body: "Tap below to verify your email for Swapify. If you didn't request this, you can safely ignore it.", + url, + buttonLabel: 'Verify Email', + buttonIcon: 'check', + badge: 'VERIFY', + accentColor, + accentColorLight, + }; +} + +export function disconnectData(url: string): TemplateData { + const accentColor = '#FB923C'; + const accentColorLight = '#FDBA74'; + + return { + title: 'You\'ve been
    disconnected', + body: "We couldn't reach Spotify on your behalf. This usually happens when your session expires or you revoke access. Log back in to reconnect.", + url, + buttonLabel: 'Reconnect', + buttonIcon: 'link', + badge: 'ACTION NEEDED', + accentColor, + accentColorLight, + }; +} + +export function circlePausedHostData(circleName: string, url: string): TemplateData { + const accentColor = '#FB923C'; + const accentColorLight = '#FDBA74'; + + return { + title: 'Your Spotify app
    needs attention', + body: + 'The Spotify developer app connected to your circle "' + + circleName + + '" is no longer responding. Your circle has been paused. Head to settings to connect a new one.', + url, + buttonLabel: 'Go to Settings', + buttonIcon: 'sliders', + badge: 'ACTION NEEDED', + accentColor, + accentColorLight, + }; +} + +export function circlePausedMemberData(circleName: string, url: string): TemplateData { + const accentColor = '#FB923C'; + const accentColorLight = '#FDBA74'; + + return { + title: 'Your circle has
    been paused', + body: + '"' + + circleName + + '" has been paused because the host\'s Spotify app stopped responding. Hang tight — the host can reconnect it, or you can join another circle.', + url, + buttonLabel: 'Open Swapify', + buttonIcon: 'headphones', + badge: 'HEADS UP', + accentColor, + accentColorLight, + }; +} + +export function circleOnlineData(circleName: string, url: string): TemplateData { + const accentColor = '#c4f441'; + const accentColorLight = '#d9f99d'; + + return { + title: + circleName + + 'is back online', + body: 'The host reconnected the Spotify app. Log in to Swapify and reconnect your account to pick up where you left off.', + url, + buttonLabel: 'Reconnect', + buttonIcon: 'link', + badge: 'GOOD NEWS', + accentColor, + accentColorLight, + }; +} + +export function notificationData(title: string, body: string, url?: string): TemplateData { + return { title, body, url, buttonLabel: 'Open Swapify' }; +} diff --git a/src/lib/email/types.ts b/src/lib/email/types.ts new file mode 100644 index 0000000..07a0816 --- /dev/null +++ b/src/lib/email/types.ts @@ -0,0 +1,36 @@ +export type EmailType = + | 'playlist-invite' + | 'circle-invite' + | 'email-verify' + | 'disconnect' + | 'circle-paused-host' + | 'circle-paused-member' + | 'circle-online' + | 'notification'; + +export interface TemplateData { + /** Bold heading text (HTML supported for accent spans) */ + title: string; + /** Body paragraph (supports tags) */ + body: string; + /** CTA button URL */ + url?: string; + /** CTA button text (default: "Open Swapify") */ + buttonLabel?: string; + /** Button icon key (default: 'play') */ + buttonIcon?: 'play' | 'check' | 'sliders' | 'link' | 'headphones'; + /** Unsubscribe URL (auto-generated from userId if not provided) */ + unsubUrl?: string | null; + /** Show "How it works" feature rows (invite emails only) */ + showFeatureRows?: boolean; + /** Italic aside text below CTA (e.g. "(don't leave them hanging)") */ + aside?: string; + /** Primary accent color (hex, e.g. "#38BDF8") */ + accentColor?: string; + /** Secondary/lighter accent for gradients */ + accentColorLight?: string; + /** Small uppercase badge text above heading (e.g. "SWAPLIST INVITE") */ + badge?: string; + /** Badge background color (10% opacity version auto-computed in template) */ + badgeColor?: string; +} diff --git a/src/lib/polling-audit.ts b/src/lib/polling-audit.ts index 664e3a9..c51bc2f 100644 --- a/src/lib/polling-audit.ts +++ b/src/lib/polling-audit.ts @@ -218,11 +218,41 @@ async function adoptExternalTrack( // ─── Liked Playlist Sync ───────────────────────────────────────────────── +/** Clear all liked-playlist sync columns when the destination no longer exists. */ +async function clearDeletedLikedPlaylist(playlistId: string, userId: string): Promise { + const membership = await db.query.playlistMembers.findFirst({ + where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, userId)), + }); + if (membership) { + await db + .update(playlistMembers) + .set({ likedPlaylistId: null, likedSyncMode: null, likedPlaylistName: null }) + .where(eq(playlistMembers.id, membership.id)); + } +} + +/** Batch-add and batch-remove URIs on a Spotify playlist (100 at a time). */ +async function applyPlaylistDiff( + userId: string, + circleId: string, + spotifyPlaylistId: string, + toAdd: string[], + toRemove: string[] +): Promise { + for (let i = 0; i < toAdd.length; i += 100) { + await addItemsToPlaylist(userId, circleId, spotifyPlaylistId, toAdd.slice(i, i + 100)); + } + for (let i = 0; i < toRemove.length; i += 100) { + await removeItemsFromPlaylist(userId, circleId, spotifyPlaylistId, toRemove.slice(i, i + 100)); + } +} + export async function syncLikedPlaylist( userId: string, circleId: string, playlistId: string, - likedPlaylistId: string + likedPlaylistId: string, + mode: 'created' | 'funnel' = 'created' ): Promise { // Get user's liked reactions const likedReactions = await db.query.trackReactions.findMany({ @@ -252,16 +282,7 @@ export async function syncLikedPlaylist( spotifyItems = await getPlaylistItems(userId, circleId, likedPlaylistId); } catch (error) { if (String(error).includes('404') || String(error).includes('Not Found')) { - // Playlist was deleted — clear likedPlaylistId - const membership = await db.query.playlistMembers.findFirst({ - where: and(eq(playlistMembers.playlistId, playlistId), eq(playlistMembers.userId, userId)), - }); - if (membership) { - await db - .update(playlistMembers) - .set({ likedPlaylistId: null }) - .where(eq(playlistMembers.id, membership.id)); - } + await clearDeletedLikedPlaylist(playlistId, userId); return false; } throw error; @@ -269,20 +290,12 @@ export async function syncLikedPlaylist( const spotifyUris = new Set(spotifyItems.map((item) => item.item.uri)); - // Diff + // Diff — funnel mode is additive only (never remove tracks from the destination) const toAdd = [...desiredUris].filter((uri) => !spotifyUris.has(uri)); - const toRemove = [...spotifyUris].filter((uri) => !desiredUris.has(uri)); + const toRemove = + mode === 'created' ? [...spotifyUris].filter((uri) => !desiredUris.has(uri)) : []; - if (toAdd.length > 0) { - for (let i = 0; i < toAdd.length; i += 100) { - await addItemsToPlaylist(userId, circleId, likedPlaylistId, toAdd.slice(i, i + 100)); - } - } - if (toRemove.length > 0) { - for (let i = 0; i < toRemove.length; i += 100) { - await removeItemsFromPlaylist(userId, circleId, likedPlaylistId, toRemove.slice(i, i + 100)); - } - } + await applyPlaylistDiff(userId, circleId, likedPlaylistId, toAdd, toRemove); return true; } @@ -301,11 +314,13 @@ export async function syncAllLikedPlaylists(): Promise { if (isCircleRateLimited(playlist.circleId) || isCircleOverBudget(playlist.circleId)) break; try { + const syncMode = (member.likedSyncMode as 'created' | 'funnel') ?? 'created'; await syncLikedPlaylist( member.userId, playlist.circleId, member.playlistId, - member.likedPlaylistId! + member.likedPlaylistId!, + syncMode ); } catch (error) { if (error instanceof TokenInvalidError) { diff --git a/src/lib/spotify-budget.ts b/src/lib/spotify-budget.ts index f98c758..872e84d 100644 --- a/src/lib/spotify-budget.ts +++ b/src/lib/spotify-budget.ts @@ -189,5 +189,10 @@ export function loadRateLimits(): void { } } +/** Reset all in-memory circle state. For testing only. */ +export function _resetAllCircleState(): void { + circleStates.clear(); +} + // Load on module initialization loadRateLimits(); diff --git a/src/lib/spotify-config.ts b/src/lib/spotify-config.ts index 6181c1f..62c1eaf 100644 --- a/src/lib/spotify-config.ts +++ b/src/lib/spotify-config.ts @@ -93,25 +93,3 @@ export { isCircleOverBudget, waitForCircleBudget, } from '@/lib/spotify-budget'; - -/** @deprecated Use isCircleOverBudget(circleId) instead */ -export function isOverBudget(): boolean { - // Legacy: conservative — returns false since we can't check without a circleId. - // Callers should migrate to isCircleOverBudget(circleId). - return false; -} - -/** @deprecated Use getCircleCallsInWindow(circleId) instead */ -export function getCallsInWindow(): number { - return 0; -} - -/** @deprecated Use trackCircleApiCall(circleId) instead */ -export function trackSpotifyApiCall(): void { - // No-op — callers should migrate to trackCircleApiCall(circleId) -} - -/** @deprecated Use waitForCircleBudget(circleId) instead */ -export async function waitForBudget(): Promise { - // No-op — callers should migrate to waitForCircleBudget(circleId) -} diff --git a/src/lib/spotify-errors.ts b/src/lib/spotify-errors.ts new file mode 100644 index 0000000..0e364f9 --- /dev/null +++ b/src/lib/spotify-errors.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { SpotifyRateLimitError, AppInvalidError, TokenInvalidError } from '@/lib/spotify-core'; + +/** + * Map a known Spotify error to an appropriate JSON response. + * Returns a NextResponse for handled errors, or null if the error is unrecognized. + */ +export function handleSpotifyError(err: unknown): NextResponse | null { + if (err instanceof SpotifyRateLimitError) { + return NextResponse.json( + { + error: 'Spotify is a bit busy right now. Please try again in a minute.', + rateLimited: true, + }, + { status: 429 } + ); + } + if (err instanceof AppInvalidError) { + return NextResponse.json( + { error: "This circle's Spotify app is no longer responding.", appInvalid: true }, + { status: 503 } + ); + } + if (err instanceof TokenInvalidError) { + return NextResponse.json( + { error: 'Your Spotify session has expired. Please reconnect.', needsReauth: true }, + { status: 401 } + ); + } + return null; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 1b702a8..6d8803d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -30,10 +30,6 @@ export function formatPlaylistName(memberNames: string[], groupName?: string): s return `${initials} Swapify`; } -export function suggestGroupNames(): string[] { - return ['Squad', 'Our', 'The Crew', 'Homies', 'Fam', 'Gang', 'Team', 'Club']; -} - export function needsGroupName(memberCount: number): boolean { return memberCount > 3; }