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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..b473440 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +ο»Ώname: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 0 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.gitignore b/.gitignore index df9f191..d06882a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,22 +15,39 @@ yarn-error.log* # env .env* +<<<<<<< HEAD +======= .env.local .env.development.local .env.test.local .env.production.local +>>>>>>> 2cf79ae775545c31935108f06979a795fe08bdad # misc .DS_Store .vercel/ *.tsbuildinfo +<<<<<<< HEAD +# test files +clean.txt +bad.txt +secret.txt +======= Thumbs.db Desktop.ini +>>>>>>> 2cf79ae775545c31935108f06979a795fe08bdad # test files clean.txt bad.txt secret.txt +<<<<<<< HEAD +playwright-report/ + +.cursorignore +sprint-1-assignee-breakdown.txt +get-issues.ps1 +======= # Python venv/ @@ -102,4 +119,13 @@ coverage/ dist/ *.tsbuildinfo +# Private planning and documentation folders +old-pbi/ +pbi-77/ +pbi-72/ +memory-bank/ +.cursorrules + +# Issues folder issues/ +>>>>>>> 2cf79ae775545c31935108f06979a795fe08bdad diff --git a/apps/web/.github/workflows/codeql.yml b/apps/web/.github/workflows/codeql.yml new file mode 100644 index 0000000..ff490b4 --- /dev/null +++ b/apps/web/.github/workflows/codeql.yml @@ -0,0 +1,38 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '0 0 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript-typescript' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" + 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/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..0a27e50 100644 --- a/apps/web/app/(auth)/sign-in/page.jsx +++ b/apps/web/app/(auth)/sign-in/page.jsx @@ -42,6 +42,7 @@ 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(' '), }, }); diff --git a/apps/web/app/admin/communities/page.jsx b/apps/web/app/admin/communities/page.jsx new file mode 100644 index 0000000..3a96d9e --- /dev/null +++ b/apps/web/app/admin/communities/page.jsx @@ -0,0 +1,941 @@ +'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 } from 'lucide-react'; +import { toast } from 'sonner'; + +export default function AdminCommunitiesPage() { + const router = useRouter(); + 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(''); + + useEffect(() => { + checkAuth(); + loadCommunities(); + }, []); + + 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 = ''; + }; + + if (loading) { + return ( +
+
+

Loading communities...

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

Communities Admin

+

+ Manage communities and their playlist links +

+
+
+ + + +
+
+ + {/* 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" + /> +