diff --git a/.github/ISSUE_TEMPLATE/pbi.md b/.github/ISSUE_TEMPLATE/pbi.md new file mode 100644 index 0000000..b090d66 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pbi.md @@ -0,0 +1,37 @@ +--- +name: "Product Backlog Item" +about: "Create a new Product Backlog Item in INVEST format" +title: " [PBI]" +labels: ["PBI"] +assignees: [] +--- + +## User Story + +As a , I want so that . + +- **Persona**: +- **Feature**: +- **Business Value**: + +--- + +## Reference(s) + +- Link(s) to design docs, wireframes, or related PBIs if any + +--- + +## Tasks + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +--- + +## Acceptance Criteria + +- [ ] Clear condition 1 that must be true +- [ ] Clear condition 2 that must be true +- [ ] Any error handling or edge cases diff --git a/.gitignore b/.gitignore index df9f191..824605d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,8 +47,6 @@ __pycache__/ .coverage htmlcov/ .mypy_cache/ -.pytest_cache/ -.coverage backend/htmlcov/ backend/coverage.xml @@ -85,14 +83,11 @@ sprint-*-assignee-breakdown.txt .settings/ # OS -.DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db -Thumbs.db - # Testing coverage/ *.lcov @@ -100,6 +95,14 @@ coverage/ # Build outputs dist/ -*.tsbuildinfo +# Private planning and documentation folders +issues/ +old-pbi/ +pbi-77/ +pbi-72/ +memory-bank/ +.cursorrules + +# Issues folder issues/ diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 2e2dd79..df9f191 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,53 +1,78 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage -/test-results -/playwright-report -/playwright/.cache - -# next.js -/.next/ -/out/ - -# production -/build +# next / build +.next/ +out/ +build/ -# misc -.DS_Store -*.pem -Thumbs.db -Desktop.ini +# deps +node_modules/ -# debug +# logs +*.log npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env .env* .env.local .env.development.local .env.test.local .env.production.local -# vercel -.vercel - -# typescript +# misc +.DS_Store +.vercel/ *.tsbuildinfo -next-env.d.ts +Thumbs.db +Desktop.ini + +# test files +clean.txt +bad.txt +secret.txt + +# Python +venv/ +.venv/ +backend/.venv/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# pytest +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.pytest_cache/ +.coverage +backend/htmlcov/ +backend/coverage.xml + +# Playwright +playwright-report/ +test-results/ +playwright/.cache/ + +# docs (if you want to ignore extracted wireframes) +docs/ + +# Cursor/IDE +.cursorignore +.claude/ + +# Scripts and temporary files +get-issues.ps1 +update-*.ps1 +issue_*_update.json +*.ps1 + +# Sprint/assignee files (if temporary) +sprint-*-assignee-breakdown.txt +*-assignee-breakdown.txt # IDE .idea/ @@ -55,14 +80,26 @@ next-env.d.ts *.swp *.swo *~ +.project +.classpath +.settings/ # OS .DS_Store .DS_Store? ._* +.Spotlight-V100 +.Trashes +ehthumbs.db Thumbs.db -# Testing outputs -/coverage/ +# Testing +coverage/ *.lcov .nyc_output/ + +# Build outputs +dist/ +*.tsbuildinfo + +issues/ diff --git a/apps/web/DEPLOYMENT_GOOGLE_YOUTUBE_SETUP.md b/apps/web/DEPLOYMENT_GOOGLE_YOUTUBE_SETUP.md new file mode 100644 index 0000000..51ce823 --- /dev/null +++ b/apps/web/DEPLOYMENT_GOOGLE_YOUTUBE_SETUP.md @@ -0,0 +1,274 @@ +# Google/YouTube OAuth Setup Guide for Supabase + +## Overview +This guide walks you through setting up Google OAuth with Supabase to enable YouTube integration in your Vybe application. + +--- + +## Step 1: Create Google OAuth Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Select your project (or create a new one) +3. Navigate to **APIs & Services > Credentials** +4. Click **Create Credentials > OAuth client ID** +5. If prompted, configure the OAuth consent screen first: + - Choose **External** (unless you have a Google Workspace) + - Fill in required fields: + - App name: "Vybe" + - User support email: Your email + - Developer contact: Your email + - Add scopes: + - `https://www.googleapis.com/auth/userinfo.email` + - `https://www.googleapis.com/auth/userinfo.profile` + - `https://www.googleapis.com/auth/youtube.readonly` + - `https://www.googleapis.com/auth/youtube.force-ssl` + - **IMPORTANT:** Add test users (see Step 1.5 below) + - Save and continue + +5.5. **Add Test Users (CRITICAL for Testing Mode):** + - After creating the OAuth consent screen, go to **APIs & Services > OAuth consent screen** + - Scroll down to **Test users** section + - Click **+ ADD USERS** + - Add your email address: `vasanth.anbukumar@gmail.com` (or any email you'll use to test) + - Add any other test user emails + - Click **Save** + - **Note:** Only these test users can access the app while it's in testing mode + +6. Create OAuth Client ID: + - Application type: **Web application** + - Name: "Vybe Web Client" + - **Authorized redirect URIs**: Add these: + ``` + https://.supabase.co/auth/v1/callback + http://localhost:3000/auth/callback (for local development) + ``` + - Click **Create** + - **Save your Client ID and Client Secret** (you'll need these) + +--- + +## Step 2: Enable YouTube Data API v3 + +1. In Google Cloud Console, go to **APIs & Services > Library** +2. Search for "YouTube Data API v3" +3. Click on it and click **Enable** +4. This allows your app to access YouTube data + +--- + +## Step 3: Configure Supabase + +1. Go to your [Supabase Dashboard](https://supabase.com/dashboard) +2. Select your project +3. Navigate to **Authentication > Providers** +4. Find **Google** in the list +5. Click to enable and configure: + - **Enable Google provider**: Toggle ON + - **Client ID (for OAuth)**: Paste your Google Client ID + - **Client Secret (for OAuth)**: Paste your Google Client Secret + - **Authorized Client IDs**: Leave empty (or add if you have multiple) +6. Click **Save** + +--- + +## Step 4: Add Redirect URI to Google Console + +**CRITICAL:** After setting up Supabase, you need to add Supabase's redirect URI to Google: + +1. Go back to [Google Cloud Console](https://console.cloud.google.com/) +2. Navigate to **APIs & Services > Credentials** +3. Click on your OAuth 2.0 Client ID +4. Under **Authorized redirect URIs**, add: + ``` + https://.supabase.co/auth/v1/callback + ``` + Replace `` with your actual Supabase project reference. + + You can find your project ref in: + - Supabase Dashboard > Settings > API + - It's the part before `.supabase.co` in your Supabase URL + +5. Click **Save** + +--- + +## Step 5: Environment Variables + +Add these to your `.env.local` (for local) and production environment: + +```bash +# Google OAuth +GOOGLE_CLIENT_ID=your_google_client_id_here +GOOGLE_CLIENT_SECRET=your_google_client_secret_here + +# Supabase (should already exist) +NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +``` + +--- + +## Step 6: Verify Database Tables + +Ensure you have a `youtube_tokens` table for storing tokens: + +```sql +-- Check if table exists +SELECT * FROM information_schema.tables +WHERE table_name = 'youtube_tokens'; + +-- If it doesn't exist, create it: +CREATE TABLE IF NOT EXISTS youtube_tokens ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + access_token TEXT NOT NULL, + refresh_token TEXT, + expires_at BIGINT, + scope TEXT, + token_type TEXT DEFAULT 'Bearer', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Enable RLS +ALTER TABLE youtube_tokens ENABLE ROW LEVEL SECURITY; + +-- Create policies +CREATE POLICY "Users can insert their own tokens" +ON youtube_tokens FOR INSERT +WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their own tokens" +ON youtube_tokens FOR UPDATE +USING (auth.uid() = user_id); + +CREATE POLICY "Users can read their own tokens" +ON youtube_tokens FOR SELECT +USING (auth.uid() = user_id); +``` + +--- + +## Step 7: Testing the Setup + +1. **Local Testing:** + - Start your dev server: `npm run dev` + - Navigate to `/sign-in` or `/library` + - Click "Sign in with Google" + - You should be redirected to Google's consent screen + - After authorizing, you should be redirected back to your app + +2. **Check Token Storage:** + ```sql + SELECT user_id, + CASE WHEN access_token IS NOT NULL THEN 'has token' ELSE 'no token' END as token_status, + CASE WHEN refresh_token IS NOT NULL THEN 'has refresh' ELSE 'no refresh' END as refresh_status, + expires_at + FROM youtube_tokens + WHERE user_id = 'your-user-id'; + ``` + +3. **Check Console Logs:** + Look for these messages in your server logs: + - `[callback] Storing Google tokens: ...` + - `[callback] Successfully stored YouTube tokens` + +--- + +## Common Issues & Solutions + +### Issue 1: "redirect_uri_mismatch" Error + +**Problem:** Google says the redirect URI doesn't match. + +**Solution:** +1. Check that you added the exact Supabase redirect URI to Google Console: + ``` + https://.supabase.co/auth/v1/callback + ``` +2. Make sure there are no trailing slashes +3. Verify your Supabase project ref is correct + +### Issue 2: "access_denied" or "has not completed Google verification" Error + +**Problem:** User is not a test user, or OAuth consent screen isn't configured. + +**Solution:** +1. **If app is in Testing mode:** + - Go to Google Cloud Console > **APIs & Services > OAuth consent screen** + - Scroll to **Test users** section + - Click **+ ADD USERS** + - Add the email address you're using to sign in (e.g., `vasanth.anbukumar@gmail.com`) + - Click **Save** + - Try signing in again with that email + +2. **If you want to allow all users (for production):** + - Go to OAuth consent screen + - Click **PUBLISH APP** button + - Confirm publishing + - **Note:** This may require app verification if using sensitive scopes + +3. Verify all required scopes are added to consent screen +4. Check that YouTube Data API v3 is enabled + +### Issue 3: Tokens Not Storing + +**Problem:** Authentication works but tokens aren't saved to database. + +**Solution:** +1. Check `youtube_tokens` table exists +2. Verify RLS policies are correct +3. Check server logs for error messages +4. Ensure callback route is handling Google provider correctly + +### Issue 4: "Invalid Client" Error + +**Problem:** Google says client ID or secret is invalid. + +**Solution:** +1. Double-check Client ID and Secret in Supabase dashboard +2. Make sure you copied the entire secret (no extra spaces) +3. Verify credentials are for the correct Google Cloud project + +--- + +## Verification Checklist + +- [ ] Google OAuth credentials created +- [ ] OAuth consent screen configured with required scopes +- [ ] YouTube Data API v3 enabled +- [ ] Supabase redirect URI added to Google Console +- [ ] Google provider enabled in Supabase +- [ ] Client ID and Secret added to Supabase +- [ ] Environment variables set (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) +- [ ] `youtube_tokens` table exists with RLS policies +- [ ] Test authentication flow works +- [ ] Tokens are being stored in database + +--- + +## Production Deployment + +When deploying to production: + +1. **Update Google Console:** + - Add your production domain to authorized redirect URIs: + ``` + https://yourdomain.com/auth/callback + ``` + - Update OAuth consent screen with production domain + +2. **Update Environment Variables:** + - Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in your hosting platform + - Ensure `NEXT_PUBLIC_SUPABASE_URL` points to production Supabase + +3. **Verify Supabase Settings:** + - Production Supabase project has Google provider configured + - Redirect URI in Supabase matches what's in Google Console + +--- + +## Additional Resources + +- [Google OAuth 2.0 Documentation](https://developers.google.com/identity/protocols/oauth2) +- [YouTube Data API Documentation](https://developers.google.com/youtube/v3) +- [Supabase Auth Documentation](https://supabase.com/docs/guides/auth) diff --git a/apps/web/MIGRATION_INSTRUCTIONS.md b/apps/web/MIGRATION_INSTRUCTIONS.md new file mode 100644 index 0000000..30face1 --- /dev/null +++ b/apps/web/MIGRATION_INSTRUCTIONS.md @@ -0,0 +1,66 @@ +# Database Migration Instructions + +## Communities Table Migration + +To fix the "Error fetching communities" error, you need to run the database migration to create the `communities` table. + +### Option 1: Run in Supabase Dashboard (Recommended) + +1. Go to your Supabase project dashboard +2. Navigate to **SQL Editor** +3. Click **New Query** +4. Copy and paste the contents of `apps/web/supabase/migrations/009_create_communities_table.sql` +5. Click **Run** (or press Ctrl+Enter) +6. Verify the table was created by checking the **Table Editor** - you should see a `communities` table + +### Option 2: Run via Supabase CLI + +If you have Supabase CLI installed: + +```bash +cd apps/web +supabase db push +``` + +Or run the specific migration: + +```bash +supabase migration up --file supabase/migrations/009_create_communities_table.sql +``` + +### Option 3: Run Curated Songs Migration (Optional) + +If you want to use the song curation feature, also run: + +1. Copy contents of `apps/web/supabase/migrations/010_create_curated_songs_table.sql` +2. Run in Supabase SQL Editor + +### Verify Migration + +After running the migration, you can verify it worked by: + +1. Check the **Table Editor** in Supabase - you should see: + - `communities` table + - `curated_songs` table (if you ran that migration too) + +2. The error should disappear and communities should load (even if empty) + +3. You can now access the admin console and create communities + +### Troubleshooting + +If you still see errors: + +1. **Check RLS Policies**: Make sure the Row Level Security policies were created correctly +2. **Check Permissions**: Verify your Supabase user has the necessary permissions +3. **Check Console**: Look at the browser console for more detailed error messages + +### Next Steps + +After running the migration: + +1. Access the admin console (click bottom-right corner 10 times quickly on homepage) +2. Create your first community +3. Add playlist links +4. Curate songs if needed + diff --git a/apps/web/SMART_SORTING_SETUP.md b/apps/web/SMART_SORTING_SETUP.md new file mode 100644 index 0000000..d1bcac8 --- /dev/null +++ b/apps/web/SMART_SORTING_SETUP.md @@ -0,0 +1,105 @@ +# Smart Sorting Setup Guide + +This document describes how to set up the OpenAI-powered smart sorting feature for playlists. + +## Environment Variables + +Add the following environment variables to your `.env.local` file: + +```bash +# OpenAI API Key (required) +OPENAI_API_KEY=sk-your-openai-api-key-here + +# Last.fm API Key (optional but recommended) +# Get a free API key at: https://www.last.fm/api/account/create +LASTFM_API_KEY=your-lastfm-api-key-here + +# MusicBrainz User Agent (required if using MusicBrainz) +# Format: "YourAppName/Version (contact email or website)" +MUSICBRAINZ_USER_AGENT=Vybe/1.0 (https://vybe.app) +``` + +## API Keys Setup + +### OpenAI API Key +1. Go to https://platform.openai.com/api-keys +2. Sign in or create an account +3. Create a new API key +4. Copy the key and add it to `.env.local` + +**Note**: OpenAI API usage incurs costs. The implementation uses `gpt-4o-mini` for cost efficiency. + +### Last.fm API Key (Optional) +1. Go to https://www.last.fm/api/account/create +2. Fill out the application form +3. Copy the API key and add it to `.env.local` + +**Benefits**: Provides genre tags and play counts for both Spotify and YouTube songs. + +### MusicBrainz User Agent (Optional) +MusicBrainz requires a User-Agent header but doesn't require an API key. The format should be: +``` +YourAppName/Version (contact information) +``` + +Example: `Vybe/1.0 (contact@vybe.app)` + +**Benefits**: Provides additional genre tags and metadata. + +## How It Works + +1. **Automatic Trigger**: When a playlist is imported to a group, smart sorting is automatically triggered in the background. + +2. **Metadata Collection**: The system fetches metadata from: + - Spotify API (for Spotify tracks - requires user's Spotify connection) + - Last.fm API (for all tracks - requires API key) + - MusicBrainz API (for all tracks - no API key needed) + +3. **AI Analysis**: OpenAI analyzes the collected metadata (genres, artists, popularity) and determines optimal ordering. + +4. **Database Update**: The new ordering is saved to the database in `smart_sorted_order` columns. + +5. **Display**: The frontend automatically displays songs and playlists using the smart-sorted order when available. + +## Manual Trigger + +You can also manually trigger smart sorting by calling: +``` +POST /api/groups/[groupId]/smart-sort +``` + +## Database Migration + +Run the migration to add the required columns: +```sql +-- Located in: apps/web/supabase/migrations/007_add_smart_sorting.sql +``` + +This adds: +- `smart_sorted_order` to `group_playlists` table +- `smart_sorted_order` to `playlist_songs` table +- `last_sorted_at` timestamp to `group_playlists` table + +## Cost Considerations + +- **OpenAI API**: Costs depend on usage. `gpt-4o-mini` is used for cost efficiency. +- **Last.fm API**: Free tier available with rate limits (5 requests/second). +- **MusicBrainz API**: Free but requires 1 request/second rate limiting. + +## Troubleshooting + +### Sorting not working? +1. Check that `OPENAI_API_KEY` is set correctly +2. Verify the database migration has been run +3. Check server logs for errors + +### Missing metadata? +- Ensure `LASTFM_API_KEY` is set for Last.fm data +- Spotify tracks require the user to have Spotify connected +- Some songs may not have metadata available from any source + +### Rate limiting issues? +- Last.fm: 5 requests/second limit +- MusicBrainz: 1 request/second limit (built-in delays in code) +- The system includes automatic rate limiting and delays + diff --git a/apps/web/app/(auth)/sign-in/page.jsx b/apps/web/app/(auth)/sign-in/page.jsx index f63face..3a39db7 100644 --- a/apps/web/app/(auth)/sign-in/page.jsx +++ b/apps/web/app/(auth)/sign-in/page.jsx @@ -25,7 +25,7 @@ export default function SignInPage() { provider: 'spotify', options: { redirectTo: `${location.origin}/auth/callback?next=/library&provider=spotify`, - scopes: 'user-read-email user-read-private playlist-read-private user-read-recently-played', + scopes: 'user-read-email user-read-private playlist-read-private playlist-modify-private playlist-modify-public user-read-recently-played', }, queryParams: { show_dialog: 'true' }, }); @@ -42,7 +42,12 @@ export default function SignInPage() { 'email', 'profile', 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.force-ssl', // Allow playlist creation ].join(' '), + queryParams: { + access_type: 'offline', // Required to get a refresh token + prompt: 'consent', // Force consent screen to ensure refresh token is returned + }, }, }); if (error) console.error('Google/YouTube login error:', error.message); @@ -55,8 +60,8 @@ export default function SignInPage() {
-
- +
+
diff --git a/apps/web/app/admin/communities/page.jsx b/apps/web/app/admin/communities/page.jsx new file mode 100644 index 0000000..c897207 --- /dev/null +++ b/apps/web/app/admin/communities/page.jsx @@ -0,0 +1,1025 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { supabaseBrowser } from '@/lib/supabase/client'; +import { TextField, TextareaField } from '@/components/shared/FormField'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import { Plus, Edit, Trash2, ExternalLink, Music, Check, X, Filter, RefreshCw, Search, Save, Copy, Download, Upload, MoreVertical, Lock } from 'lucide-react'; +import { toast } from 'sonner'; + +// Admin password - hashed would be better in production +const ADMIN_PASSWORD = 'Te@m_Vybe-2O25!'; +const AUTH_KEY = 'vybe_admin_auth'; + +export default function AdminCommunitiesPage() { + const router = useRouter(); + const [isAdminAuthed, setIsAdminAuthed] = useState(false); + const [passwordInput, setPasswordInput] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [communities, setCommunities] = useState([]); + const [loading, setLoading] = useState(true); + const [editingCommunity, setEditingCommunity] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [curatingCommunity, setCuratingCommunity] = useState(null); + const [isCurationDialogOpen, setIsCurationDialogOpen] = useState(false); + const [songs, setSongs] = useState([]); + const [loadingSongs, setLoadingSongs] = useState(false); + const [filterStatus, setFilterStatus] = useState('all'); // 'all', 'pending', 'approved', 'removed' + const [searchQuery, setSearchQuery] = useState(''); + const [editingInline, setEditingInline] = useState(null); // Community ID being edited inline + const [selectedCommunities, setSelectedCommunities] = useState([]); + const [showBulkActions, setShowBulkActions] = useState(false); + + // Form state + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [playlistLinks, setPlaylistLinks] = useState([]); + + // Inline edit state + const [inlineName, setInlineName] = useState(''); + const [inlineDescription, setInlineDescription] = useState(''); + + // Check for existing admin auth on mount + useEffect(() => { + const storedAuth = sessionStorage.getItem(AUTH_KEY); + if (storedAuth === 'true') { + setIsAdminAuthed(true); + } + }, []); + + useEffect(() => { + if (isAdminAuthed) { + checkAuth(); + loadCommunities(); + } + }, [isAdminAuthed]); + + const handlePasswordSubmit = (e) => { + e.preventDefault(); + if (passwordInput === ADMIN_PASSWORD) { + setIsAdminAuthed(true); + sessionStorage.setItem(AUTH_KEY, 'true'); + setPasswordError(''); + toast.success('Access granted'); + } else { + setPasswordError('Incorrect password'); + setPasswordInput(''); + } + }; + + const handleLogout = () => { + setIsAdminAuthed(false); + sessionStorage.removeItem(AUTH_KEY); + toast.success('Logged out of admin'); + }; + + const checkAuth = async () => { + const supabase = supabaseBrowser(); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session) { + router.push('/sign-in'); + } + }; + + const loadCommunities = async () => { + try { + setLoading(true); + const response = await fetch('/api/communities'); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to load communities'); + } + + setCommunities(data.communities || []); + } catch (error) { + console.error('Error loading communities:', error); + toast.error('Failed to load communities'); + } finally { + setLoading(false); + } + }; + + const openCreateDialog = () => { + setEditingCommunity(null); + setName(''); + setDescription(''); + setPlaylistLinks([]); + setIsDialogOpen(true); + }; + + const openEditDialog = (community) => { + setEditingCommunity(community); + setName(community.name || ''); + setDescription(community.description || ''); + setPlaylistLinks(community.playlist_links || []); + setIsDialogOpen(true); + }; + + const handleSave = async () => { + if (!name.trim()) { + toast.error('Community name is required'); + return; + } + + try { + const url = editingCommunity + ? `/api/communities/${editingCommunity.id}` + : '/api/communities'; + + const method = editingCommunity ? 'PUT' : 'POST'; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + description, + member_count: 0, + group_count: 0, + playlist_links: playlistLinks + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to save community'); + } + + toast.success(editingCommunity ? 'Community updated' : 'Community created'); + setIsDialogOpen(false); + loadCommunities(); + } catch (error) { + console.error('Error saving community:', error); + toast.error(error.message || 'Failed to save community'); + } + }; + + const handleDelete = async (community) => { + if (!confirm(`Are you sure you want to delete "${community.name}"?`)) { + return; + } + + try { + setIsDeleting(true); + const response = await fetch(`/api/communities/${community.id}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete community'); + } + + toast.success('Community deleted'); + loadCommunities(); + } catch (error) { + console.error('Error deleting community:', error); + toast.error(error.message || 'Failed to delete community'); + } finally { + setIsDeleting(false); + } + }; + + const addPlaylistLink = () => { + setPlaylistLinks([...playlistLinks, { platform: 'spotify', url: '', label: '' }]); + }; + + const removePlaylistLink = (index) => { + setPlaylistLinks(playlistLinks.filter((_, i) => i !== index)); + }; + + const updatePlaylistLink = (index, field, value) => { + const updated = [...playlistLinks]; + updated[index] = { ...updated[index], [field]: value }; + setPlaylistLinks(updated); + }; + + const openCurationDialog = async (community) => { + setCuratingCommunity(community); + setIsCurationDialogOpen(true); + await loadSongs(community.id); + }; + + const loadSongs = async (communityId) => { + try { + setLoadingSongs(true); + const response = await fetch(`/api/communities/${communityId}/playlist-songs`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to load songs'); + } + + setSongs(data.songs || []); + } catch (error) { + console.error('Error loading songs:', error); + toast.error(error.message || 'Failed to load songs. Make sure you have connected Spotify or YouTube.'); + } finally { + setLoadingSongs(false); + } + }; + + const handleCurateSong = async (song, status, reason = null) => { + if (!curatingCommunity) return; + + try { + const response = await fetch(`/api/communities/${curatingCommunity.id}/curate-song`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + song_id: song.id, + playlist_link_index: song.playlist_link_index, + status: status, + removal_reason: reason || (status === 'removed' ? 'vulgar' : null), + song_title: song.title, + song_artist: song.artist, + song_thumbnail: song.thumbnail, + song_duration: song.duration, + platform: song.platform + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to curate song'); + } + + toast.success(status === 'approved' ? 'Song approved' : 'Song removed'); + + // Update local state + setSongs(songs.map(s => + s.id === song.id && s.playlist_link_index === song.playlist_link_index + ? { ...s, curation_status: status, removal_reason: reason || null } + : s + )); + } catch (error) { + console.error('Error curating song:', error); + toast.error(error.message || 'Failed to curate song'); + } + }; + + const filteredSongs = songs.filter(song => { + if (filterStatus === 'all') return true; + return song.curation_status === filterStatus; + }); + + // Filter communities by search query + const filteredCommunities = communities.filter(community => { + if (!searchQuery.trim()) return true; + const query = searchQuery.toLowerCase(); + return ( + community.name?.toLowerCase().includes(query) || + community.description?.toLowerCase().includes(query) || + community.playlist_links?.some(link => + link.label?.toLowerCase().includes(query) || + link.url?.toLowerCase().includes(query) + ) + ); + }); + + const startInlineEdit = (community) => { + setEditingInline(community.id); + setInlineName(community.name || ''); + setInlineDescription(community.description || ''); + }; + + const cancelInlineEdit = () => { + setEditingInline(null); + setInlineName(''); + setInlineDescription(''); + }; + + const saveInlineEdit = async (community) => { + if (!inlineName.trim()) { + toast.error('Community name is required'); + return; + } + + try { + const response = await fetch(`/api/communities/${community.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: inlineName.trim(), + description: inlineDescription.trim() || null, + member_count: 0, + group_count: 0 + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update community'); + } + + toast.success('Community updated'); + setEditingInline(null); + loadCommunities(); + } catch (error) { + console.error('Error updating community:', error); + toast.error(error.message || 'Failed to update community'); + } + }; + + const toggleCommunitySelection = (communityId) => { + setSelectedCommunities(prev => + prev.includes(communityId) + ? prev.filter(id => id !== communityId) + : [...prev, communityId] + ); + }; + + const selectAllCommunities = () => { + if (selectedCommunities.length === filteredCommunities.length) { + setSelectedCommunities([]); + } else { + setSelectedCommunities(filteredCommunities.map(c => c.id)); + } + }; + + const handleBulkDelete = async () => { + if (selectedCommunities.length === 0) return; + + if (!confirm(`Are you sure you want to delete ${selectedCommunities.length} community/communities?`)) { + return; + } + + try { + setIsDeleting(true); + const deletePromises = selectedCommunities.map(id => + fetch(`/api/communities/${id}`, { method: 'DELETE' }) + ); + + await Promise.all(deletePromises); + toast.success(`${selectedCommunities.length} community/communities deleted`); + setSelectedCommunities([]); + setShowBulkActions(false); + loadCommunities(); + } catch (error) { + console.error('Error deleting communities:', error); + toast.error('Failed to delete some communities'); + } finally { + setIsDeleting(false); + } + }; + + const exportCommunities = () => { + const dataStr = JSON.stringify(communities, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `communities-${new Date().toISOString().split('T')[0]}.json`; + link.click(); + URL.revokeObjectURL(url); + toast.success('Communities exported'); + }; + + const handleImportCommunities = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const imported = JSON.parse(text); + + if (!Array.isArray(imported)) { + throw new Error('Invalid file format'); + } + + let successCount = 0; + let errorCount = 0; + + for (const community of imported) { + try { + const response = await fetch('/api/communities', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: community.name, + description: community.description, + member_count: community.member_count || 0, + group_count: community.group_count || 0, + playlist_links: community.playlist_links || [] + }) + }); + + if (response.ok) { + successCount++; + } else { + errorCount++; + } + } catch (error) { + errorCount++; + } + } + + toast.success(`Imported ${successCount} communities${errorCount > 0 ? `, ${errorCount} failed` : ''}`); + loadCommunities(); + } catch (error) { + console.error('Error importing communities:', error); + toast.error('Failed to import communities. Please check the file format.'); + } + + // Reset file input + event.target.value = ''; + }; + + // Password protection screen + if (!isAdminAuthed) { + return ( +
+ + +
+ +
+ Admin Access Required +

+ Enter the admin password to access this page +

+
+ +
+
+ setPasswordInput(e.target.value)} + placeholder="Enter admin password" + className="w-full px-4 py-3 bg-[var(--background)] border border-[var(--glass-border)] rounded-lg text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-purple-500" + autoFocus + /> + {passwordError && ( +

{passwordError}

+ )} +
+ +
+
+
+
+ ); + } + + if (loading) { + return ( +
+
+

Loading communities...

+
+
+ ); + } + + return ( +
+
+
+

Our Favorites Admin

+

+ Manage playlists we're currently listening to +

+
+
+ + + + +
+
+ + {/* Search and Filter */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-[var(--background)] border border-[var(--glass-border)] rounded-lg text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ {selectedCommunities.length > 0 && ( +
+ + {selectedCommunities.length} selected + + + +
+ )} +
+ + {communities.length === 0 ? ( + + + +

No communities yet

+ +
+
+ ) : ( +
+ {filteredCommunities.length === 0 ? ( +
+ +

+ {searchQuery ? 'No communities match your search' : 'No communities found'} +

+
+ ) : ( + filteredCommunities.map((community) => ( + + +
+ toggleCommunitySelection(community.id)} + className="mt-1 w-4 h-4 rounded border-[var(--glass-border)]" + /> +
+ {editingInline === community.id ? ( +
+ setInlineName(e.target.value)} + className="w-full px-2 py-1 bg-[var(--background)] border border-[var(--glass-border)] rounded text-[var(--foreground)] text-lg font-semibold" + placeholder="Community name" + /> +