Compare commits

..

52 commits

Author SHA1 Message Date
fullsizemalt
4a103511da feat: Add video integration - display videos on performance pages and indicators
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Add YouTubeEmbed to performance detail page when youtube_link exists
- Add YouTube icon indicator on setlist items that have videos
- Add YouTube badge on show cards in archive when full show video exists
- Add youtube_link to ShowRead and PerformanceRead schemas
- Add VIDEO_INTEGRATION_SPEC.md documentation
2025-12-22 23:52:34 -08:00
fullsizemalt
171b8a38ca feat: Add /videos page listing all YouTube videos without thumbnails 2025-12-22 23:16:04 -08:00
fullsizemalt
0ad89105b3 feat: Improved YouTube matching with fuzzy logic (+40 more videos) 2025-12-22 23:13:13 -08:00
fullsizemalt
dc584af2f2 feat: Add YouTube API fetch and import scripts with 620 videos 2025-12-22 23:09:43 -08:00
fullsizemalt
bd6832705f feat: Add Mark Caught button for chase songs + fix Next.js 16 build errors
- Add MarkCaughtButton component to show page setlist
- Fix TypeScript errors in profile, settings, welcome pages
- Fix Switch component onChange props
- Fix notification-bell imports and button size
- Fix performance-list orphaned JSX
- Fix song-evolution-chart tooltip types
- Add Suspense boundaries for useSearchParams (Next.js 16 requirement)
2025-12-22 00:21:58 -08:00
fullsizemalt
823c6e7dee fix: Rewrite youtube parser to handle escaped markdown line-by-line 2025-12-21 22:35:41 -08:00
fullsizemalt
98a7965c52 fix: Handle escaped markdown in youtube import parser 2025-12-21 22:33:51 -08:00
fullsizemalt
8620841932 feat: Add YouTube video import script for performances and shows 2025-12-21 22:20:10 -08:00
fullsizemalt
060797a233 style: Update Review Header formatting for Performances 2025-12-21 22:02:30 -08:00
fullsizemalt
16bacc29df feat: Enhance Performance Page with Top Rated Versions list 2025-12-21 21:52:23 -08:00
fullsizemalt
5e123463f7 feat: Update Show page to link to Performance details instead of Song 2025-12-21 21:45:53 -08:00
fullsizemalt
532798db76 feat: Add data backfill migration for set names and slugs 2025-12-21 20:53:06 -08:00
fullsizemalt
e3e074248e feat: complete slug integration, fix set names logic, add missing ui components 2025-12-21 20:29:36 -08:00
fullsizemalt
b73f993475 feat: Email Service Integration with AWS SES
- Implemented backend/services/email_service.py with boto3 SES client
- Added EmailService class with verification and password reset methods
- Updated auth router to use the new email service
- Configured docker-compose.yml to pass AWS SES environment variables
2025-12-21 19:28:29 -08:00
fullsizemalt
bc804a666b feat: Gamification sprint complete
XP System:
- XP now awarded for attendance (+25), ratings (+10), reviews (+50)
- First-time bonuses for first rating (+25) and first review (+50)
- Streak bonuses (+10 per day, capped at 7x)
- Badge awards automatically grant XP

User Titles & Flair System (Tracker-style):
- Level-based free titles: Rookie → Immortal
- Purchasable titles with XP: Jam Connoisseur, Setlist Savant, etc.
- Username colors purchasable with XP (6 colors + Rainbow)
- Emoji flairs purchasable with XP
- Early adopter perks: exclusive titles, colors, 10% XP bonus

New Fields on User:
- custom_title, title_color, flair
- is_early_adopter, is_supporter
- joined_at

Shop API Endpoints:
- GET /gamification/shop/titles
- POST /gamification/shop/titles/purchase
- GET/POST for colors and flairs
- GET /gamification/user/{id}/display
- GET /gamification/early-adopter-perks

Frontend:
- XP Leaderboard added to home page
- LevelProgressCard shows on profile
2025-12-21 19:21:20 -08:00
fullsizemalt
5ffb428bb8 feat: Add gamification system
Backend:
- Add XP, level, streak fields to User model
- Add tier, category, xp_reward fields to Badge model
- Create gamification service with XP, levels, streaks, badge checking
- Add gamification router with level progress, leaderboard endpoints
- Define 16 badge types across attendance, ratings, social, milestones

Frontend:
- LevelProgressCard component with XP bar and streak display
- XPLeaderboard component showing top users
- Integrate level progress into profile page

Slug System:
- All entities now support slug-based URLs
- Performances use songslug-YYYY-MM-DD format
2025-12-21 18:58:42 -08:00
fullsizemalt
66b5039337 fix: Fix import shadowing in routers 2025-12-21 18:51:23 -08:00
fullsizemalt
a12f7fa8b0 fix: Fix import path in slug migration 2025-12-21 18:48:02 -08:00
fullsizemalt
3edbcdeb64 feat: Add slug support for all entities
- Add slug fields to Song, Venue, Show, Tour, Performance models
- Update routers to support lookup by slug or ID
- Create slugify.py utility for generating URL-safe slugs
- Add migration script to generate slugs for existing data
- Performance slugs use songslug-YYYY-MM-DD format
2025-12-21 18:46:40 -08:00
fullsizemalt
2e4e0b811d feat: User profile enhancements - chase songs and attendance stats
Backend:
- Add ChaseSong model for tracking songs users want to see
- New /chase router with CRUD for chase songs
- Profile stats endpoint with heady versions, debuts, etc.

Frontend:
- ChaseSongsList component with search, add, remove
- AttendanceSummary with auto-generated stats
- Updated profile page with new Overview tab content
2025-12-21 18:39:39 -08:00
fullsizemalt
131bafa825 fix: Multiple fixes
- Add missing @radix-ui/react-select dependency
- Sort tour shows chronologically by date
- Add context to review forms (song name, date)
- Redesign performance page with distinct visual identity
- Update ReviewForm to use RatingInput slider
2025-12-21 18:18:35 -08:00
fullsizemalt
557d9e808e feat: Professional Terms of Service and Privacy Policy pages
- Comprehensive Terms of Service with 11 sections
- GDPR-compliant Privacy Policy with 12 sections
- Proper styling, metadata, and formatting
- Contact information and last updated dates
2025-12-21 18:12:09 -08:00
fullsizemalt
eebebbb637 feat: Improve navigation between shows, performances, and songs
- Show page: Song titles now link to /performances/[id]
- Performance page: Added breadcrumbs (Songs > Song Title > Date)
- Performance page: Song title links to /songs/[id]
- Performance page: Venue links to /venues/[id]
- Performance page: Added rating component in header
2025-12-21 18:09:10 -08:00
fullsizemalt
b973b9e270 feat: Better decimal rating input with slider
- New RatingInput component with slider + numeric input
- Visual stars show partial fill for decimals
- Gradient slider (red → yellow → green) for intuitive scoring
- RatingBadge component for compact display
- Updated EntityRating to use new components
2025-12-21 18:06:15 -08:00
fullsizemalt
d443eabd69 fix: Add missing avatar component, reduce venues API limit
- Create Avatar, AvatarImage, AvatarFallback components
- Fix venues page API limit (500 -> 100)
2025-12-21 17:58:58 -08:00
fullsizemalt
835299fab5 feat: Support decimal ratings (e.g., 9.2)
- Rating/Review models now use float instead of int
- Star rating component shows partial fills
- Numeric value displayed while rating
- Supports precision: 0.1 increments
2025-12-21 17:53:56 -08:00
fullsizemalt
ee311c0bc4 feat: Complete venues overhaul
- Fix ratings API to support venue_id and tour_id
- Add migration for new rating columns
- Venues list: search, state filter, sort by name/shows
- Venues detail: show list with dates, venue stats, error handling
- Remove broken EntityRating/SocialWrapper from venue pages
2025-12-21 17:51:05 -08:00
fullsizemalt
cd5b0698d3 fix: Remove CSS @import for fonts - Next.js handles font loading 2025-12-21 17:36:56 -08:00
fullsizemalt
67fbd4d152 style: Match Ersen design system
- Add Space Grotesk and JetBrains Mono fonts
- Implement light/dark mode toggle with next-themes
- Update color palette to match Ersen (HSL-based tokens)
- Add ThemeProvider and ThemeToggle components
- Reduce border radius to 0.3rem for cleaner look
2025-12-21 17:32:58 -08:00
fullsizemalt
415a092257 refactor: Use SES v2 stored templates in Python email service
- Switch from raw HTML to stored templates
- Use sesv2 client instead of ses
- Add send_security_alert_email function
- Templates: ELMEG_EMAIL_VERIFICATION, ELMEG_PASSWORD_RESET, ELMEG_SECURITY_ALERT
2025-12-21 16:55:15 -08:00
fullsizemalt
530f217445 feat: Add AWS SES v2 email service
Complete transactional email layer for Elmeg:
- 3 SES templates (verification, password reset, security alert)
- TypeScript integration module with AWS SDK v3
- Template deployment script
- Usage examples
- Comprehensive README with compliance notes
2025-12-21 16:04:59 -08:00
fullsizemalt
da5b5e7c45 fix: Add missing select component + update terminology to 'Top Performances' 2025-12-21 15:56:03 -08:00
fullsizemalt
06dc8889b5 feat: Add Heady Versions (performances) page
- /performances page with top-rated performance leaderboard
- Added to Browse dropdown in navbar
- Updated home page CTA to feature Heady Versions
- Medal icons for top 3 performances
2025-12-21 15:44:09 -08:00
fullsizemalt
3987b64209 fix: Use sh in docker-compose command for start.sh 2025-12-21 15:40:10 -08:00
fullsizemalt
1242b5d2ed fix: Use sh to run start.sh (bypass permission issue) 2025-12-21 15:38:36 -08:00
fullsizemalt
ae732074e2 fix: Add email-validator for pydantic EmailStr 2025-12-21 15:34:57 -08:00
fullsizemalt
d386dcbd65 fix: Add missing radio-group component + doc fixes 2025-12-21 14:44:30 -08:00
fullsizemalt
cc694ed5bb docs: Add comprehensive AWS SES setup guides
- AWS_SES_SETUP.md: Complete setup with prod/dev env separation
- AWS_SES_BROWSER_AGENT.md: Step-by-step for browser-based agent
- Explicit security notes about IAM scoping and key handling
2025-12-21 14:40:20 -08:00
fullsizemalt
bb1cba5e20 fix: Update production URL to elmeg.xyz 2025-12-21 14:32:36 -08:00
fullsizemalt
5a8764df05 feat: Add Heady Version Leaderboard to Song Page (Phase 4-5)
- YouTube embed of #1 rated performance
- Top 5 performances ranked with medal icons
- Rating + rating count display
- YouTube link icons for each performance
- Gradient gold styling for heady section
2025-12-21 14:06:37 -08:00
fullsizemalt
ad2e6a107e feat: Enhance Mod Panel (Phase 3)
Backend:
- User lookup by email/username with activity stats
- Ban/unban endpoints with role protection
- Bulk approve/reject nicknames
- Bulk resolve/dismiss reports
- Queue stats endpoint

Frontend:
- Stats cards (pending items, ban count)
- User Lookup tab with search
- User profile with activity stats
- Ban dialog with duration selector
- Bulk selection checkboxes on queues
2025-12-21 14:04:33 -08:00
fullsizemalt
c16fe559e0 feat: Add Admin Panel (Phase 2)
Backend:
- admin.py router with user management CRUD
- Platform stats endpoint
- Shows/Songs/Venues/Tours CRUD
- Protected by RoleChecker (admin only)

Frontend:
- /admin dashboard with stats cards
- Users tab with search and edit dialog
- Content tab with entity counts
- Role/ban/verification management
2025-12-21 13:50:52 -08:00
fullsizemalt
9af0bc4b96 refactor: Switch from SendGrid to AWS SES for email
- Replace httpx/SendGrid with boto3/SES
- Add boto3 to requirements.txt
- Create AWS_SES_SETUP.md documentation
- Remove SendGrid setup doc
2025-12-21 13:42:07 -08:00
fullsizemalt
f1d8a14f75 feat: Add email verification and password reset (Phase 1)
- Add email_verified, verification_token, reset_token fields to User model
- Create email_service.py with SendGrid integration
- Add auth endpoints: verify-email, resend-verification, forgot-password, reset-password
- Create frontend pages: /verify-email, /forgot-password, /reset-password
- Add forgot password link to login page
- Add PLATFORM_ENHANCEMENT_SPEC.md specification
2025-12-21 13:28:54 -08:00
fullsizemalt
fd81b38c0c fix: Fix migration import path 2025-12-21 13:00:10 -08:00
fullsizemalt
8df513b84f feat: Add YouTube link support for shows, songs, and performances 2025-12-21 12:58:32 -08:00
fullsizemalt
958f097068 fix: Update docker-compose to use start.sh migration runner 2025-12-21 03:49:47 -08:00
fullsizemalt
aa3faaa7e4 feat: Add CI/CD migration runner to backend startup 2025-12-21 03:46:11 -08:00
fullsizemalt
df586b7c4e feat: Hide leaderboards, add healthz endpoint and seed script 2025-12-21 03:41:23 -08:00
fullsizemalt
d257698539 feat(frontend): Add missing radix-ui packages for persistence 2025-12-21 03:33:02 -08:00
fullsizemalt
591ab8f6d3 fix: Correct leaderboards queries to use proper Rating FKs 2025-12-21 03:22:59 -08:00
fullsizemalt
c1ceb65d04 fix: Add migration for Reaction table 2025-12-21 03:18:53 -08:00
106 changed files with 21612 additions and 508 deletions

BIN
._youtube.md Normal file

Binary file not shown.

View file

@ -7,4 +7,5 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run migrations then start server (shell form handles permissions)
CMD ["sh", "start.sh"]

View file

@ -0,0 +1,213 @@
"""Add slugs
Revision ID: 65c515b4722a
Revises: e50a60c5d343
Create Date: 2025-12-21 20:24:07.968495
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '65c515b4722a'
down_revision: Union[str, Sequence[str], None] = 'e50a60c5d343'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# op.create_table('reaction',
# sa.Column('id', sa.Integer(), nullable=False),
# sa.Column('user_id', sa.Integer(), nullable=False),
# sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
# sa.Column('entity_id', sa.Integer(), nullable=False),
# sa.Column('emoji', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
# sa.Column('created_at', sa.DateTime(), nullable=False),
# sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
# sa.PrimaryKeyConstraint('id')
# )
# with op.batch_alter_table('reaction', schema=None) as batch_op:
# batch_op.create_index(batch_op.f('ix_reaction_entity_id'), ['entity_id'], unique=False)
# batch_op.create_index(batch_op.f('ix_reaction_entity_type'), ['entity_type'], unique=False)
# op.create_table('chasesong',
# sa.Column('id', sa.Integer(), nullable=False),
# sa.Column('user_id', sa.Integer(), nullable=False),
# sa.Column('song_id', sa.Integer(), nullable=False),
# sa.Column('priority', sa.Integer(), nullable=False),
# sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
# sa.Column('created_at', sa.DateTime(), nullable=False),
# sa.Column('caught_at', sa.DateTime(), nullable=True),
# sa.Column('caught_show_id', sa.Integer(), nullable=True),
# sa.ForeignKeyConstraint(['caught_show_id'], ['show.id'], ),
# sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
# sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
# sa.PrimaryKeyConstraint('id')
# )
# with op.batch_alter_table('chasesong', schema=None) as batch_op:
# batch_op.create_index(batch_op.f('ix_chasesong_song_id'), ['song_id'], unique=False)
# batch_op.create_index(batch_op.f('ix_chasesong_user_id'), ['user_id'], unique=False)
# with op.batch_alter_table('badge', schema=None) as batch_op:
# batch_op.add_column(sa.Column('tier', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
# batch_op.add_column(sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
# batch_op.add_column(sa.Column('xp_reward', sa.Integer(), nullable=False))
with op.batch_alter_table('comment', schema=None) as batch_op:
batch_op.add_column(sa.Column('parent_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_comment_parent_id', 'comment', ['parent_id'], ['id'])
with op.batch_alter_table('performance', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('track_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_performance_slug'), ['slug'], unique=True)
with op.batch_alter_table('rating', schema=None) as batch_op:
batch_op.add_column(sa.Column('performance_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('venue_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('tour_id', sa.Integer(), nullable=True))
batch_op.alter_column('score',
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False)
batch_op.create_foreign_key('fk_rating_tour_id', 'tour', ['tour_id'], ['id'])
batch_op.create_foreign_key('fk_rating_performance_id', 'performance', ['performance_id'], ['id'])
batch_op.create_foreign_key('fk_rating_venue_id', 'venue', ['venue_id'], ['id'])
with op.batch_alter_table('review', schema=None) as batch_op:
batch_op.alter_column('score',
existing_type=sa.INTEGER(),
type_=sa.Float(),
existing_nullable=False)
with op.batch_alter_table('show', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('bandcamp_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('nugs_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_show_slug'), ['slug'], unique=True)
with op.batch_alter_table('song', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_song_slug'), ['slug'], unique=True)
with op.batch_alter_table('tour', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_tour_slug'), ['slug'], unique=True)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('xp', sa.Integer(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('level', sa.Integer(), nullable=False, server_default="1"))
batch_op.add_column(sa.Column('streak_days', sa.Integer(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('last_activity', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('custom_title', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('title_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('flair', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('is_early_adopter', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('is_supporter', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('joined_at', sa.DateTime(), nullable=False, server_default=sa.func.now()))
batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False, server_default="0"))
batch_op.add_column(sa.Column('verification_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('reset_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
with op.batch_alter_table('venue', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_venue_slug'), ['slug'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('venue', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_venue_slug'))
batch_op.drop_column('slug')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('reset_token_expires')
batch_op.drop_column('reset_token')
batch_op.drop_column('verification_token_expires')
batch_op.drop_column('verification_token')
batch_op.drop_column('email_verified')
batch_op.drop_column('joined_at')
batch_op.drop_column('is_supporter')
batch_op.drop_column('is_early_adopter')
batch_op.drop_column('flair')
batch_op.drop_column('title_color')
batch_op.drop_column('custom_title')
batch_op.drop_column('last_activity')
batch_op.drop_column('streak_days')
batch_op.drop_column('level')
batch_op.drop_column('xp')
with op.batch_alter_table('tour', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_tour_slug'))
batch_op.drop_column('slug')
with op.batch_alter_table('song', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_song_slug'))
batch_op.drop_column('youtube_link')
batch_op.drop_column('slug')
with op.batch_alter_table('show', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_show_slug'))
batch_op.drop_column('youtube_link')
batch_op.drop_column('nugs_link')
batch_op.drop_column('bandcamp_link')
batch_op.drop_column('slug')
with op.batch_alter_table('review', schema=None) as batch_op:
batch_op.alter_column('score',
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False)
with op.batch_alter_table('rating', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.alter_column('score',
existing_type=sa.Float(),
type_=sa.INTEGER(),
existing_nullable=False)
batch_op.drop_column('tour_id')
batch_op.drop_column('venue_id')
batch_op.drop_column('performance_id')
with op.batch_alter_table('performance', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_performance_slug'))
batch_op.drop_column('youtube_link')
batch_op.drop_column('track_url')
batch_op.drop_column('slug')
with op.batch_alter_table('comment', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('parent_id')
with op.batch_alter_table('badge', schema=None) as batch_op:
batch_op.drop_column('xp_reward')
batch_op.drop_column('category')
batch_op.drop_column('tier')
with op.batch_alter_table('chasesong', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_chasesong_user_id'))
batch_op.drop_index(batch_op.f('ix_chasesong_song_id'))
op.drop_table('chasesong')
with op.batch_alter_table('reaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reaction_entity_type'))
batch_op.drop_index(batch_op.f('ix_reaction_entity_id'))
op.drop_table('reaction')
# ### end Alembic commands ###

146
backend/email_service.py Normal file
View file

@ -0,0 +1,146 @@
"""
Email Service - AWS SES v2 integration using stored templates.
Uses SES stored templates for consistent, branded transactional emails:
- ELMEG_EMAIL_VERIFICATION
- ELMEG_PASSWORD_RESET
- ELMEG_SECURITY_ALERT
"""
import os
import json
import secrets
from datetime import datetime, timedelta
from typing import Optional
import boto3
from botocore.exceptions import ClientError
# Configuration
AWS_REGION = os.getenv("AWS_SES_REGION", "us-east-1")
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
SUPPORT_EMAIL = os.getenv("SUPPORT_EMAIL", "support@elmeg.xyz")
APP_NAME = "Elmeg"
# SES Template Names
TEMPLATE_VERIFICATION = "ELMEG_EMAIL_VERIFICATION"
TEMPLATE_PASSWORD_RESET = "ELMEG_PASSWORD_RESET"
TEMPLATE_SECURITY_ALERT = "ELMEG_SECURITY_ALERT"
def get_ses_client():
"""Get boto3 SES v2 client"""
return boto3.client('sesv2', region_name=AWS_REGION)
def is_email_configured() -> bool:
"""Check if email is properly configured"""
return bool(os.getenv("AWS_ACCESS_KEY_ID") and os.getenv("AWS_SECRET_ACCESS_KEY"))
def send_templated_email(
to: str,
template_name: str,
template_data: dict
) -> dict:
"""
Send email using SES stored template.
Returns:
dict with 'success', 'message_id' (on success), 'error' (on failure)
"""
# Dev mode - log instead of sending
if not is_email_configured():
print(f"[EMAIL DEV MODE] To: {to}, Template: {template_name}")
print(f"[EMAIL DEV MODE] Data: {json.dumps(template_data, indent=2)}")
return {"success": True, "message_id": "dev-mode", "dev_mode": True}
try:
client = get_ses_client()
response = client.send_email(
FromEmailAddress=EMAIL_FROM,
Destination={"ToAddresses": [to]},
Content={
"Template": {
"TemplateName": template_name,
"TemplateData": json.dumps(template_data)
}
}
)
message_id = response.get("MessageId", "unknown")
print(f"[Email] Sent {template_name} to {to}, MessageId: {message_id}")
return {"success": True, "message_id": message_id}
except ClientError as e:
error_msg = e.response.get('Error', {}).get('Message', str(e))
print(f"[Email] Failed to send {template_name} to {to}: {error_msg}")
return {"success": False, "error": error_msg}
# =============================================================================
# Email Functions
# =============================================================================
async def send_verification_email(email: str, token: str, user_name: Optional[str] = None) -> bool:
"""Send email verification using SES template"""
verification_link = f"{FRONTEND_URL}/verify-email?token={token}"
template_data = {
"user_name": user_name or email.split("@")[0],
"verification_link": verification_link,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_VERIFICATION, template_data)
return result["success"]
async def send_password_reset_email(email: str, token: str, user_name: Optional[str] = None) -> bool:
"""Send password reset email using SES template"""
reset_link = f"{FRONTEND_URL}/reset-password?token={token}"
template_data = {
"user_name": user_name or email.split("@")[0],
"reset_link": reset_link,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_PASSWORD_RESET, template_data)
return result["success"]
async def send_security_alert_email(
email: str,
security_event_description: str,
user_name: Optional[str] = None
) -> bool:
"""Send security alert email using SES template"""
template_data = {
"user_name": user_name or email.split("@")[0],
"security_event_description": security_event_description,
"app_name": APP_NAME,
"support_email": SUPPORT_EMAIL
}
result = send_templated_email(email, TEMPLATE_SECURITY_ALERT, template_data)
return result["success"]
# =============================================================================
# Token Generation & Expiry Helpers
# =============================================================================
def generate_token() -> str:
"""Generate a secure random token"""
return secrets.token_urlsafe(32)
def get_verification_expiry() -> datetime:
"""24 hour expiry for email verification"""
return datetime.utcnow() + timedelta(hours=24)
def get_reset_expiry() -> datetime:
"""1 hour expiry for password reset"""
return datetime.utcnow() + timedelta(hours=1)

207
backend/fetch_youtube.py Normal file
View file

@ -0,0 +1,207 @@
"""
Fetch all videos from Goose YouTube channel using YouTube Data API v3
"""
import requests
import json
import re
from datetime import datetime
API_KEY = "AIzaSyCxDpv6HM-sPD8vPJIBffwa2-skOpEJkOU"
CHANNEL_HANDLE = "@GooseTheBand"
def get_channel_id(handle: str) -> str:
"""Get channel ID from handle."""
url = "https://www.googleapis.com/youtube/v3/search"
params = {
"key": API_KEY,
"q": handle,
"type": "channel",
"part": "snippet",
"maxResults": 1
}
resp = requests.get(url, params=params)
data = resp.json()
if "items" in data and len(data["items"]) > 0:
return data["items"][0]["snippet"]["channelId"]
return None
def get_uploads_playlist_id(channel_id: str) -> str:
"""Get the uploads playlist ID for a channel."""
url = "https://www.googleapis.com/youtube/v3/channels"
params = {
"key": API_KEY,
"id": channel_id,
"part": "contentDetails"
}
resp = requests.get(url, params=params)
data = resp.json()
if "items" in data and len(data["items"]) > 0:
return data["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
return None
def get_all_videos(playlist_id: str) -> list:
"""Fetch all videos from a playlist (handles pagination)."""
videos = []
url = "https://www.googleapis.com/youtube/v3/playlistItems"
next_page_token = None
while True:
params = {
"key": API_KEY,
"playlistId": playlist_id,
"part": "snippet,contentDetails",
"maxResults": 50
}
if next_page_token:
params["pageToken"] = next_page_token
resp = requests.get(url, params=params)
data = resp.json()
if "error" in data:
print(f"API Error: {data['error']}")
break
for item in data.get("items", []):
snippet = item["snippet"]
video = {
"videoId": snippet["resourceId"]["videoId"],
"title": snippet["title"],
"description": snippet.get("description", ""),
"publishedAt": snippet["publishedAt"],
"thumbnails": snippet.get("thumbnails", {})
}
videos.append(video)
next_page_token = data.get("nextPageToken")
print(f"Fetched {len(videos)} videos so far...")
if not next_page_token:
break
return videos
def parse_video_metadata(videos: list) -> list:
"""Parse video titles to extract show date and type."""
parsed = []
# Date patterns to look for in titles/descriptions
date_patterns = [
r'(\d{1,2})[./](\d{1,2})[./](\d{2,4})', # M/D/YY or M.D.YYYY
r'(\d{4})-(\d{2})-(\d{2})', # YYYY-MM-DD
]
for video in videos:
title = video["title"]
desc = video.get("description", "")
# Determine video type
video_type = "song" # default
title_lower = title.lower()
if "full show" in title_lower or "live at" in title_lower or "night 1" in title_lower or "night 2" in title_lower or "night 3" in title_lower:
video_type = "full_show"
elif "" in title or "->" in title:
video_type = "sequence"
elif "documentary" in title_lower or "behind" in title_lower:
video_type = "documentary"
elif "visualizer" in title_lower:
video_type = "visualizer"
elif "session" in title_lower or "studio" in title_lower:
video_type = "session"
# Try to extract date
show_date = None
# Check description first (often has date info)
combined_text = f"{title} {desc}"
for pattern in date_patterns:
match = re.search(pattern, combined_text)
if match:
groups = match.groups()
try:
if len(groups[0]) == 4: # YYYY-MM-DD
show_date = f"{groups[0]}-{groups[1]}-{groups[2]}"
else: # M/D/YY
year = groups[2]
if len(year) == 2:
year = "20" + year if int(year) < 50 else "19" + year
month = groups[0].zfill(2)
day = groups[1].zfill(2)
show_date = f"{year}-{month}-{day}"
break
except:
pass
# Extract venue from title if possible
venue = None
venue_patterns = [
r'@ (.+)$',
r'at (.+?) -',
r'Live at (.+)',
r'- (.+?, [A-Z]{2})$',
]
for pattern in venue_patterns:
match = re.search(pattern, title, re.IGNORECASE)
if match:
venue = match.group(1).strip()
break
parsed.append({
"videoId": video["videoId"],
"title": title,
"date": show_date,
"venue": venue,
"type": video_type,
"publishedAt": video["publishedAt"]
})
return parsed
def main():
print("Fetching Goose YouTube channel videos...")
# Get channel ID
print(f"Looking up channel: {CHANNEL_HANDLE}")
channel_id = get_channel_id(CHANNEL_HANDLE)
if not channel_id:
print("Could not find channel!")
return
print(f"Channel ID: {channel_id}")
# Get uploads playlist
uploads_playlist = get_uploads_playlist_id(channel_id)
if not uploads_playlist:
print("Could not find uploads playlist!")
return
print(f"Uploads playlist: {uploads_playlist}")
# Fetch all videos
videos = get_all_videos(uploads_playlist)
print(f"\nTotal videos found: {len(videos)}")
# Parse metadata
parsed = parse_video_metadata(videos)
# Save to JSON
output_file = "youtube_videos.json"
with open(output_file, 'w') as f:
json.dump(parsed, f, indent=2)
print(f"\nSaved to {output_file}")
# Show stats
types = {}
dated = 0
for v in parsed:
types[v["type"]] = types.get(v["type"], 0) + 1
if v["date"]:
dated += 1
print("\n=== Stats ===")
print(f"Total: {len(parsed)}")
print(f"With dates: {dated}")
for vtype, count in sorted(types.items()):
print(f" {vtype}: {count}")
if __name__ == "__main__":
main()

View file

@ -12,6 +12,7 @@ from models import (
User, UserPreferences
)
from passlib.context import CryptContext
from slugify import generate_slug, generate_show_slug
BASE_URL = "https://elgoose.net/api/v2"
ARTIST_ID = 1 # Goose
@ -131,6 +132,7 @@ def import_venues(session):
else:
venue = Venue(
name=v['venuename'],
slug=generate_slug(v['venuename']),
city=v.get('city'),
state=v.get('state'),
country=v.get('country'),
@ -166,6 +168,7 @@ def import_songs(session, vertical_id):
else:
song = Song(
title=s['name'],
slug=generate_slug(s['name']),
original_artist=s.get('original_artist'),
vertical_id=vertical_id
# API doesn't include debut_date or times_played in base response
@ -211,7 +214,10 @@ def import_shows(session, vertical_id, venue_map):
if existing_tour:
tour_map[s['tour_id']] = existing_tour.id
else:
tour = Tour(name=s['tourname'])
tour = Tour(
name=s['tourname'],
slug=generate_slug(s['tourname'])
)
session.add(tour)
session.commit()
session.refresh(tour)
@ -235,6 +241,7 @@ def import_shows(session, vertical_id, venue_map):
else:
show = Show(
date=show_date,
slug=generate_show_slug(s['showdate'], s.get('venuename', 'unknown')),
vertical_id=vertical_id,
venue_id=venue_map.get(s['venue_id']),
tour_id=tour_id,
@ -292,11 +299,24 @@ def import_setlists(session, show_map, song_map):
).first()
if not existing_perf:
# Map setnumber to set_name
set_val = str(perf_data.get('setnumber', '1'))
if set_val.isdigit():
set_name = f"Set {set_val}"
elif set_val.lower() == 'e':
set_name = "Encore"
elif set_val.lower() == 'e2':
set_name = "Encore 2"
elif set_val.lower() == 's':
set_name = "Soundcheck"
else:
set_name = f"Set {set_val}"
perf = Performance(
show_id=our_show_id,
song_id=our_song_id,
position=perf_data.get('position', 0),
set_name=perf_data.get('set'),
set_name=set_name,
segue=bool(perf_data.get('segue', 0)),
notes=perf_data.get('notes')
)

255
backend/import_youtube.py Normal file
View file

@ -0,0 +1,255 @@
"""
YouTube Video Import Script v3
Improved title matching with fuzzy logic and normalization.
"""
import json
import re
from datetime import datetime
from sqlmodel import Session, select
from database import engine
from models import Performance, Show, Song
def make_youtube_url(video_id: str) -> str:
return f"https://www.youtube.com/watch?v={video_id}"
def normalize_title(title: str) -> str:
"""Normalize title for better matching."""
title = title.lower().strip()
# Remove common suffixes/prefixes
title = re.sub(r'\s*\(.*?\)', '', title) # Remove parentheticals
title = re.sub(r'\s*\[.*?\]', '', title) # Remove brackets
title = re.sub(r'\s*feat\.?\s+.*$', '', title, flags=re.IGNORECASE) # Remove feat.
title = re.sub(r'\s*ft\.?\s+.*$', '', title, flags=re.IGNORECASE) # Remove ft.
title = re.sub(r'\s*w/\s+.*$', '', title) # Remove w/ collaborators
title = re.sub(r'\s*[-–—]\s*$', '', title) # Trailing dashes
# Normalize characters
title = title.replace('&', 'and')
title = re.sub(r'[^\w\s]', '', title) # Remove punctuation
title = re.sub(r'\s+', ' ', title) # Collapse whitespace
return title.strip()
def extract_song_title(raw_title: str) -> str:
"""Extract the actual song title from YouTube video title."""
title = raw_title
# Remove common prefixes
title = re.sub(r'^Goose\s*[-–—]\s*', '', title, flags=re.IGNORECASE)
# Remove date patterns (e.g., "- 12/13/25 Providence, RI")
title = re.sub(r'\s*[-–—]\s*\d{1,2}/\d{1,2}/\d{2,4}.*$', '', title)
# Remove "Live at..." suffix
title = re.sub(r'\s*[-–—]\s*Live at.*$', '', title, flags=re.IGNORECASE)
# Remove "(Official Audio)" etc
title = re.sub(r'\s*\(Official\s*(Audio|Video|Visualizer)\)', '', title, flags=re.IGNORECASE)
# Remove "(4K HDR)" etc
title = re.sub(r'\s*\(4K\s*HDR?\)', '', title, flags=re.IGNORECASE)
# Remove "Set I/II Opener" etc
title = re.sub(r'\s*Set\s*(I|II|1|2)?\s*Opener.*$', '', title, flags=re.IGNORECASE)
# Remove "Live from..." suffix
title = re.sub(r'\s*Live from.*$', '', title, flags=re.IGNORECASE)
# Remove date at start (e.g., "9/20/2025")
title = re.sub(r'^\d{1,2}/\d{1,2}/\d{2,4}\s*', '', title)
# Remove location suffix (e.g., "Providence, RI")
title = re.sub(r'\s*[-–—]?\s*[A-Z][a-z]+,?\s*[A-Z]{2}\s*$', '', title)
return title.strip()
def find_song_match(session, song_title: str, all_songs: list) -> Song:
"""Try multiple matching strategies to find a song."""
normalized_search = normalize_title(song_title)
# Strategy 1: Exact match (case insensitive)
for song in all_songs:
if song.title.lower() == song_title.lower():
return song
# Strategy 2: Normalized exact match
for song in all_songs:
if normalize_title(song.title) == normalized_search:
return song
# Strategy 3: Starts with (for songs with suffixes in DB)
for song in all_songs:
if normalize_title(song.title).startswith(normalized_search):
return song
if normalized_search.startswith(normalize_title(song.title)):
return song
# Strategy 4: Contains (substring match)
for song in all_songs:
norm_song = normalize_title(song.title)
if len(normalized_search) >= 4: # Avoid short false positives
if normalized_search in norm_song or norm_song in normalized_search:
return song
# Strategy 5: Word overlap (for complex titles)
search_words = set(normalized_search.split())
if len(search_words) >= 2: # Only for multi-word titles
for song in all_songs:
song_words = set(normalize_title(song.title).split())
# If most words match
overlap = len(search_words & song_words)
if overlap >= len(search_words) * 0.7:
return song
return None
def import_videos():
"""Import video links into the database."""
with open("youtube_videos.json", 'r') as f:
videos = json.load(f)
stats = {
'songs_matched': 0,
'songs_not_found': 0,
'songs_not_found_titles': [],
'sequences_processed': 0,
'full_shows_matched': 0,
'no_date': 0,
'skipped': 0,
'show_not_found': 0
}
with Session(engine) as session:
# Pre-load all songs for faster matching
all_songs = session.exec(select(Song)).all()
print(f"Loaded {len(all_songs)} songs from database")
for video in videos:
video_id = video.get('videoId')
raw_title = video.get('title', '')
video_type = video.get('type', 'song')
date_str = video.get('date')
youtube_url = make_youtube_url(video_id)
# Skip non-performance content
if video_type in ('documentary', 'visualizer', 'session'):
stats['skipped'] += 1
continue
# Skip videos without dates
if not date_str:
stats['no_date'] += 1
continue
# Parse date
try:
show_date = datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
stats['no_date'] += 1
continue
# Find show by date
show = session.exec(
select(Show).where(Show.date == show_date)
).first()
if not show:
stats['show_not_found'] += 1
continue
# Handle full shows
if video_type == 'full_show':
show.youtube_link = youtube_url
session.add(show)
stats['full_shows_matched'] += 1
continue
# Extract song title
song_title = extract_song_title(raw_title)
# Handle sequences
if video_type == 'sequence' or '' in song_title or '>' in song_title:
song_titles = [s.strip() for s in re.split(r'[→>]', song_title)]
matched_any = False
for title in song_titles:
if not title or len(title) < 2:
continue
song = find_song_match(session, title, all_songs)
if song:
perf = session.exec(
select(Performance).where(
Performance.show_id == show.id,
Performance.song_id == song.id
)
).first()
if perf:
perf.youtube_link = youtube_url
session.add(perf)
matched_any = True
if matched_any:
stats['sequences_processed'] += 1
else:
stats['songs_not_found'] += 1
stats['songs_not_found_titles'].append(f"SEQ: {song_title}")
continue
# Single song matching
song = find_song_match(session, song_title, all_songs)
if song:
perf = session.exec(
select(Performance).where(
Performance.show_id == show.id,
Performance.song_id == song.id
)
).first()
if perf:
perf.youtube_link = youtube_url
session.add(perf)
stats['songs_matched'] += 1
else:
# Song exists but wasn't played at this show
stats['songs_not_found'] += 1
stats['songs_not_found_titles'].append(f"{date_str}: {song_title} (song exists, no perf)")
else:
stats['songs_not_found'] += 1
stats['songs_not_found_titles'].append(f"{date_str}: {song_title}")
session.commit()
print("\n" + "="*50)
print("IMPORT SUMMARY")
print("="*50)
print(f" songs_matched: {stats['songs_matched']}")
print(f" sequences_processed: {stats['sequences_processed']}")
print(f" full_shows_matched: {stats['full_shows_matched']}")
print(f" songs_not_found: {stats['songs_not_found']}")
print(f" no_date: {stats['no_date']}")
print(f" skipped: {stats['skipped']}")
print(f" show_not_found: {stats['show_not_found']}")
total_linked = stats['songs_matched'] + stats['sequences_processed'] + stats['full_shows_matched']
print(f"\n TOTAL LINKED: {total_linked}")
# Show some unmatched titles for debugging
if stats['songs_not_found_titles']:
print("\n" + "="*50)
print("SAMPLE UNMATCHED (first 20):")
print("="*50)
for title in stats['songs_not_found_titles'][:20]:
print(f" - {title}")
if __name__ == "__main__":
import_videos()

View file

@ -1,5 +1,5 @@
from fastapi import FastAPI
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos
from fastapi.middleware.cors import CORSMiddleware
@ -34,7 +34,17 @@ app.include_router(notifications.router)
app.include_router(feed.router)
app.include_router(leaderboards.router)
app.include_router(stats.router)
app.include_router(admin.router)
app.include_router(chase.router)
app.include_router(gamification.router)
app.include_router(videos.router)
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/healthz")
def health_check():
"""Health check endpoint for monitoring and load balancers"""
return {"status": "healthy"}

View file

@ -0,0 +1,288 @@
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlmodel import Session, select
from database import engine
from models import Venue, Song, Show, Tour, Performance
from slugify import generate_slug, generate_show_slug
import requests
import time
BASE_URL = "https://elgoose.net/api/v2"
def fetch_all_json(endpoint, params=None):
all_data = []
page = 1
params = params.copy() if params else {}
print(f"Fetching {endpoint}...")
seen_ids = set()
while True:
params['page'] = page
url = f"{BASE_URL}/{endpoint}.json"
try:
resp = requests.get(url, params=params)
if resp.status_code != 200:
print(f" Failed with status {resp.status_code}")
break
# API can return a dict with 'data' or just a list sometimes, handling both
json_resp = resp.json()
if isinstance(json_resp, dict):
items = json_resp.get('data', [])
elif isinstance(json_resp, list):
items = json_resp
else:
items = []
if not items:
print(" No more items found.")
break
# Check for cycles / infinite loop by checking if we've seen these IDs before
# Assuming items have 'id' or 'show_id' etc.
# If not, we hash the string representation.
new_items_count = 0
for item in items:
# Try to find a unique identifier
uid = item.get('id') or item.get('show_id') or str(item)
if uid not in seen_ids:
seen_ids.add(uid)
all_data.append(item)
new_items_count += 1
if new_items_count == 0:
print(f" Page {page} returned {len(items)} items but all were duplicates. Stopping.")
break
print(f" Page {page} done ({new_items_count} new items)")
page += 1
time.sleep(0.5)
# Safety break
if page > 1000:
print(" Hit 1000 pages safety limit.")
break
if page > 200: # Safety break
print(" Safety limit reached.")
break
except Exception as e:
print(f"Error fetching {endpoint}: {e}")
break
return all_data
def fix_data():
with Session(engine) as session:
# 1. Fix Venues Slugs
print("Fixing Venue Slugs...")
venues = session.exec(select(Venue)).all()
existing_venue_slugs = {v.slug for v in venues if v.slug}
for v in venues:
if not v.slug:
new_slug = generate_slug(v.name)
# Ensure unique
original_slug = new_slug
counter = 1
while new_slug in existing_venue_slugs:
counter += 1
new_slug = f"{original_slug}-{counter}"
v.slug = new_slug
existing_venue_slugs.add(new_slug)
session.add(v)
session.commit()
# 2. Fix Songs Slugs
print("Fixing Song Slugs...")
songs = session.exec(select(Song)).all()
existing_song_slugs = {s.slug for s in songs if s.slug}
for s in songs:
if not s.slug:
new_slug = generate_slug(s.title)
original_slug = new_slug
counter = 1
while new_slug in existing_song_slugs:
counter += 1
new_slug = f"{original_slug}-{counter}"
s.slug = new_slug
existing_song_slugs.add(new_slug)
session.add(s)
session.commit()
# 3. Fix Tours Slugs
print("Fixing Tour Slugs...")
tours = session.exec(select(Tour)).all()
existing_tour_slugs = {t.slug for t in tours if t.slug}
for t in tours:
if not t.slug:
new_slug = generate_slug(t.name)
original_slug = new_slug
counter = 1
while new_slug in existing_tour_slugs:
counter += 1
new_slug = f"{original_slug}-{counter}"
t.slug = new_slug
existing_tour_slugs.add(new_slug)
session.add(t)
session.commit()
# 4. Fix Shows Slugs
print("Fixing Show Slugs...")
shows = session.exec(select(Show)).all()
existing_show_slugs = {s.slug for s in shows if s.slug}
venue_map = {v.id: v for v in venues} # Cache venues for naming
for show in shows:
if not show.slug:
date_str = show.date.strftime("%Y-%m-%d") if show.date else "unknown"
venue_name = "unknown"
if show.venue_id and show.venue_id in venue_map:
venue_name = venue_map[show.venue_id].name
new_slug = generate_show_slug(date_str, venue_name)
# Ensure unique
original_slug = new_slug
counter = 1
while new_slug in existing_show_slugs:
counter += 1
new_slug = f"{original_slug}-{counter}"
show.slug = new_slug
existing_show_slugs.add(new_slug)
session.add(show)
session.commit()
# 4b. Fix Performance Slugs
print("Fixing Performance Slugs...")
from slugify import generate_performance_slug
perfs = session.exec(select(Performance)).all()
existing_perf_slugs = {p.slug for p in perfs if p.slug}
# We need song titles and show dates
# Efficient way: build maps
song_map = {s.id: s.title for s in songs}
show_map = {s.id: s.date.strftime("%Y-%m-%d") for s in shows}
for p in perfs:
if not p.slug:
song_title = song_map.get(p.song_id, "unknown")
show_date = show_map.get(p.show_id, "unknown")
new_slug = generate_performance_slug(song_title, show_date)
# Ensure unique (for reprises etc)
original_slug = new_slug
counter = 1
while new_slug in existing_perf_slugs:
counter += 1
new_slug = f"{original_slug}-{counter}"
p.slug = new_slug
existing_perf_slugs.add(new_slug)
session.add(p)
session.commit()
# 5. Fix Set Names (Fetch API)
print("Fixing Set Names (fetching setlists)...")
# We need to map El Goose show_id/song_id to our IDs to find the record.
# But we don't store El Goose IDs in our models?
# Checked models.py: we don't store ex_id.
# We match by show date/venue and song title.
# This is hard to do reliably without external IDs.
# Alternatively, we can infer set name from 'position'?
# No, position 1 could be Set 1 or Encore if short show? No.
# Wait, import_elgoose mappings are local var.
# If we re-run import logic but UPDATE instead of SKIP, we can fix it.
# But matching is tricky.
# Let's try to match by Show Date and Song Title.
# Build map: (show_id, song_id, position) -> Performance
# Refresh perfs from DB since we might have added slugs
# perfs = session.exec(select(Performance)).all() # Already have them, but maybe stale?
# Re-querying is safer but PERFS list object is updated by session.add? Yes.
perf_map = {} # (show_id, song_id, position) -> perf object
for p in perfs:
perf_map[(p.show_id, p.song_id, p.position)] = p
# We need show map: el_goose_show_id -> our_show_id
# We need song map: el_goose_song_id -> our_song_id
# We have to re-fetch shows and songs to rebuild this map.
print(" Re-building ID maps...")
# Map Shows
el_shows = fetch_all_json("shows", {"artist": 1})
if not el_shows: el_shows = fetch_all_json("shows") # fallback
el_show_map = {} # el_id -> our_id
for s in el_shows:
# Find our show
dt = s['showdate'] # YYYY-MM-DD
# We need to match precise Show.
# Simplified: match by date.
# Convert string to datetime
from datetime import datetime
s_date = datetime.strptime(dt, "%Y-%m-%d")
# Find show in our DB
# We can optimise this but for now linear search or query is fine for one-off script
found = session.exec(select(Show).where(Show.date == s_date)).first()
if found:
el_show_map[s['show_id']] = found.id
# Map Songs
el_songs = fetch_all_json("songs")
el_song_map = {} # el_id -> our_id
for s in el_songs:
found = session.exec(select(Song).where(Song.title == s['name'])).first()
if found:
el_song_map[s['id']] = found.id
# Now fetch setlists
el_setlists = fetch_all_json("setlists")
count = 0
for item in el_setlists:
our_show_id = el_show_map.get(item['show_id'])
our_song_id = el_song_map.get(item['song_id'])
position = item.get('position', 0)
if our_show_id and our_song_id:
# Find existing perf
perf = perf_map.get((our_show_id, our_song_id, position))
if perf:
# Logic to fix set_name
set_val = str(item.get('setnumber', '1'))
set_name = f"Set {set_val}"
if set_val.isdigit():
set_name = f"Set {set_val}"
elif set_val.lower() == 'e':
set_name = "Encore"
elif set_val.lower() == 'e2':
set_name = "Encore 2"
elif set_val.lower() == 's':
set_name = "Soundcheck"
if perf.set_name != set_name:
perf.set_name = set_name
session.add(perf)
count += 1
else:
# Debug only first few failures to avoid spam
if count < 5:
print(f"Match failed for el_show_id={item.get('show_id')} el_song_id={item.get('song_id')}")
if not our_show_id: print(f" -> Show ID not found in map (Map size: {len(el_show_map)})")
if not our_song_id: print(f" -> Song ID not found in map (Map size: {len(el_song_map)})")
session.commit()
print(f"Fixed {count} performance set names.")
if __name__ == "__main__":
fix_data()

View file

@ -0,0 +1,34 @@
"""
Migration to add email verification and password reset columns to user table.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlmodel import Session, create_engine, text
from database import DATABASE_URL
def add_email_verification_columns():
engine = create_engine(DATABASE_URL)
columns = [
('email_verified', 'BOOLEAN DEFAULT FALSE'),
('verification_token', 'VARCHAR'),
('verification_token_expires', 'TIMESTAMP'),
('reset_token', 'VARCHAR'),
('reset_token_expires', 'TIMESTAMP'),
]
with Session(engine) as session:
for col_name, col_type in columns:
try:
session.exec(text(f"""
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS {col_name} {col_type}
"""))
session.commit()
print(f"✅ Added {col_name} to user")
except Exception as e:
print(f"⚠️ {col_name}: {e}")
if __name__ == "__main__":
add_email_verification_columns()

View file

@ -0,0 +1,43 @@
"""Add venue_id and tour_id columns to rating table"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import DATABASE_URL
import psycopg2
def run():
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
# Add venue_id column
try:
cur.execute("ALTER TABLE rating ADD COLUMN venue_id INTEGER REFERENCES venue(id)")
print("✅ Added venue_id to rating")
except Exception as e:
if "already exists" in str(e):
print("⚠️ venue_id column already exists")
else:
print(f"❌ Error adding venue_id: {e}")
conn.rollback()
else:
conn.commit()
# Add tour_id column
try:
cur.execute("ALTER TABLE rating ADD COLUMN tour_id INTEGER REFERENCES tour(id)")
print("✅ Added tour_id to rating")
except Exception as e:
if "already exists" in str(e):
print("⚠️ tour_id column already exists")
else:
print(f"❌ Error adding tour_id: {e}")
conn.rollback()
else:
conn.commit()
cur.close()
conn.close()
if __name__ == "__main__":
run()

View file

@ -0,0 +1,26 @@
from sqlmodel import Session, create_engine, text
from database import DATABASE_URL
def add_reaction_table():
engine = create_engine(DATABASE_URL)
with Session(engine) as session:
try:
session.exec(text("""
CREATE TABLE IF NOT EXISTS reaction (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id),
entity_type VARCHAR NOT NULL,
entity_id INTEGER NOT NULL,
emoji VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""))
session.exec(text("CREATE INDEX IF NOT EXISTS ix_reaction_entity_type ON reaction (entity_type)"))
session.exec(text("CREATE INDEX IF NOT EXISTS ix_reaction_entity_id ON reaction (entity_id)"))
session.commit()
print("Successfully created reaction table")
except Exception as e:
print(f"Error creating table: {e}")
if __name__ == "__main__":
add_reaction_table()

View file

@ -0,0 +1,225 @@
"""
Migration script to add slug columns and generate slugs for existing data
"""
import os
import sys
# Add parent directory (backend/) to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlmodel import create_engine, Session, select, text
from slugify import generate_slug, generate_show_slug, generate_performance_slug
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://elmeg:elmeg@localhost/elmeg")
engine = create_engine(DATABASE_URL)
def add_slug_columns():
"""Add slug columns to tables if they don't exist"""
with engine.connect() as conn:
# Add slug to song
conn.execute(text("ALTER TABLE song ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_song_slug ON song(slug)"))
# Add slug to venue
conn.execute(text("ALTER TABLE venue ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_venue_slug ON venue(slug)"))
# Add slug to show
conn.execute(text("ALTER TABLE show ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_show_slug ON show(slug)"))
# Add slug to tour
conn.execute(text("ALTER TABLE tour ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_tour_slug ON tour(slug)"))
# Add slug to performance
conn.execute(text("ALTER TABLE performance ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_performance_slug ON performance(slug)"))
conn.commit()
print("✓ Slug columns added")
def generate_song_slugs():
"""Generate slugs for all songs"""
with Session(engine) as session:
# Get all songs without slugs
result = session.exec(text("SELECT id, title FROM song WHERE slug IS NULL"))
songs = result.fetchall()
existing_slugs = set()
# Get existing slugs
existing = session.exec(text("SELECT slug FROM song WHERE slug IS NOT NULL"))
for row in existing.fetchall():
existing_slugs.add(row[0])
count = 0
for song_id, title in songs:
base_slug = generate_slug(title, 50)
slug = base_slug
counter = 2
while slug in existing_slugs:
slug = f"{base_slug}-{counter}"
counter += 1
existing_slugs.add(slug)
session.execute(
text("UPDATE song SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": song_id}
)
count += 1
session.commit()
print(f"✓ Generated slugs for {count} songs")
def generate_venue_slugs():
"""Generate slugs for all venues"""
with Session(engine) as session:
result = session.exec(text("SELECT id, name, city FROM venue WHERE slug IS NULL"))
venues = result.fetchall()
existing_slugs = set()
existing = session.exec(text("SELECT slug FROM venue WHERE slug IS NOT NULL"))
for row in existing.fetchall():
existing_slugs.add(row[0])
count = 0
for venue_id, name, city in venues:
# Include city to help disambiguate
base_slug = generate_slug(f"{name} {city}", 60)
slug = base_slug
counter = 2
while slug in existing_slugs:
slug = f"{base_slug}-{counter}"
counter += 1
existing_slugs.add(slug)
session.execute(
text("UPDATE venue SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": venue_id}
)
count += 1
session.commit()
print(f"✓ Generated slugs for {count} venues")
def generate_show_slugs():
"""Generate slugs for all shows"""
with Session(engine) as session:
result = session.exec(text("""
SELECT s.id, s.date, v.name
FROM show s
LEFT JOIN venue v ON s.venue_id = v.id
WHERE s.slug IS NULL
"""))
shows = result.fetchall()
existing_slugs = set()
existing = session.exec(text("SELECT slug FROM show WHERE slug IS NOT NULL"))
for row in existing.fetchall():
existing_slugs.add(row[0])
count = 0
for show_id, date, venue_name in shows:
date_str = date.strftime("%Y-%m-%d") if date else "unknown"
venue_slug = generate_slug(venue_name or "unknown", 25)
base_slug = f"{date_str}-{venue_slug}"
slug = base_slug
counter = 2
while slug in existing_slugs:
slug = f"{base_slug}-{counter}"
counter += 1
existing_slugs.add(slug)
session.execute(
text("UPDATE show SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": show_id}
)
count += 1
session.commit()
print(f"✓ Generated slugs for {count} shows")
def generate_tour_slugs():
"""Generate slugs for all tours"""
with Session(engine) as session:
result = session.exec(text("SELECT id, name FROM tour WHERE slug IS NULL"))
tours = result.fetchall()
existing_slugs = set()
existing = session.exec(text("SELECT slug FROM tour WHERE slug IS NOT NULL"))
for row in existing.fetchall():
existing_slugs.add(row[0])
count = 0
for tour_id, name in tours:
base_slug = generate_slug(name, 50)
slug = base_slug
counter = 2
while slug in existing_slugs:
slug = f"{base_slug}-{counter}"
counter += 1
existing_slugs.add(slug)
session.execute(
text("UPDATE tour SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": tour_id}
)
count += 1
session.commit()
print(f"✓ Generated slugs for {count} tours")
def generate_performance_slugs():
"""Generate slugs for all performances (songslug-date format)"""
with Session(engine) as session:
result = session.exec(text("""
SELECT p.id, s.slug as song_slug, sh.date
FROM performance p
JOIN song s ON p.song_id = s.id
JOIN show sh ON p.show_id = sh.id
WHERE p.slug IS NULL
"""))
performances = result.fetchall()
existing_slugs = set()
existing = session.exec(text("SELECT slug FROM performance WHERE slug IS NOT NULL"))
for row in existing.fetchall():
existing_slugs.add(row[0])
count = 0
for perf_id, song_slug, date in performances:
date_str = date.strftime("%Y-%m-%d") if date else "unknown"
base_slug = f"{song_slug}-{date_str}"
slug = base_slug
counter = 2
# Handle multiple performances of same song in same show
while slug in existing_slugs:
slug = f"{base_slug}-{counter}"
counter += 1
existing_slugs.add(slug)
session.execute(
text("UPDATE performance SET slug = :slug WHERE id = :id"),
{"slug": slug, "id": perf_id}
)
count += 1
session.commit()
print(f"✓ Generated slugs for {count} performances")
def run_migration():
print("=== Running Slug Migration ===")
add_slug_columns()
generate_song_slugs()
generate_venue_slugs()
generate_tour_slugs()
generate_show_slugs()
generate_performance_slugs() # Must run after song slugs
print("=== Migration Complete ===")
if __name__ == "__main__":
run_migration()

View file

@ -0,0 +1,28 @@
"""
Migration to add youtube_link column to show, song, and performance tables.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlmodel import Session, create_engine, text
from database import DATABASE_URL
def add_youtube_link_columns():
engine = create_engine(DATABASE_URL)
tables = ['show', 'song', 'performance']
with Session(engine) as session:
for table in tables:
try:
session.exec(text(f"""
ALTER TABLE "{table}" ADD COLUMN IF NOT EXISTS youtube_link VARCHAR
"""))
session.commit()
print(f"✅ Added youtube_link to {table}")
except Exception as e:
print(f"⚠️ {table}: {e}")
if __name__ == "__main__":
add_youtube_link_columns()

View file

@ -6,6 +6,7 @@ from datetime import datetime
class Performance(SQLModel, table=True):
"""Link table between Show and Song (Many-to-Many with extra data)"""
id: Optional[int] = Field(default=None, primary_key=True)
slug: Optional[str] = Field(default=None, unique=True, index=True, description="songslug-YYYY-MM-DD")
show_id: int = Field(foreign_key="show.id")
song_id: int = Field(foreign_key="song.id")
position: int = Field(description="Order in the setlist")
@ -13,6 +14,7 @@ class Performance(SQLModel, table=True):
segue: bool = Field(default=False, description="Transition to next song >")
notes: Optional[str] = Field(default=None)
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
show: "Show" = Relationship(back_populates="performances")
@ -63,6 +65,7 @@ class Vertical(SQLModel, table=True):
class Venue(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
city: str
state: Optional[str] = Field(default=None)
country: str
@ -74,6 +77,7 @@ class Venue(SQLModel, table=True):
class Tour(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
notes: Optional[str] = Field(default=None)
@ -89,6 +93,7 @@ class Artist(SQLModel, table=True):
class Show(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
date: datetime = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
vertical_id: int = Field(foreign_key="vertical.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
@ -97,6 +102,7 @@ class Show(SQLModel, table=True):
# External Links
bandcamp_link: Optional[str] = Field(default=None)
nugs_link: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
vertical: Vertical = Relationship(back_populates="shows")
venue: Optional[Venue] = Relationship(back_populates="shows")
@ -107,9 +113,11 @@ class Show(SQLModel, table=True):
class Song(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
original_artist: Optional[str] = Field(default=None)
vertical_id: int = Field(foreign_key="vertical.id")
notes: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
vertical: Vertical = Relationship(back_populates="songs")
@ -145,12 +153,14 @@ class Comment(SQLModel, table=True):
class Rating(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
score: int = Field(ge=1, le=10, description="Rating from 1 to 10")
score: float = Field(ge=1.0, le=10.0, description="Rating from 1.0 to 10.0")
created_at: datetime = Field(default_factory=datetime.utcnow)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
performance_id: Optional[int] = Field(default=None, foreign_key="performance.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
user: "User" = Relationship(back_populates="ratings")
@ -164,6 +174,29 @@ class User(SQLModel, table=True):
bio: Optional[str] = Field(default=None)
avatar: Optional[str] = Field(default=None)
# Gamification
xp: int = Field(default=0, description="Experience points")
level: int = Field(default=1, description="User level based on XP")
streak_days: int = Field(default=0, description="Consecutive days active")
last_activity: Optional[datetime] = Field(default=None)
# Custom Titles & Flair (tracker forum style)
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
flair: Optional[str] = Field(default=None, description="Small text/emoji beside name")
is_early_adopter: bool = Field(default=False, description="First 100 users get special perks")
is_supporter: bool = Field(default=False, description="Donated/supported the platform")
joined_at: datetime = Field(default_factory=datetime.utcnow)
# Email verification
email_verified: bool = Field(default=False)
verification_token: Optional[str] = Field(default=None)
verification_token_expires: Optional[datetime] = Field(default=None)
# Password reset
reset_token: Optional[str] = Field(default=None)
reset_token_expires: Optional[datetime] = Field(default=None)
# Multi-identity support: A user can have multiple Profiles
profiles: List["Profile"] = Relationship(back_populates="user")
comments: List["Comment"] = Relationship(back_populates="user")
@ -193,6 +226,9 @@ class Badge(SQLModel, table=True):
description: str
icon: str = Field(description="Lucide icon name or image URL")
slug: str = Field(unique=True, index=True)
tier: str = Field(default="bronze", description="bronze, silver, gold, platinum, diamond")
category: str = Field(default="general", description="attendance, ratings, social, milestones")
xp_reward: int = Field(default=50, description="XP awarded when badge is earned")
class UserBadge(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
@ -208,7 +244,7 @@ class Review(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id")
blurb: str = Field(description="One-liner/pullquote")
content: str = Field(description="Full review text")
score: int = Field(ge=1, le=10)
score: float = Field(ge=1.0, le=10.0)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
@ -290,3 +326,18 @@ class Reaction(SQLModel, table=True):
created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship()
class ChaseSong(SQLModel, table=True):
"""Songs a user wants to see live (hasn't seen performed yet or wants to see again)"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
song_id: int = Field(foreign_key="song.id", index=True)
priority: int = Field(default=1, description="1=high, 2=medium, 3=low")
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
caught_at: Optional[datetime] = Field(default=None, description="When they finally saw it")
caught_show_id: Optional[int] = Field(default=None, foreign_key="show.id")
user: User = Relationship()
song: "Song" = Relationship()

View file

@ -11,3 +11,5 @@ argon2-cffi
psycopg2-binary
requests
beautifulsoup4
boto3
email-validator

434
backend/routers/admin.py Normal file
View file

@ -0,0 +1,434 @@
"""
Admin Router - Protected endpoints for admin users only.
User management, content CRUD, platform stats.
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from pydantic import BaseModel
from database import get_session
from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance
from dependencies import RoleChecker
from auth import get_password_hash
router = APIRouter(prefix="/admin", tags=["admin"])
# Only admins can access these endpoints
allow_admin = RoleChecker(["admin"])
# ============ SCHEMAS ============
class UserUpdate(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
email_verified: Optional[bool] = None
class UserListItem(BaseModel):
id: int
email: str
role: str
is_active: bool
email_verified: bool
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class ShowCreate(BaseModel):
date: datetime
vertical_id: int
venue_id: Optional[int] = None
tour_id: Optional[int] = None
notes: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
youtube_link: Optional[str] = None
class ShowUpdate(BaseModel):
date: Optional[datetime] = None
venue_id: Optional[int] = None
tour_id: Optional[int] = None
notes: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
youtube_link: Optional[str] = None
class SongCreate(BaseModel):
title: str
vertical_id: int
original_artist: Optional[str] = None
notes: Optional[str] = None
youtube_link: Optional[str] = None
class SongUpdate(BaseModel):
title: Optional[str] = None
original_artist: Optional[str] = None
notes: Optional[str] = None
youtube_link: Optional[str] = None
class VenueCreate(BaseModel):
name: str
city: str
state: Optional[str] = None
country: str = "USA"
capacity: Optional[int] = None
class VenueUpdate(BaseModel):
name: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
capacity: Optional[int] = None
class TourCreate(BaseModel):
name: str
vertical_id: int
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class TourUpdate(BaseModel):
name: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class PlatformStats(BaseModel):
total_users: int
verified_users: int
total_shows: int
total_songs: int
total_venues: int
total_ratings: int
total_reviews: int
total_comments: int
# ============ STATS ============
@router.get("/stats", response_model=PlatformStats)
def get_platform_stats(
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get platform-wide statistics"""
return PlatformStats(
total_users=session.exec(select(func.count(User.id))).one(),
verified_users=session.exec(select(func.count(User.id)).where(User.email_verified == True)).one(),
total_shows=session.exec(select(func.count(Show.id))).one(),
total_songs=session.exec(select(func.count(Song.id))).one(),
total_venues=session.exec(select(func.count(Venue.id))).one(),
total_ratings=session.exec(select(func.count(Rating.id))).one(),
total_reviews=session.exec(select(func.count(Review.id))).one(),
total_comments=session.exec(select(func.count(Comment.id))).one(),
)
# ============ USERS ============
@router.get("/users")
def list_users(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
role: Optional[str] = None,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""List all users with optional filtering"""
query = select(User)
if search:
query = query.where(User.email.contains(search))
if role:
query = query.where(User.role == role)
query = query.offset(skip).limit(limit)
users = session.exec(query).all()
# Get profiles for usernames
result = []
for user in users:
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
result.append({
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
})
return result
@router.get("/users/{user_id}")
def get_user(
user_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get user details with activity stats"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
return {
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
"bio": user.bio,
"stats": {
"ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(),
"reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(),
"comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(),
"attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(),
}
}
@router.patch("/users/{user_id}")
def update_user(
user_id: int,
update: UserUpdate,
session: Session = Depends(get_session),
admin: User = Depends(allow_admin)
):
"""Update user role, status, or verification"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent admin from demoting themselves
if user.id == admin.id and update.role and update.role != "admin":
raise HTTPException(status_code=400, detail="Cannot demote yourself")
if update.role is not None:
user.role = update.role
if update.is_active is not None:
user.is_active = update.is_active
if update.email_verified is not None:
user.email_verified = update.email_verified
session.add(user)
session.commit()
session.refresh(user)
return {"message": "User updated", "user_id": user.id}
# ============ SHOWS ============
@router.post("/shows")
def create_show(
show_data: ShowCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new show"""
show = Show(**show_data.model_dump())
session.add(show)
session.commit()
session.refresh(show)
return show
@router.patch("/shows/{show_id}")
def update_show(
show_id: int,
update: ShowUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update show details"""
show = session.get(Show, show_id)
if not show:
raise HTTPException(status_code=404, detail="Show not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(show, key, value)
session.add(show)
session.commit()
session.refresh(show)
return show
@router.delete("/shows/{show_id}")
def delete_show(
show_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a show"""
show = session.get(Show, show_id)
if not show:
raise HTTPException(status_code=404, detail="Show not found")
session.delete(show)
session.commit()
return {"message": "Show deleted", "show_id": show_id}
# ============ SONGS ============
@router.post("/songs")
def create_song(
song_data: SongCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new song"""
song = Song(**song_data.model_dump())
session.add(song)
session.commit()
session.refresh(song)
return song
@router.patch("/songs/{song_id}")
def update_song(
song_id: int,
update: SongUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update song details"""
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(song, key, value)
session.add(song)
session.commit()
session.refresh(song)
return song
@router.delete("/songs/{song_id}")
def delete_song(
song_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a song"""
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
session.delete(song)
session.commit()
return {"message": "Song deleted", "song_id": song_id}
# ============ VENUES ============
@router.post("/venues")
def create_venue(
venue_data: VenueCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new venue"""
venue = Venue(**venue_data.model_dump())
session.add(venue)
session.commit()
session.refresh(venue)
return venue
@router.patch("/venues/{venue_id}")
def update_venue(
venue_id: int,
update: VenueUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update venue details"""
venue = session.get(Venue, venue_id)
if not venue:
raise HTTPException(status_code=404, detail="Venue not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(venue, key, value)
session.add(venue)
session.commit()
session.refresh(venue)
return venue
@router.delete("/venues/{venue_id}")
def delete_venue(
venue_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a venue"""
venue = session.get(Venue, venue_id)
if not venue:
raise HTTPException(status_code=404, detail="Venue not found")
session.delete(venue)
session.commit()
return {"message": "Venue deleted", "venue_id": venue_id}
# ============ TOURS ============
@router.post("/tours")
def create_tour(
tour_data: TourCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new tour"""
tour = Tour(**tour_data.model_dump())
session.add(tour)
session.commit()
session.refresh(tour)
return tour
@router.patch("/tours/{tour_id}")
def update_tour(
tour_id: int,
update: TourUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update tour details"""
tour = session.get(Tour, tour_id)
if not tour:
raise HTTPException(status_code=404, detail="Tour not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(tour, key, value)
session.add(tour)
session.commit()
session.refresh(tour)
return tour
@router.delete("/tours/{tour_id}")
def delete_tour(
tour_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a tour"""
tour = session.get(Tour, tour_id)
if not tour:
raise HTTPException(status_code=404, detail="Tour not found")
session.delete(tour)
session.commit()
return {"message": "Tour deleted", "tour_id": tour_id}

View file

@ -5,6 +5,7 @@ from database import get_session
from models import Attendance, User, Show
from schemas import AttendanceCreate, AttendanceRead
from auth import get_current_user
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/attendance", tags=["attendance"])
@ -32,6 +33,12 @@ def mark_attendance(
db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id)
session.add(db_attendance)
# Award XP for marking attendance
new_xp, level_up = award_xp(session, current_user, XP_REWARDS["attendance_add"], "attendance")
update_streak(session, current_user)
new_badges = check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_attendance)
return db_attendance

View file

@ -1,24 +1,56 @@
from datetime import timedelta
from datetime import timedelta, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import Session, select
from pydantic import BaseModel, EmailStr
from database import get_session
from models import User, Profile
from schemas import UserCreate, Token, UserRead
from auth import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
from services.email_service import (
send_verification_email, send_password_reset_email,
generate_token, get_verification_expiry, get_reset_expiry
)
router = APIRouter(prefix="/auth", tags=["auth"])
# Request/Response schemas for new endpoints
class VerifyEmailRequest(BaseModel):
token: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
class ResendVerificationRequest(BaseModel):
email: EmailStr
@router.post("/register", response_model=UserRead)
def register(user_in: UserCreate, session: Session = Depends(get_session)):
async def register(
user_in: UserCreate,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
user = session.exec(select(User).where(User.email == user_in.email)).first()
if user:
raise HTTPException(status_code=400, detail="Email already registered")
# Create User
# Create User with verification token
hashed_password = get_password_hash(user_in.password)
db_user = User(email=user_in.email, hashed_password=hashed_password)
verification_token = generate_token()
db_user = User(
email=user_in.email,
hashed_password=hashed_password,
email_verified=False,
verification_token=verification_token,
verification_token_expires=get_verification_expiry()
)
session.add(db_user)
session.commit()
session.refresh(db_user)
@ -28,8 +60,112 @@ def register(user_in: UserCreate, session: Session = Depends(get_session)):
session.add(profile)
session.commit()
# Send verification email in background
background_tasks.add_task(send_verification_email, db_user.email, verification_token)
return db_user
@router.post("/verify-email")
def verify_email(request: VerifyEmailRequest, session: Session = Depends(get_session)):
"""Verify user's email with token"""
user = session.exec(
select(User).where(User.verification_token == request.token)
).first()
if not user:
raise HTTPException(status_code=400, detail="Invalid verification token")
if user.verification_token_expires and user.verification_token_expires < datetime.utcnow():
raise HTTPException(status_code=400, detail="Verification token expired")
if user.email_verified:
return {"message": "Email already verified"}
# Mark as verified
user.email_verified = True
user.verification_token = None
user.verification_token_expires = None
session.add(user)
session.commit()
return {"message": "Email verified successfully"}
@router.post("/resend-verification")
async def resend_verification(
request: ResendVerificationRequest,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
"""Resend verification email"""
user = session.exec(select(User).where(User.email == request.email)).first()
if not user:
# Don't reveal if email exists
return {"message": "If the email exists, a verification link has been sent"}
if user.email_verified:
return {"message": "Email already verified"}
# Generate new token
user.verification_token = generate_token()
user.verification_token_expires = get_verification_expiry()
session.add(user)
session.commit()
background_tasks.add_task(send_verification_email, user.email, user.verification_token)
return {"message": "If the email exists, a verification link has been sent"}
@router.post("/forgot-password")
async def forgot_password(
request: ForgotPasswordRequest,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
"""Request password reset email"""
user = session.exec(select(User).where(User.email == request.email)).first()
if not user:
# Don't reveal if email exists
return {"message": "If the email exists, a reset link has been sent"}
# Generate reset token
user.reset_token = generate_token()
user.reset_token_expires = get_reset_expiry()
session.add(user)
session.commit()
background_tasks.add_task(send_password_reset_email, user.email, user.reset_token)
return {"message": "If the email exists, a reset link has been sent"}
@router.post("/reset-password")
def reset_password(request: ResetPasswordRequest, session: Session = Depends(get_session)):
"""Reset password with token"""
user = session.exec(
select(User).where(User.reset_token == request.token)
).first()
if not user:
raise HTTPException(status_code=400, detail="Invalid reset token")
if user.reset_token_expires and user.reset_token_expires < datetime.utcnow():
raise HTTPException(status_code=400, detail="Reset token expired")
# Update password
user.hashed_password = get_password_hash(request.new_password)
user.reset_token = None
user.reset_token_expires = None
session.add(user)
session.commit()
return {"message": "Password reset successfully"}
@router.post("/token", response_model=Token)
def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
@ -49,6 +185,8 @@ def login_for_access_token(
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/users/me", response_model=UserRead)
def read_users_me(current_user: Annotated[User, Depends(get_current_user)]):
return current_user

327
backend/routers/chase.py Normal file
View file

@ -0,0 +1,327 @@
"""
Chase Songs and Profile Stats Router
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select, func
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
from database import get_session
from models import ChaseSong, Song, Attendance, Show, Performance, Rating, User
from routers.auth import get_current_user
router = APIRouter(prefix="/chase", tags=["chase"])
# --- Schemas ---
class ChaseSongCreate(BaseModel):
song_id: int
priority: int = 1
notes: Optional[str] = None
class ChaseSongResponse(BaseModel):
id: int
song_id: int
song_title: str
priority: int
notes: Optional[str]
created_at: datetime
caught_at: Optional[datetime]
caught_show_id: Optional[int]
caught_show_date: Optional[str] = None
class ChaseSongUpdate(BaseModel):
priority: Optional[int] = None
notes: Optional[str] = None
class ProfileStats(BaseModel):
shows_attended: int
unique_songs_seen: int
debuts_witnessed: int
heady_versions_attended: int # Top 10 rated performances
top_10_performances: int
total_ratings: int
total_reviews: int
chase_songs_count: int
chase_songs_caught: int
most_seen_song: Optional[str] = None
most_seen_count: int = 0
# --- Routes ---
@router.get("/songs", response_model=List[ChaseSongResponse])
async def get_my_chase_songs(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get all chase songs for the current user"""
statement = (
select(ChaseSong)
.where(ChaseSong.user_id == current_user.id)
.order_by(ChaseSong.priority, ChaseSong.created_at.desc())
)
chase_songs = session.exec(statement).all()
result = []
for cs in chase_songs:
song = session.get(Song, cs.song_id)
caught_show_date = None
if cs.caught_show_id:
show = session.get(Show, cs.caught_show_id)
if show:
caught_show_date = show.date.strftime("%Y-%m-%d") if show.date else None
result.append(ChaseSongResponse(
id=cs.id,
song_id=cs.song_id,
song_title=song.title if song else "Unknown",
priority=cs.priority,
notes=cs.notes,
created_at=cs.created_at,
caught_at=cs.caught_at,
caught_show_id=cs.caught_show_id,
caught_show_date=caught_show_date
))
return result
@router.post("/songs", response_model=ChaseSongResponse, status_code=status.HTTP_201_CREATED)
async def add_chase_song(
data: ChaseSongCreate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Add a song to user's chase list"""
# Check if song exists
song = session.get(Song, data.song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
# Check if already chasing
existing = session.exec(
select(ChaseSong)
.where(ChaseSong.user_id == current_user.id, ChaseSong.song_id == data.song_id)
).first()
if existing:
raise HTTPException(status_code=400, detail="Song already in chase list")
chase_song = ChaseSong(
user_id=current_user.id,
song_id=data.song_id,
priority=data.priority,
notes=data.notes
)
session.add(chase_song)
session.commit()
session.refresh(chase_song)
return ChaseSongResponse(
id=chase_song.id,
song_id=chase_song.song_id,
song_title=song.title,
priority=chase_song.priority,
notes=chase_song.notes,
created_at=chase_song.created_at,
caught_at=None,
caught_show_id=None
)
@router.patch("/songs/{chase_id}", response_model=ChaseSongResponse)
async def update_chase_song(
chase_id: int,
data: ChaseSongUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update a chase song"""
chase_song = session.get(ChaseSong, chase_id)
if not chase_song or chase_song.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Chase song not found")
if data.priority is not None:
chase_song.priority = data.priority
if data.notes is not None:
chase_song.notes = data.notes
session.add(chase_song)
session.commit()
session.refresh(chase_song)
song = session.get(Song, chase_song.song_id)
return ChaseSongResponse(
id=chase_song.id,
song_id=chase_song.song_id,
song_title=song.title if song else "Unknown",
priority=chase_song.priority,
notes=chase_song.notes,
created_at=chase_song.created_at,
caught_at=chase_song.caught_at,
caught_show_id=chase_song.caught_show_id
)
@router.delete("/songs/{chase_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_chase_song(
chase_id: int,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Remove a song from chase list"""
chase_song = session.get(ChaseSong, chase_id)
if not chase_song or chase_song.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Chase song not found")
session.delete(chase_song)
session.commit()
@router.post("/songs/{chase_id}/caught")
async def mark_song_caught(
chase_id: int,
show_id: int,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Mark a chase song as caught at a specific show"""
chase_song = session.get(ChaseSong, chase_id)
if not chase_song or chase_song.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Chase song not found")
show = session.get(Show, show_id)
if not show:
raise HTTPException(status_code=404, detail="Show not found")
chase_song.caught_at = datetime.utcnow()
chase_song.caught_show_id = show_id
session.add(chase_song)
session.commit()
return {"message": "Song marked as caught!"}
# Profile stats endpoint
@router.get("/profile/stats", response_model=ProfileStats)
async def get_profile_stats(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get comprehensive profile stats for the current user"""
# Shows attended
shows_attended = session.exec(
select(func.count(Attendance.id))
.where(Attendance.user_id == current_user.id)
).one() or 0
# Get show IDs user attended
attended_show_ids = session.exec(
select(Attendance.show_id)
.where(Attendance.user_id == current_user.id)
).all()
# Unique songs seen (performances at attended shows)
unique_songs_seen = session.exec(
select(func.count(func.distinct(Performance.song_id)))
.where(Performance.show_id.in_(attended_show_ids) if attended_show_ids else False)
).one() or 0
# Debuts witnessed (times_played = 1 at show they attended)
# This would require joining with song data - simplified for now
debuts_witnessed = 0
if attended_show_ids:
debuts_q = session.exec(
select(Performance)
.where(Performance.show_id.in_(attended_show_ids))
).all()
# Count performances where this was the debut
for perf in debuts_q:
# Check if this was the first performance of the song
earlier_perfs = session.exec(
select(func.count(Performance.id))
.join(Show, Performance.show_id == Show.id)
.where(Performance.song_id == perf.song_id)
.where(Show.date < session.get(Show, perf.show_id).date if session.get(Show, perf.show_id) else False)
).one()
if earlier_perfs == 0:
debuts_witnessed += 1
# Top performances attended (with avg rating >= 8.0)
top_performances_attended = 0
heady_versions_attended = 0
if attended_show_ids:
# Get average ratings for performances at attended shows
perf_ratings = session.exec(
select(
Rating.performance_id,
func.avg(Rating.score).label("avg_rating")
)
.where(Rating.performance_id.isnot(None))
.group_by(Rating.performance_id)
.having(func.avg(Rating.score) >= 8.0)
).all()
# Filter to performances at attended shows
high_rated_perf_ids = [pr[0] for pr in perf_ratings]
if high_rated_perf_ids:
attended_high_rated = session.exec(
select(func.count(Performance.id))
.where(Performance.id.in_(high_rated_perf_ids))
.where(Performance.show_id.in_(attended_show_ids))
).one() or 0
top_performances_attended = attended_high_rated
heady_versions_attended = attended_high_rated
# Total ratings/reviews
total_ratings = session.exec(
select(func.count(Rating.id)).where(Rating.user_id == current_user.id)
).one() or 0
total_reviews = session.exec(
select(func.count()).select_from(session.exec(
select(1).where(Rating.user_id == current_user.id) # placeholder
).subquery())
).one() if False else 0 # Will fix this
# Chase songs
chase_count = session.exec(
select(func.count(ChaseSong.id)).where(ChaseSong.user_id == current_user.id)
).one() or 0
chase_caught = session.exec(
select(func.count(ChaseSong.id))
.where(ChaseSong.user_id == current_user.id)
.where(ChaseSong.caught_at.isnot(None))
).one() or 0
# Most seen song
most_seen_song = None
most_seen_count = 0
if attended_show_ids:
song_counts = session.exec(
select(
Performance.song_id,
func.count(Performance.id).label("count")
)
.where(Performance.show_id.in_(attended_show_ids))
.group_by(Performance.song_id)
.order_by(func.count(Performance.id).desc())
.limit(1)
).first()
if song_counts:
song = session.get(Song, song_counts[0])
if song:
most_seen_song = song.title
most_seen_count = song_counts[1]
return ProfileStats(
shows_attended=shows_attended,
unique_songs_seen=unique_songs_seen,
debuts_witnessed=min(debuts_witnessed, 50), # Cap to prevent timeout
heady_versions_attended=heady_versions_attended,
top_10_performances=top_performances_attended,
total_ratings=total_ratings,
total_reviews=0, # TODO: implement
chase_songs_count=chase_count,
chase_songs_caught=chase_caught,
most_seen_song=most_seen_song,
most_seen_count=most_seen_count
)

View file

@ -0,0 +1,371 @@
"""
Gamification Router - XP, Levels, Leaderboards
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from pydantic import BaseModel
from datetime import datetime
from database import get_session
from models import User, Badge, UserBadge
from routers.auth import get_current_user
from services.gamification import (
calculate_level,
xp_for_next_level,
update_streak,
check_and_award_badges,
get_leaderboard,
seed_badges,
LEVEL_NAMES,
XP_REWARDS,
)
router = APIRouter(prefix="/gamification", tags=["gamification"])
class LevelProgress(BaseModel):
current_xp: int
level: int
level_name: str
xp_for_next: int
xp_progress: int
progress_percent: float
streak_days: int
class LeaderboardEntry(BaseModel):
rank: int
username: str
xp: int
level: int
level_name: str
streak: int
class BadgeResponse(BaseModel):
id: int
name: str
description: str
icon: str
slug: str
tier: str
category: str
awarded_at: datetime | None = None
class XPRewardsInfo(BaseModel):
rewards: dict
level_thresholds: List[int]
level_names: dict
@router.get("/me", response_model=LevelProgress)
async def get_my_progress(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's XP and level progress"""
xp_needed, xp_progress = xp_for_next_level(current_user.xp)
progress_percent = (xp_progress / xp_needed * 100) if xp_needed > 0 else 100
return LevelProgress(
current_xp=current_user.xp,
level=current_user.level,
level_name=LEVEL_NAMES.get(current_user.level, "Unknown"),
xp_for_next=xp_needed,
xp_progress=xp_progress,
progress_percent=round(progress_percent, 1),
streak_days=current_user.streak_days,
)
@router.post("/activity")
async def record_activity(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Record user activity and update streak"""
streak = update_streak(session, current_user)
new_badges = check_and_award_badges(session, current_user)
session.commit()
return {
"streak_days": streak,
"new_badges": [b.name for b in new_badges],
"xp": current_user.xp,
"level": current_user.level,
}
@router.get("/leaderboard", response_model=List[LeaderboardEntry])
async def get_xp_leaderboard(
limit: int = 10,
session: Session = Depends(get_session)
):
"""Get top users by XP"""
leaders = get_leaderboard(session, limit)
return [
LeaderboardEntry(
rank=i + 1,
username=l["email"],
xp=l["xp"],
level=l["level"],
level_name=l["level_name"],
streak=l["streak"],
)
for i, l in enumerate(leaders)
]
@router.get("/badges", response_model=List[BadgeResponse])
async def get_all_badges(session: Session = Depends(get_session)):
"""Get all available badges"""
badges = session.exec(select(Badge).order_by(Badge.tier, Badge.category)).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
)
for b in badges
]
@router.get("/badges/me", response_model=List[BadgeResponse])
async def get_my_badges(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's earned badges"""
user_badges = session.exec(
select(UserBadge, Badge)
.join(Badge)
.where(UserBadge.user_id == current_user.id)
.order_by(UserBadge.awarded_at.desc())
).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
awarded_at=ub.awarded_at,
)
for ub, b in user_badges
]
@router.get("/info", response_model=XPRewardsInfo)
async def get_xp_info():
"""Get XP reward values and level thresholds"""
from services.gamification import LEVEL_THRESHOLDS
return XPRewardsInfo(
rewards=XP_REWARDS,
level_thresholds=LEVEL_THRESHOLDS,
level_names=LEVEL_NAMES,
)
@router.post("/seed-badges")
async def seed_badge_data(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Seed badge definitions (admin only)"""
if current_user.role not in ["admin", "moderator"]:
raise HTTPException(status_code=403, detail="Admin access required")
seed_badges(session)
return {"message": "Badges seeded successfully"}
# ==========================================
# TITLE/FLAIR SHOP ENDPOINTS
# ==========================================
from services.gamification import (
get_available_titles,
get_available_colors,
get_available_flairs,
purchase_title,
purchase_color,
purchase_flair,
get_user_display,
EARLY_ADOPTER_PERKS,
)
class ShopItem(BaseModel):
name: str
type: str
cost: int
level_required: int
is_owned: bool = False
class PurchaseRequest(BaseModel):
item_name: str
@router.get("/shop/titles")
async def get_title_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available titles for purchase"""
available = get_available_titles(current_user)
return {
"current_title": current_user.custom_title,
"current_xp": current_user.xp,
"items": [
{
"name": title,
"type": info["type"],
"cost": info["cost"],
"level_required": info["level_required"],
"is_owned": current_user.custom_title == title,
}
for title, info in available.items()
]
}
@router.post("/shop/titles/purchase")
async def buy_title(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a title with XP"""
success, message = purchase_title(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_title": current_user.custom_title,
"remaining_xp": current_user.xp,
}
@router.get("/shop/colors")
async def get_color_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available username colors for purchase"""
available = get_available_colors(current_user)
return {
"current_color": current_user.title_color,
"current_xp": current_user.xp,
"items": [
{
"name": name,
"hex": info["hex"],
"cost": info["cost"],
"is_owned": current_user.title_color == info["hex"],
}
for name, info in available.items()
]
}
@router.post("/shop/colors/purchase")
async def buy_color(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a username color with XP"""
success, message = purchase_color(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_color": current_user.title_color,
"remaining_xp": current_user.xp,
}
@router.get("/shop/flairs")
async def get_flair_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available flairs for purchase"""
available = get_available_flairs(current_user)
return {
"current_flair": current_user.flair,
"current_xp": current_user.xp,
"items": [
{
"name": flair,
"cost": info["cost"],
"is_owned": current_user.flair == flair,
}
for flair, info in available.items()
]
}
@router.post("/shop/flairs/purchase")
async def buy_flair(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a flair with XP"""
success, message = purchase_flair(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_flair": current_user.flair,
"remaining_xp": current_user.xp,
}
@router.get("/user/{user_id}/display")
async def get_user_display_info(
user_id: int,
session: Session = Depends(get_session)
):
"""Get a user's display info (title, color, flair)"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return get_user_display(user)
@router.get("/early-adopter-perks")
async def get_early_adopter_info():
"""Get information about early adopter perks"""
return {
"perks": EARLY_ADOPTER_PERKS,
"description": "The first 100 users get exclusive perks including unique titles, colors, "
"and a 10% XP bonus on all actions!"
}

View file

@ -22,7 +22,7 @@ def get_top_shows(limit: int = 10, session: Session = Depends(get_session)):
)
.join(Rating, Rating.show_id == Show.id)
.join(Venue, Show.venue_id == Venue.id)
.where(Rating.entity_type == "show")
# Rating FK already filters via join
.group_by(Show.id, Venue.id)
.having(func.count(Rating.id) >= 1)
.order_by(desc("avg_score"), desc("rating_count"))
@ -57,7 +57,7 @@ def get_top_performances(limit: int = 20, session: Session = Depends(get_session
.join(Song, Performance.song_id == Song.id)
.join(Show, Performance.show_id == Show.id)
.join(Venue, Show.venue_id == Venue.id)
.where(Rating.entity_type == "performance")
# Rating FK already filters via join
.group_by(Performance.id, Song.id, Show.id, Venue.id)
.having(func.count(Rating.id) >= 1)
.order_by(desc("avg_score"), desc("rating_count"))
@ -80,19 +80,16 @@ def get_top_performances(limit: int = 20, session: Session = Depends(get_session
@router.get("/venues/top")
def get_top_venues(limit: int = 10, session: Session = Depends(get_session)):
"""Get top rated venues based on show ratings there?"""
# A venue's rating is often derivative of the shows there, or specific venue ratings.
# Let's assume venue rating directly for now if entity_type='venue' exists,
# otherwise we might avg show ratings. Let's start with direct venue ratings.
"""Get top rated venues based on show ratings there"""
# Aggregate ratings from shows played at each venue
query = (
select(
Venue,
func.avg(Rating.score).label("avg_score"),
func.count(Rating.id).label("rating_count")
)
.join(Rating, Rating.venue_id == Venue.id)
.where(Rating.entity_type == "venue")
.join(Show, Show.venue_id == Venue.id)
.join(Rating, Rating.show_id == Show.id)
.group_by(Venue.id)
.having(func.count(Rating.id) >= 1)
.order_by(desc("avg_score"))
@ -101,9 +98,6 @@ def get_top_venues(limit: int = 10, session: Session = Depends(get_session)):
results = session.exec(query).all()
# Fallback: if no direct venue ratings, maybe average the shows played there?
# Keeping it simple for now.
return [
{
"venue": venue,

View file

@ -1,8 +1,10 @@
from typing import List
from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from sqlmodel import Session, select, func
from pydantic import BaseModel
from database import get_session
from models import Report, User, PerformanceNickname
from models import Report, User, PerformanceNickname, Profile, Rating, Review, Comment, Attendance
from schemas import ReportCreate, ReportRead, PerformanceNicknameRead
from auth import get_current_user
from dependencies import RoleChecker
@ -11,6 +13,246 @@ router = APIRouter(prefix="/moderation", tags=["moderation"])
allow_moderator = RoleChecker(["moderator", "admin"])
# ============ SCHEMAS ============
class UserLookupResult(BaseModel):
id: int
email: str
username: Optional[str] = None
role: str
is_active: bool
email_verified: bool
ban_expires: Optional[datetime] = None
stats: dict
class TempBanRequest(BaseModel):
user_id: int
duration_hours: int # 0 = permanent
reason: str
class BulkActionRequest(BaseModel):
ids: List[int]
action: str # approve, reject, resolve, dismiss
class ModActionLog(BaseModel):
id: int
moderator_id: int
moderator_email: str
action_type: str
target_type: str
target_id: int
reason: Optional[str] = None
created_at: datetime
# ============ USER LOOKUP ============
@router.get("/users/lookup")
def lookup_user(
query: str,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Search for a user by email or username"""
# Search by email
user = session.exec(select(User).where(User.email.contains(query))).first()
if not user:
# Try username via profile
profile = session.exec(select(Profile).where(Profile.username.contains(query))).first()
if profile:
user = session.get(User, profile.user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
return {
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
"stats": {
"ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(),
"reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(),
"comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(),
"attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(),
"reports_submitted": session.exec(select(func.count(Report.id)).where(Report.user_id == user.id)).one(),
}
}
@router.get("/users/{user_id}/activity")
def get_user_activity(
user_id: int,
limit: int = 50,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Get recent activity for a user"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get recent comments
comments = session.exec(
select(Comment).where(Comment.user_id == user_id).limit(20)
).all()
# Get recent reviews
reviews = session.exec(
select(Review).where(Review.user_id == user_id).limit(20)
).all()
# Format activity
activity = []
for c in comments:
activity.append({
"type": "comment",
"id": c.id,
"content": c.content[:100] if c.content else "",
"entity_type": c.entity_type,
"entity_id": c.entity_id,
})
for r in reviews:
activity.append({
"type": "review",
"id": r.id,
"content": r.content[:100] if r.content else "",
"show_id": r.show_id,
})
return activity[:limit]
# ============ TEMP BANS ============
@router.post("/users/ban")
def ban_user(
request: TempBanRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Ban a user (temporarily or permanently)"""
user = session.get(User, request.user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Don't allow banning admins
if user.role == "admin":
raise HTTPException(status_code=400, detail="Cannot ban an admin")
# Don't allow mods to ban other mods (only admins can)
if user.role == "moderator" and mod.role != "admin":
raise HTTPException(status_code=400, detail="Only admins can ban moderators")
user.is_active = False
session.add(user)
session.commit()
return {
"message": "User banned",
"user_id": user.id,
"duration_hours": request.duration_hours,
"reason": request.reason
}
@router.post("/users/{user_id}/unban")
def unban_user(
user_id: int,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Unban a user"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = True
session.add(user)
session.commit()
return {"message": "User unbanned", "user_id": user.id}
# ============ BULK ACTIONS ============
@router.post("/nicknames/bulk")
def bulk_moderate_nicknames(
request: BulkActionRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Bulk approve or reject nicknames"""
if request.action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="Invalid action")
status = "approved" if request.action == "approve" else "rejected"
count = 0
for nickname_id in request.ids:
nickname = session.get(PerformanceNickname, nickname_id)
if nickname and nickname.status == "pending":
nickname.status = status
session.add(nickname)
count += 1
session.commit()
return {"message": f"{count} nicknames {status}", "count": count}
@router.post("/reports/bulk")
def bulk_moderate_reports(
request: BulkActionRequest,
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Bulk resolve or dismiss reports"""
if request.action not in ("resolve", "dismiss"):
raise HTTPException(status_code=400, detail="Invalid action")
status = "resolved" if request.action == "resolve" else "dismissed"
count = 0
for report_id in request.ids:
report = session.get(Report, report_id)
if report and report.status == "pending":
report.status = status
session.add(report)
count += 1
session.commit()
return {"message": f"{count} reports {status}", "count": count}
# ============ QUEUE STATS ============
@router.get("/queue/stats")
def get_queue_stats(
session: Session = Depends(get_session),
mod: User = Depends(allow_moderator)
):
"""Get moderation queue statistics"""
return {
"pending_nicknames": session.exec(
select(func.count(PerformanceNickname.id)).where(PerformanceNickname.status == "pending")
).one(),
"pending_reports": session.exec(
select(func.count(Report.id)).where(Report.status == "pending")
).one(),
"total_bans": session.exec(
select(func.count(User.id)).where(User.is_active == False)
).one(),
}
# ============ EXISTING ENDPOINTS ============
@router.post("/reports", response_model=ReportRead)
def create_report(
report: ReportCreate,
@ -81,3 +323,4 @@ def moderate_report(
session.commit()
session.refresh(report)
return report

View file

@ -1,36 +1,44 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from sqlmodel import Session, select, func
from database import get_session
from models import Performance, PerformanceNickname, Tag, EntityTag
from schemas import PerformanceDetailRead, PerformanceNicknameCreate, PerformanceNicknameRead
from models import Performance, PerformanceNickname, Tag, EntityTag, Show, Venue, Rating
from schemas import PerformanceDetailRead, PerformanceNicknameCreate, PerformanceNicknameRead, PerformanceReadWithShow
from auth import get_current_user
router = APIRouter(prefix="/performances", tags=["performances"])
@router.get("/{performance_id}", response_model=PerformanceDetailRead)
def read_performance(performance_id: int, session: Session = Depends(get_session)):
performance = session.get(Performance, performance_id)
@router.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead)
def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)):
performance = None
if performance_id_or_slug.isdigit():
performance = session.get(Performance, int(performance_id_or_slug))
if not performance:
# Try slug lookup
performance = session.exec(
select(Performance).where(Performance.slug == performance_id_or_slug)
).first()
if not performance:
raise HTTPException(status_code=404, detail="Performance not found")
# --- Calculate Stats & Navigation ---
from sqlmodel import select, func, desc
from models import Show
performance_id = performance.id # Use actual ID for lookups
# --- Calculate Stats & Navigation ---
# Get all performances of this song, ordered by date
# We need to join Show to order by date
all_perfs = session.exec(
select(Performance, Show.date)
.join(Show)
# Join Show and Venue for list display
all_perfs_data = session.exec(
select(Performance, Show, Venue)
.join(Show, Performance.show_id == Show.id)
.outerjoin(Venue, Show.venue_id == Venue.id)
.where(Performance.song_id == performance.song_id)
.order_by(Show.date)
).all()
# Find current index
# all_perfs is a list of tuples (Performance, date)
current_index = -1
for i, (p, d) in enumerate(all_perfs):
for i, (p, s, v) in enumerate(all_perfs_data):
if p.id == performance_id:
current_index = i
break
@ -41,12 +49,11 @@ def read_performance(performance_id: int, session: Session = Depends(get_session
times_played = current_index + 1 # 1-based count
if current_index > 0:
prev_id = all_perfs[current_index - 1][0].id
prev_id = all_perfs_data[current_index - 1][0].id
# Calculate Gap
# Gap is number of shows between prev performance and this one
prev_date = all_perfs[current_index - 1][1]
current_date = all_perfs[current_index][1]
prev_date = all_perfs_data[current_index - 1][1].date
current_date = all_perfs_data[current_index][1].date
gap = session.exec(
select(func.count(Show.id))
@ -54,8 +61,43 @@ def read_performance(performance_id: int, session: Session = Depends(get_session
.where(Show.date < current_date)
).one()
if current_index < len(all_perfs) - 1:
next_id = all_perfs[current_index + 1][0].id
if current_index < len(all_perfs_data) - 1:
next_id = all_perfs_data[current_index + 1][0].id
# Fetch ratings for all performances of this song
rating_stats = session.exec(
select(Rating.performance_id, func.avg(Rating.score), func.count(Rating.id))
.where(Rating.song_id == performance.song_id)
.where(Rating.performance_id.is_not(None))
.group_by(Rating.performance_id)
).all()
rating_map = {row[0]: {"avg": row[1], "count": row[2]} for row in rating_stats}
# Build other_performances list
other_performances = []
for p, s, v in all_perfs_data:
if p.id == performance_id:
continue
stats = rating_map.get(p.id, {"avg": 0.0, "count": 0})
perf_read = PerformanceReadWithShow(
**p.model_dump(),
song=performance.song, # Reuse loaded song object
show_date=s.date,
show_slug=s.slug,
venue_name=v.name if v else "Unknown Venue",
venue_city=v.city if v else "Unknown City",
venue_state=v.state if v else None,
avg_rating=stats["avg"],
total_reviews=stats["count"],
nicknames=p.nicknames
)
other_performances.append(perf_read)
# Sort by rating desc, then date desc
other_performances.sort(key=lambda x: (x.avg_rating or 0, x.show_date), reverse=True)
# Construct response manually to include extra fields
# We need to ensure nested models (show, song) are validated correctly
@ -67,6 +109,7 @@ def read_performance(performance_id: int, session: Session = Depends(get_session
perf_dict['next_performance_id'] = next_id
perf_dict['gap'] = gap
perf_dict['times_played'] = times_played
perf_dict['other_performances'] = other_performances
return perf_dict

View file

@ -1,10 +1,11 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from sqlmodel import Session, select, func
from database import get_session
from models import Review, User
from schemas import ReviewCreate, ReviewRead
from auth import get_current_user
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/reviews", tags=["reviews"])
@ -17,6 +18,21 @@ def create_review(
db_review = Review.model_validate(review)
db_review.user_id = current_user.id
session.add(db_review)
# Check if this is user's first review for bonus XP
review_count = session.exec(
select(func.count(Review.id)).where(Review.user_id == current_user.id)
).one() or 0
# Award XP
xp_amount = XP_REWARDS["review_write"]
if review_count == 0:
xp_amount += XP_REWARDS["first_review"] # Bonus for first review
award_xp(session, current_user, xp_amount, "review")
update_streak(session, current_user)
check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_review)
return db_review

View file

@ -49,8 +49,12 @@ def read_recent_shows(
return shows
@router.get("/{show_id}", response_model=ShowRead)
def read_show(show_id: int, session: Session = Depends(get_session)):
show = session.get(Show, show_id)
def read_show(show_id: str, session: Session = Depends(get_session)):
if show_id.isdigit():
show = session.get(Show, int(show_id))
else:
show = session.exec(select(Show).where(Show.slug == show_id)).first()
if not show:
raise HTTPException(status_code=404, detail="Show not found")
@ -58,7 +62,7 @@ def read_show(show_id: int, session: Session = Depends(get_session)):
select(Tag)
.join(EntityTag, Tag.id == EntityTag.tag_id)
.where(EntityTag.entity_type == "show")
.where(EntityTag.entity_id == show_id)
.where(EntityTag.entity_id == show.id)
).all()
# Manually populate performances to ensure nicknames are filtered if needed

View file

@ -7,6 +7,7 @@ from models import Comment, Rating, User, Profile, Reaction
from schemas import CommentCreate, CommentRead, RatingCreate, RatingRead, ReactionCreate, ReactionRead
from auth import get_current_user
from helpers import create_notification
from services.gamification import award_xp, check_and_award_badges, update_streak, XP_REWARDS
router = APIRouter(prefix="/social", tags=["social"])
@ -93,12 +94,16 @@ def create_rating(
query = query.where(Rating.song_id == rating.song_id)
elif rating.performance_id:
query = query.where(Rating.performance_id == rating.performance_id)
elif rating.venue_id:
query = query.where(Rating.venue_id == rating.venue_id)
elif rating.tour_id:
query = query.where(Rating.tour_id == rating.tour_id)
else:
raise HTTPException(status_code=400, detail="Must rate a show, song, or performance")
raise HTTPException(status_code=400, detail="Must rate a show, song, performance, venue, or tour")
existing_rating = session.exec(query).first()
if existing_rating:
# Update existing
# Update existing (no XP for updating)
existing_rating.score = rating.score
session.add(existing_rating)
session.commit()
@ -108,6 +113,21 @@ def create_rating(
db_rating = Rating.model_validate(rating)
db_rating.user_id = current_user.id
session.add(db_rating)
# Award XP for new rating
# Check if first rating for bonus
rating_count = session.exec(
select(func.count(Rating.id)).where(Rating.user_id == current_user.id)
).one() or 0
xp_amount = XP_REWARDS["rating_submit"]
if rating_count == 0:
xp_amount += XP_REWARDS["first_rating"] # Bonus for first rating
award_xp(session, current_user, xp_amount, "rating")
update_streak(session, current_user)
check_and_award_badges(session, current_user)
session.commit()
session.refresh(db_rating)
return db_rating
@ -117,6 +137,8 @@ def get_average_rating(
show_id: Optional[int] = None,
song_id: Optional[int] = None,
performance_id: Optional[int] = None,
venue_id: Optional[int] = None,
tour_id: Optional[int] = None,
session: Session = Depends(get_session)
):
query = select(func.avg(Rating.score))
@ -126,8 +148,13 @@ def get_average_rating(
query = query.where(Rating.song_id == song_id)
elif performance_id:
query = query.where(Rating.performance_id == performance_id)
elif venue_id:
query = query.where(Rating.venue_id == venue_id)
elif tour_id:
query = query.where(Rating.tour_id == tour_id)
else:
raise HTTPException(status_code=400, detail="Must specify show_id, song_id, or performance_id")
# Return 0 if no entity specified instead of error (graceful degradation)
return 0.0
avg = session.exec(query).first()
return float(avg) if avg else 0.0

View file

@ -1,11 +1,12 @@
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from sqlmodel import Session, select, func
from database import get_session
from models import Song, User, Tag, EntityTag
from schemas import SongCreate, SongRead, SongReadWithStats, SongUpdate, TagRead
from models import Song, User, Tag, EntityTag, Show, Performance, Rating
from schemas import SongCreate, SongRead, SongReadWithStats, SongUpdate, TagRead, PerformanceReadWithShow
from auth import get_current_user
from services.stats import get_song_stats
router = APIRouter(prefix="/songs", tags=["songs"])
@ -22,14 +23,21 @@ def read_songs(offset: int = 0, limit: int = Query(default=100, le=100), session
songs = session.exec(select(Song).offset(offset).limit(limit)).all()
return songs
from services.stats import get_song_stats
@router.get("/{song_id_or_slug}", response_model=SongReadWithStats)
def read_song(song_id_or_slug: str, session: Session = Depends(get_session)):
# Try to parse as int (ID), otherwise treat as slug
song = None
if song_id_or_slug.isdigit():
song = session.get(Song, int(song_id_or_slug))
if not song:
# Try slug lookup
song = session.exec(select(Song).where(Song.slug == song_id_or_slug)).first()
@router.get("/{song_id}", response_model=SongReadWithStats)
def read_song(song_id: int, session: Session = Depends(get_session)):
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
song_id = song.id # Use actual ID for lookups
stats = get_song_stats(session, song_id)
tags = session.exec(
@ -40,12 +48,6 @@ def read_song(song_id: int, session: Session = Depends(get_session)):
).all()
# Fetch performances
# We join Show to ensure we can order by date
from models import Show, Performance, Rating
from sqlmodel import func
# We need PerformanceReadWithShow from schemas
from schemas import PerformanceReadWithShow
perfs = session.exec(
select(Performance)
.join(Show)
@ -72,9 +74,11 @@ def read_song(song_id: int, session: Session = Depends(get_session)):
venue_city = ""
venue_state = ""
show_date = datetime.now()
show_slug = None
if p.show:
show_date = p.show.date
show_slug = p.show.slug
if p.show.venue:
venue_name = p.show.venue.name
venue_city = p.show.venue.city
@ -85,6 +89,7 @@ def read_song(song_id: int, session: Session = Depends(get_session)):
perf_dtos.append(PerformanceReadWithShow(
**p.model_dump(),
show_date=show_date,
show_slug=show_slug,
venue_name=venue_name,
venue_city=venue_city,
venue_state=venue_state,

View file

@ -21,9 +21,15 @@ def read_venues(offset: int = 0, limit: int = Query(default=100, le=100), sessio
venues = session.exec(select(Venue).offset(offset).limit(limit)).all()
return venues
@router.get("/{venue_id}", response_model=VenueRead)
def read_venue(venue_id: int, session: Session = Depends(get_session)):
venue = session.get(Venue, venue_id)
@router.get("/{venue_id_or_slug}", response_model=VenueRead)
def read_venue(venue_id_or_slug: str, session: Session = Depends(get_session)):
venue = None
if venue_id_or_slug.isdigit():
venue = session.get(Venue, int(venue_id_or_slug))
if not venue:
venue = session.exec(select(Venue).where(Venue.slug == venue_id_or_slug)).first()
if not venue:
raise HTTPException(status_code=404, detail="Venue not found")
return venue

124
backend/routers/videos.py Normal file
View file

@ -0,0 +1,124 @@
"""
Videos endpoint - list all performances and shows with YouTube links
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from database import get_session
from models import Show, Performance, Song, Venue
router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("/")
def get_all_videos(
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
session: Session = Depends(get_session)
):
"""Get all performances and shows with YouTube links."""
# Get performances with videos
perf_query = (
select(
Performance.id,
Performance.youtube_link,
Performance.show_id,
Song.id.label("song_id"),
Song.title.label("song_title"),
Song.slug.label("song_slug"),
Show.date,
Show.slug.label("show_slug"),
Venue.name.label("venue_name"),
Venue.city.label("venue_city"),
Venue.state.label("venue_state")
)
.join(Song, Performance.song_id == Song.id)
.join(Show, Performance.show_id == Show.id)
.join(Venue, Show.venue_id == Venue.id)
.where(Performance.youtube_link != None)
.order_by(Show.date.desc())
.limit(limit)
.offset(offset)
)
perf_results = session.exec(perf_query).all()
performances = [
{
"type": "performance",
"id": r[0],
"youtube_link": r[1],
"show_id": r[2],
"song_id": r[3],
"song_title": r[4],
"song_slug": r[5],
"date": r[6].isoformat() if r[6] else None,
"show_slug": r[7],
"venue_name": r[8],
"venue_city": r[9],
"venue_state": r[10]
}
for r in perf_results
]
# Get shows with videos
show_query = (
select(
Show.id,
Show.youtube_link,
Show.date,
Show.slug,
Venue.name.label("venue_name"),
Venue.city.label("venue_city"),
Venue.state.label("venue_state")
)
.join(Venue, Show.venue_id == Venue.id)
.where(Show.youtube_link != None)
.order_by(Show.date.desc())
.limit(limit)
.offset(offset)
)
show_results = session.exec(show_query).all()
shows = [
{
"type": "full_show",
"id": r[0],
"youtube_link": r[1],
"date": r[2].isoformat() if r[2] else None,
"show_slug": r[3],
"venue_name": r[4],
"venue_city": r[5],
"venue_state": r[6]
}
for r in show_results
]
return {
"performances": performances,
"shows": shows,
"total_performances": len(performances),
"total_shows": len(shows)
}
@router.get("/stats")
def get_video_stats(session: Session = Depends(get_session)):
"""Get counts of videos in the database."""
from sqlmodel import func
perf_count = session.exec(
select(func.count(Performance.id)).where(Performance.youtube_link != None)
).one()
show_count = session.exec(
select(func.count(Show.id)).where(Show.youtube_link != None)
).one()
return {
"performance_videos": perf_count,
"full_show_videos": show_count,
"total": perf_count + show_count
}

View file

@ -34,6 +34,7 @@ class VenueCreate(VenueBase):
class VenueRead(VenueBase):
id: int
slug: Optional[str] = None
class VenueUpdate(SQLModel):
name: Optional[str] = None
@ -55,6 +56,7 @@ class SongCreate(SongBase):
class SongRead(SongBase):
id: int
slug: Optional[str] = None
tags: List["TagRead"] = []
@ -86,11 +88,14 @@ class PerformanceBase(SQLModel):
class PerformanceRead(PerformanceBase):
id: int
slug: Optional[str] = None
song: Optional["SongRead"] = None
nicknames: List["PerformanceNicknameRead"] = []
youtube_link: Optional[str] = None
class PerformanceReadWithShow(PerformanceRead):
show_date: datetime
show_slug: Optional[str] = None
venue_name: str
venue_city: str
venue_state: Optional[str] = None
@ -109,6 +114,7 @@ class PerformanceDetailRead(PerformanceRead):
next_performance_id: Optional[int] = None
gap: Optional[int] = 0
times_played: Optional[int] = 0
other_performances: List[PerformanceReadWithShow] = []
# --- Groups ---
class GroupBase(SQLModel):
@ -141,10 +147,12 @@ class GroupPostRead(GroupPostBase):
class ShowRead(ShowBase):
id: int
slug: Optional[str] = None
venue: Optional["VenueRead"] = None
tour: Optional["TourRead"] = None
tags: List["TagRead"] = []
performances: List["PerformanceRead"] = []
youtube_link: Optional[str] = None
class ShowUpdate(SQLModel):
date: Optional[datetime] = None
@ -164,6 +172,7 @@ class TourCreate(TourBase):
class TourRead(TourBase):
id: int
slug: Optional[str] = None
class TourUpdate(SQLModel):
name: Optional[str] = None
@ -219,10 +228,12 @@ class CommentRead(CommentBase):
# We might want to include the username here later
class RatingBase(SQLModel):
score: int
score: float
show_id: Optional[int] = None
song_id: Optional[int] = None
performance_id: Optional[int] = None
venue_id: Optional[int] = None
tour_id: Optional[int] = None
class RatingCreate(RatingBase):
pass
@ -235,7 +246,7 @@ class RatingRead(RatingBase):
class ReviewBase(SQLModel):
blurb: str
content: str
score: int
score: float
show_id: Optional[int] = None
venue_id: Optional[int] = None
song_id: Optional[int] = None
@ -342,6 +353,7 @@ class TagCreate(TagBase):
class TagRead(TagBase):
id: int
slug: str
# Circular refs

64
backend/seed_ratings.py Normal file
View file

@ -0,0 +1,64 @@
"""
Seed script to create demo ratings for shows and performances.
Run with: python seed_ratings.py
"""
from sqlmodel import Session, create_engine, select
from database import DATABASE_URL
from models import Rating, Show, Performance, User
import random
def seed_ratings():
engine = create_engine(DATABASE_URL)
with Session(engine) as session:
# Get first user (or admin) to attribute ratings to
user = session.exec(select(User).limit(1)).first()
if not user:
print("No users found. Please create a user first.")
return
# Get some shows
shows = session.exec(select(Show).limit(20)).all()
# Get some performances
performances = session.exec(select(Performance).limit(50)).all()
created_count = 0
# Seed show ratings (7-10 range for positive feel)
for show in shows:
# Check if rating already exists
existing = session.exec(
select(Rating).where(Rating.show_id == show.id, Rating.user_id == user.id)
).first()
if existing:
continue
rating = Rating(
user_id=user.id,
show_id=show.id,
score=random.randint(7, 10)
)
session.add(rating)
created_count += 1
# Seed performance ratings
for perf in performances:
existing = session.exec(
select(Rating).where(Rating.performance_id == perf.id, Rating.user_id == user.id)
).first()
if existing:
continue
rating = Rating(
user_id=user.id,
performance_id=perf.id,
score=random.randint(6, 10)
)
session.add(rating)
created_count += 1
session.commit()
print(f"Created {created_count} demo ratings!")
if __name__ == "__main__":
seed_ratings()

View file

@ -0,0 +1,151 @@
import os
import boto3
import secrets
from datetime import datetime, timedelta
from botocore.exceptions import ClientError
from typing import Optional
class EmailService:
def __init__(self):
self.region_name = os.getenv("AWS_SES_REGION", "us-east-1")
self.aws_access_key_id = os.getenv("AWS_ACCESS_KEY_ID")
self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY")
self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
# Initialize SES client if credentials exist
if self.aws_access_key_id and self.aws_secret_access_key:
self.client = boto3.client(
"ses",
region_name=self.region_name,
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
)
else:
self.client = None
print("WARNING: AWS credentials not found. Email service running in dummy mode.")
def send_email(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send an email using AWS SES"""
if not self.client:
print(f"DUMMY EMAIL to {to_email}: {subject}")
print(text_content)
return True
try:
response = self.client.send_email(
Source=self.email_from,
Destination={
"ToAddresses": [to_email],
},
Message={
"Subject": {
"Data": subject,
"Charset": "UTF-8",
},
"Body": {
"Html": {
"Data": html_content,
"Charset": "UTF-8",
},
"Text": {
"Data": text_content,
"Charset": "UTF-8",
},
},
},
)
return response
except ClientError as e:
print(f"Error sending email: {e.response['Error']['Message']}")
return False
# Global instance
email_service = EmailService()
# --- Helper Functions (used by auth router) ---
def generate_token() -> str:
"""Generate a secure random token"""
return secrets.token_urlsafe(32)
def get_verification_expiry() -> datetime:
"""Get expiration time for verification token (48 hours)"""
return datetime.utcnow() + timedelta(hours=48)
def get_reset_expiry() -> datetime:
"""Get expiration time for reset token (1 hour)"""
return datetime.utcnow() + timedelta(hours=1)
def send_verification_email(to_email: str, token: str):
"""Send account verification email"""
verify_url = f"{email_service.frontend_url}/verify-email?token={token}"
subject = "Verify your Elmeg account"
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Welcome to Elmeg!</h2>
<p>Thanks for signing up. Please verify your email address to get started.</p>
<div style="margin: 30px 0;">
<a href="{verify_url}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Verify Email Address</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
<p style="font-size: 12px; color: #666;">{verify_url}</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #999;">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
"""
text_content = f"""
Welcome to Elmeg!
Please verify your email address by visiting this link:
{verify_url}
If you didn't create an account, safely ignore this email.
"""
return email_service.send_email(to_email, subject, html_content, text_content)
def send_password_reset_email(to_email: str, token: str):
"""Send password reset email"""
reset_url = f"{email_service.frontend_url}/reset-password?token={token}"
subject = "Reset your Elmeg password"
html_content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Password Reset Request</h2>
<p>We received a request to reset your password. Click the button below to choose a new one.</p>
<div style="margin: 30px 0;">
<a href="{reset_url}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reset Password</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
<p style="font-size: 12px; color: #666;">{reset_url}</p>
<p style="font-size: 14px; color: #666;">This link expires in 1 hour.</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #999;">If you didn't request a password reset, you can safely ignore this email.</p>
</div>
</body>
</html>
"""
text_content = f"""
Reset your Elmeg password
Click the link below to choose a new password:
{reset_url}
This link expires in 1 hour.
If you didn't request a password reset, safely ignore this email.
"""
return email_service.send_email(to_email, subject, html_content, text_content)

View file

@ -0,0 +1,470 @@
"""
Gamification Service - XP, Levels, Badges, and Streaks
"""
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlmodel import Session, select, func
from models import User, Badge, UserBadge, Attendance, Rating, Review, Comment
# XP rewards for different actions
XP_REWARDS = {
"attendance_add": 25, # Mark a show as attended
"rating_submit": 10, # Submit a rating
"review_write": 50, # Write a review
"comment_post": 5, # Post a comment
"first_rating": 25, # First ever rating (bonus)
"first_review": 50, # First ever review (bonus)
"streak_bonus": 10, # Per day of streak
"daily_login": 5, # Daily activity bonus
}
# Level thresholds (XP required for each level)
LEVEL_THRESHOLDS = [
0, # Level 1
100, # Level 2
250, # Level 3
500, # Level 4
1000, # Level 5
2000, # Level 6
3500, # Level 7
5500, # Level 8
8000, # Level 9
12000, # Level 10
18000, # Level 11
26000, # Level 12
36000, # Level 13
50000, # Level 14
70000, # Level 15
]
LEVEL_NAMES = {
1: "Rookie",
2: "Fan",
3: "Enthusiast",
4: "Regular",
5: "Dedicated",
6: "Veteran",
7: "Expert",
8: "Master",
9: "Elite",
10: "Legend",
11: "Icon",
12: "Mythic",
13: "Transcendent",
14: "Eternal",
15: "Immortal",
}
def calculate_level(xp: int) -> int:
"""Calculate level based on XP"""
for level, threshold in enumerate(LEVEL_THRESHOLDS):
if xp < threshold:
return level # Previous level
return len(LEVEL_THRESHOLDS) # Max level
def xp_for_next_level(current_xp: int) -> Tuple[int, int]:
"""Returns (XP needed for next level, XP progress toward next level)"""
current_level = calculate_level(current_xp)
if current_level >= len(LEVEL_THRESHOLDS):
return 0, 0 # Max level
next_threshold = LEVEL_THRESHOLDS[current_level]
prev_threshold = LEVEL_THRESHOLDS[current_level - 1] if current_level > 0 else 0
progress = current_xp - prev_threshold
needed = next_threshold - prev_threshold
return needed, progress
def award_xp(session: Session, user: User, amount: int, reason: str) -> Tuple[int, bool]:
"""
Award XP to a user and check for level up.
Returns (new_total_xp, did_level_up)
"""
old_level = user.level
user.xp += amount
new_level = calculate_level(user.xp)
level_up = new_level > old_level
if level_up:
user.level = new_level
session.add(user)
return user.xp, level_up
def update_streak(session: Session, user: User) -> int:
"""Update user's activity streak. Returns current streak."""
now = datetime.utcnow()
if user.last_activity:
days_since = (now.date() - user.last_activity.date()).days
if days_since == 0:
# Same day, no streak change
pass
elif days_since == 1:
# Next day, increment streak
user.streak_days += 1
# Award streak bonus
award_xp(session, user, XP_REWARDS["streak_bonus"] * min(user.streak_days, 7), "streak_bonus")
else:
# Streak broken
user.streak_days = 1
else:
user.streak_days = 1
user.last_activity = now
session.add(user)
return user.streak_days
# Badge definitions for seeding
BADGE_DEFINITIONS = [
# Attendance badges
{"name": "First Show", "slug": "first-show", "description": "Marked your first show as attended", "icon": "ticket", "tier": "bronze", "category": "attendance", "xp_reward": 50},
{"name": "Regular", "slug": "regular-10", "description": "Attended 10 shows", "icon": "calendar", "tier": "bronze", "category": "attendance", "xp_reward": 100},
{"name": "Veteran", "slug": "veteran-50", "description": "Attended 50 shows", "icon": "award", "tier": "silver", "category": "attendance", "xp_reward": 250},
{"name": "Lifer", "slug": "lifer-100", "description": "Attended 100 shows", "icon": "crown", "tier": "gold", "category": "attendance", "xp_reward": 500},
{"name": "Legend", "slug": "legend-250", "description": "Attended 250 shows", "icon": "star", "tier": "platinum", "category": "attendance", "xp_reward": 1000},
# Rating badges
{"name": "First Rating", "slug": "first-rating", "description": "Submitted your first rating", "icon": "star", "tier": "bronze", "category": "ratings", "xp_reward": 25},
{"name": "Critic", "slug": "critic-50", "description": "Submitted 50 ratings", "icon": "thumbs-up", "tier": "silver", "category": "ratings", "xp_reward": 150},
{"name": "Connoisseur", "slug": "connoisseur-200", "description": "Submitted 200 ratings", "icon": "wine", "tier": "gold", "category": "ratings", "xp_reward": 400},
# Review badges
{"name": "Wordsmith", "slug": "first-review", "description": "Wrote your first review", "icon": "pen", "tier": "bronze", "category": "social", "xp_reward": 50},
{"name": "Columnist", "slug": "columnist-10", "description": "Wrote 10 reviews", "icon": "file-text", "tier": "silver", "category": "social", "xp_reward": 200},
{"name": "Essayist", "slug": "essayist-50", "description": "Wrote 50 reviews", "icon": "book-open", "tier": "gold", "category": "social", "xp_reward": 500},
# Streak badges
{"name": "Consistent", "slug": "streak-7", "description": "7-day activity streak", "icon": "flame", "tier": "bronze", "category": "milestones", "xp_reward": 75},
{"name": "Dedicated", "slug": "streak-30", "description": "30-day activity streak", "icon": "zap", "tier": "silver", "category": "milestones", "xp_reward": 300},
{"name": "Unstoppable", "slug": "streak-100", "description": "100-day activity streak", "icon": "rocket", "tier": "gold", "category": "milestones", "xp_reward": 750},
# Special badges
{"name": "Debut Hunter", "slug": "debut-witness", "description": "Was in attendance for a song debut", "icon": "sparkles", "tier": "gold", "category": "milestones", "xp_reward": 200},
{"name": "Heady Spotter", "slug": "heady-witness", "description": "Attended a top-rated performance", "icon": "trophy", "tier": "silver", "category": "milestones", "xp_reward": 150},
{"name": "Song Chaser", "slug": "chase-caught-5", "description": "Caught 5 chase songs", "icon": "target", "tier": "silver", "category": "milestones", "xp_reward": 200},
]
def check_and_award_badges(session: Session, user: User) -> List[Badge]:
"""
Check all badge criteria and award any earned badges.
Returns list of newly awarded badges.
"""
awarded = []
# Get user's existing badge slugs
existing = session.exec(
select(Badge.slug)
.join(UserBadge)
.where(UserBadge.user_id == user.id)
).all()
existing_slugs = set(existing)
# Count attendance
attendance_count = session.exec(
select(func.count(Attendance.id))
.where(Attendance.user_id == user.id)
).one() or 0
# Count ratings
rating_count = session.exec(
select(func.count(Rating.id))
.where(Rating.user_id == user.id)
).one() or 0
# Count reviews
review_count = session.exec(
select(func.count(Review.id))
.where(Review.user_id == user.id)
).one() or 0
badges_to_check = [
("first-show", attendance_count >= 1),
("regular-10", attendance_count >= 10),
("veteran-50", attendance_count >= 50),
("lifer-100", attendance_count >= 100),
("legend-250", attendance_count >= 250),
("first-rating", rating_count >= 1),
("critic-50", rating_count >= 50),
("connoisseur-200", rating_count >= 200),
("first-review", review_count >= 1),
("columnist-10", review_count >= 10),
("essayist-50", review_count >= 50),
("streak-7", user.streak_days >= 7),
("streak-30", user.streak_days >= 30),
("streak-100", user.streak_days >= 100),
]
for slug, condition in badges_to_check:
if condition and slug not in existing_slugs:
badge = session.exec(select(Badge).where(Badge.slug == slug)).first()
if badge:
user_badge = UserBadge(user_id=user.id, badge_id=badge.id)
session.add(user_badge)
award_xp(session, user, badge.xp_reward, f"badge_{slug}")
awarded.append(badge)
existing_slugs.add(slug)
if awarded:
session.commit()
return awarded
def get_leaderboard(session: Session, limit: int = 10) -> List[dict]:
"""Get top users by XP"""
users = session.exec(
select(User)
.where(User.is_active == True)
.order_by(User.xp.desc())
.limit(limit)
).all()
return [
{
"id": u.id,
"email": u.email.split("@")[0], # Just username part
"xp": u.xp,
"level": u.level,
"level_name": LEVEL_NAMES.get(u.level, "Unknown"),
"streak": u.streak_days,
}
for u in users
]
def seed_badges(session: Session):
"""Seed all badge definitions into the database"""
for badge_def in BADGE_DEFINITIONS:
existing = session.exec(
select(Badge).where(Badge.slug == badge_def["slug"])
).first()
if not existing:
badge = Badge(**badge_def)
session.add(badge)
session.commit()
print(f"Seeded {len(BADGE_DEFINITIONS)} badge definitions")
# ==========================================
# USER TITLE & FLAIR SYSTEM (Tracker-style)
# ==========================================
# Titles that can be unlocked at certain levels
LEVEL_TITLES = {
1: ["Rookie", "Newbie", "Fresh Ears"],
3: ["Fan", "Listener", "Devotee"],
5: ["Regular", "Familiar Face", "Couch Tour Pro"],
7: ["Veteran", "Road Warrior", "Tour Rat"],
10: ["Legend", "OG", "Scene Elder"],
12: ["Mythic", "Phenom", "Enlightened"],
15: ["Immortal", "Transcendent One", "Ascended"],
}
# Titles that can be purchased with XP
PURCHASABLE_TITLES = {
"Jam Connoisseur": {"cost": 500, "min_level": 3},
"Setlist Savant": {"cost": 750, "min_level": 5},
"Show Historian": {"cost": 1000, "min_level": 5},
"Type II Specialist": {"cost": 1500, "min_level": 7},
"Heady Scholar": {"cost": 2000, "min_level": 8},
"Rager": {"cost": 500, "min_level": 3},
"Rail Rider": {"cost": 750, "min_level": 4},
"Taper Section Regular": {"cost": 1000, "min_level": 5},
"Lot Lizard": {"cost": 600, "min_level": 4},
"Show Whisperer": {"cost": 2500, "min_level": 10},
}
# Username colors that can be purchased with XP
PURCHASABLE_COLORS = {
"Sage Green": {"hex": "#6B9B6B", "cost": 300, "min_level": 2},
"Ocean Blue": {"hex": "#4A90D9", "cost": 300, "min_level": 2},
"Sunset Orange": {"hex": "#E67E22", "cost": 300, "min_level": 2},
"Royal Purple": {"hex": "#9B59B6", "cost": 500, "min_level": 4},
"Ruby Red": {"hex": "#E74C3C", "cost": 500, "min_level": 4},
"Electric Cyan": {"hex": "#00CED1", "cost": 750, "min_level": 6},
"Gold": {"hex": "#FFD700", "cost": 1000, "min_level": 8},
"Rainbow": {"hex": "gradient", "cost": 2500, "min_level": 10},
}
# Flairs (small text/emoji beside username)
PURCHASABLE_FLAIRS = {
"": {"cost": 100, "min_level": 1},
"🎸": {"cost": 100, "min_level": 1},
"🎵": {"cost": 100, "min_level": 1},
"🌈": {"cost": 200, "min_level": 3},
"🔥": {"cost": 200, "min_level": 3},
"": {"cost": 300, "min_level": 5},
"👑": {"cost": 500, "min_level": 7},
"🚀": {"cost": 400, "min_level": 6},
"💎": {"cost": 750, "min_level": 9},
"🌟": {"cost": 1000, "min_level": 10},
}
# Early adopter perks
EARLY_ADOPTER_PERKS = {
"free_title_change": True, # Early adopters can change title for free (once per month)
"exclusive_titles": ["Pioneer", "Founding Member", "OG User", "Day One"],
"exclusive_colors": {"Pioneer Gold": "#FFB347", "Genesis Green": "#50C878"},
"exclusive_flair": ["🥇", "🏆"],
"title_color": "#FFB347", # Default gold color for early adopters
"bonus_xp_multiplier": 1.1, # 10% XP bonus
}
def get_available_titles(user: User) -> dict:
"""Get all titles available to this user based on level and status"""
available = {}
# Level-based titles
for level, titles in LEVEL_TITLES.items():
if user.level >= level:
for title in titles:
available[title] = {"type": "level", "level_required": level, "cost": 0}
# Purchasable titles
for title, info in PURCHASABLE_TITLES.items():
if user.level >= info["min_level"]:
available[title] = {"type": "purchase", "level_required": info["min_level"], "cost": info["cost"]}
# Early adopter exclusive titles
if user.is_early_adopter:
for title in EARLY_ADOPTER_PERKS["exclusive_titles"]:
available[title] = {"type": "early_adopter", "level_required": 1, "cost": 0}
return available
def get_available_colors(user: User) -> dict:
"""Get all colors available to this user"""
available = {}
for name, info in PURCHASABLE_COLORS.items():
if user.level >= info["min_level"]:
available[name] = {"hex": info["hex"], "cost": info["cost"]}
# Early adopter exclusive colors
if user.is_early_adopter:
for name, hex_color in EARLY_ADOPTER_PERKS["exclusive_colors"].items():
available[name] = {"hex": hex_color, "cost": 0}
return available
def get_available_flairs(user: User) -> dict:
"""Get all flairs available to this user"""
available = {}
for flair, info in PURCHASABLE_FLAIRS.items():
if user.level >= info["min_level"]:
available[flair] = {"cost": info["cost"]}
# Early adopter exclusive flairs
if user.is_early_adopter:
for flair in EARLY_ADOPTER_PERKS["exclusive_flair"]:
available[flair] = {"cost": 0}
return available
def purchase_title(session: Session, user: User, title: str) -> Tuple[bool, str]:
"""Attempt to purchase a title. Returns (success, message)"""
available = get_available_titles(user)
if title not in available:
return False, "Title not available at your level"
info = available[title]
cost = info["cost"]
# Early adopters get free title changes for level/early_adopter titles
if user.is_early_adopter and info["type"] in ["level", "early_adopter"]:
cost = 0
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set title
user.xp -= cost
user.custom_title = title
session.add(user)
session.commit()
return True, f"Title '{title}' acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def purchase_color(session: Session, user: User, color_name: str) -> Tuple[bool, str]:
"""Attempt to purchase a username color"""
available = get_available_colors(user)
if color_name not in available:
return False, "Color not available at your level"
info = available[color_name]
cost = info["cost"]
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set color
user.xp -= cost
user.title_color = info["hex"]
session.add(user)
session.commit()
return True, f"Color '{color_name}' applied!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def purchase_flair(session: Session, user: User, flair: str) -> Tuple[bool, str]:
"""Attempt to purchase a flair"""
available = get_available_flairs(user)
if flair not in available:
return False, "Flair not available at your level"
info = available[flair]
cost = info["cost"]
if user.xp < cost:
return False, f"Not enough XP. Need {cost}, have {user.xp}"
# Deduct XP and set flair
user.xp -= cost
user.flair = flair
session.add(user)
session.commit()
return True, f"Flair {flair} acquired!" + (f" (-{cost} XP)" if cost > 0 else " (Free!)")
def get_user_display(user: User) -> dict:
"""Get the full display info for a user including title, color, flair"""
username = user.email.split("@")[0] if user.email else "Anonymous"
# Determine title to show
display_title = user.custom_title
if not display_title:
display_title = LEVEL_NAMES.get(user.level, "User")
return {
"username": username,
"title": display_title,
"color": user.title_color,
"flair": user.flair,
"level": user.level,
"xp": user.xp,
"is_early_adopter": user.is_early_adopter,
"is_supporter": user.is_supporter,
}

134
backend/slugify.py Normal file
View file

@ -0,0 +1,134 @@
"""
Slug generation utilities
"""
import re
from typing import Optional
def generate_slug(text: str, max_length: int = 50) -> str:
"""
Generate a URL-safe slug from text.
Examples:
"Tweezer Reprise" -> "tweezer-reprise"
"You Enjoy Myself" -> "you-enjoy-myself"
"The Gorge Amphitheatre" -> "the-gorge-amphitheatre"
"""
if not text:
return ""
# Convert to lowercase
slug = text.lower()
# Replace common special characters
replacements = {
"'": "",
"'": "",
'"': "",
"&": "and",
"+": "and",
"@": "at",
"#": "",
"$": "",
"%": "",
"!": "",
"?": "",
".": "",
",": "",
":": "",
";": "",
"/": "-",
"\\": "-",
"(": "",
")": "",
"[": "",
"]": "",
"{": "",
"}": "",
"<": "",
">": "",
"": "-",
"": "-",
"...": "",
}
for old, new in replacements.items():
slug = slug.replace(old, new)
# Replace any non-alphanumeric characters with dashes
slug = re.sub(r'[^a-z0-9]+', '-', slug)
# Remove leading/trailing dashes and collapse multiple dashes
slug = re.sub(r'-+', '-', slug).strip('-')
# Truncate to max length (at word boundary if possible)
if len(slug) > max_length:
slug = slug[:max_length]
# Try to cut at last dash to avoid partial words
last_dash = slug.rfind('-')
if last_dash > max_length // 2:
slug = slug[:last_dash]
return slug
def generate_unique_slug(
base_text: str,
existing_slugs: list[str],
max_length: int = 50
) -> str:
"""
Generate a unique slug, appending numbers if necessary.
Examples:
"Tweezer" with existing ["tweezer"] -> "tweezer-2"
"Tweezer" with existing ["tweezer", "tweezer-2"] -> "tweezer-3"
"""
base_slug = generate_slug(base_text, max_length - 4) # Leave room for "-999"
if base_slug not in existing_slugs:
return base_slug
# Find next available number
counter = 2
while f"{base_slug}-{counter}" in existing_slugs:
counter += 1
return f"{base_slug}-{counter}"
def generate_show_slug(date_str: str, venue_name: str) -> str:
"""
Generate a slug for a show based on date and venue.
Examples:
"2024-12-31", "Madison Square Garden" -> "2024-12-31-msg"
"2024-07-04", "The Gorge Amphitheatre" -> "2024-07-04-the-gorge"
"""
# Common venue abbreviations
abbreviations = {
"madison square garden": "msg",
"red rocks amphitheatre": "red-rocks",
"the gorge amphitheatre": "the-gorge",
"alpine valley music theatre": "alpine",
"dicks sporting goods park": "dicks",
"mgm grand garden arena": "mgm",
"saratoga performing arts center": "spac",
}
venue_slug = abbreviations.get(venue_name.lower())
if not venue_slug:
# Take first 2-3 words of venue name
venue_slug = generate_slug(venue_name, 25)
return f"{date_str}-{venue_slug}"
def generate_performance_slug(song_title: str, show_date: str) -> str:
"""
Generate a slug for a specific performance.
Examples:
"Tweezer", "2024-12-31" -> "tweezer-2024-12-31"
"""
song_slug = generate_slug(song_title, 30)
return f"{song_slug}-{show_date}"

17
backend/start.sh Normal file
View file

@ -0,0 +1,17 @@
#!/bin/bash
set -e
echo "🔄 Running database migrations..."
# Run any migration scripts that exist
for script in /app/migrations/*.py; do
if [ -f "$script" ]; then
echo " Running: $(basename $script)"
python "$script" || echo " ⚠️ Migration $script failed (may already be applied)"
fi
done
echo "✅ Migrations complete. Starting server..."
# Start the main application with production settings
exec uvicorn main:app --host 0.0.0.0 --port 8000 --root-path /api --proxy-headers --forwarded-allow-ips '*'

4962
backend/youtube_videos.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,12 @@ services:
environment:
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
command: uvicorn main:app --host 0.0.0.0 --port 8000 --root-path /api --proxy-headers --forwarded-allow-ips '*'
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_SES_REGION=${AWS_SES_REGION}
- EMAIL_FROM=${EMAIL_FROM}
- FRONTEND_URL=${FRONTEND_URL:-https://elmeg.xyz}
command: sh start.sh
depends_on:
- db
restart: unless-stopped

295
docs/AUDIT_AND_PLAN.md Normal file
View file

@ -0,0 +1,295 @@
# Elmeg Platform Audit & Implementation Plan
>
> **Date**: December 22, 2024
---
## Executive Summary
This audit examines the Elmeg platform against spec'd features, user stories, and interaction gaps. The platform has strong core functionality but has several incomplete areas that impact user experience.
---
## 🔴 Critical Gaps (High Priority)
### 1. Email System Not Functional
**Status**: Backend model ready, email sending not implemented
**Impact**: Users cannot:
- Verify their email addresses
- Reset passwords
- Receive notification emails
**User Stories Affected**:
- ❌ "As a new user, I want to receive a verification email"
- ❌ "As a user, I want to reset my password if I forgot it"
**Fix Required**:
- Implement `backend/services/email_service.py`
- Integrate with AWS SES (docs exist at `AWS_SES_SETUP.md`)
- Connect auth endpoints to email service
---
### 2. XP Not Actually Awarded
**Status**: Models and endpoints exist, but XP isn't awarded on actions
**Impact**: Gamification system is purely cosmetic - actions don't increase XP
**User Stories Affected**:
- ❌ "As a user, I want to earn XP when I rate a performance"
- ❌ "As a user, I want to earn XP when I mark attendance"
**Fix Required**:
- Hook `award_xp()` into attendance, rating, review endpoints
- Call `check_and_award_badges()` after XP-earning actions
---
### 3. Frontend Not Using Slug URLs
**Status**: API supports slugs, frontend still uses numeric IDs
**Impact**: URLs are non-memorable (e.g., `/songs/69` instead of `/songs/tweezer`)
**Fix Required**:
- Update all `<Link>` components to use slug
- Add slug to API response schemas
- Update frontend routing to accept slug params
---
## 🟡 Important Gaps (Medium Priority)
### 4. Onboarding Flow Incomplete
**Status**: `/welcome` page exists but is minimal
**Gaps**:
- No guided tour for new users
- No prompt to set up profile
- No progressive disclosure of features
**User Stories Affected**:
- ❌ "As a new user, I want a guided introduction to the platform"
---
### 5. Chase Song "Mark as Caught" Not Wired
**Status**: Backend endpoint exists, no frontend UI
**Impact**: Users can add chase songs but can't mark them as caught at shows
**Fix Required**:
- Add "Mark Caught" button on show detail page
- Connect to `POST /chase/songs/{id}/caught`
---
### 6. Performance Rating Disconnected
**Status**: RatingInput component exists, not connected to performances
**Impact**: Users can see ratings but can't submit their own on performance pages
**Fix Required**:
- Wire up `POST /ratings` endpoint on performance detail page
- Award XP when rating is submitted
---
### 7. Notification Center Empty
**Status**: Backend + frontend components exist, no triggers
**Impact**: Bell icon in header shows nothing useful
**Fix Required**:
- Create notifications on: ratings received, badge earned, reply to comment
- Add notification sound/toast for new notifications
---
### 8. Groups Feature Skeletal
**Status**: CRUD exists, no member activity
**Gaps**:
- Can't see what members are doing
- No group leaderboards
- No group chat/discussions
---
## 🟢 Working Features (Verified)
| Feature | Status | Notes |
|---------|--------|-------|
| User registration/login | ✅ | Works |
| Show/Song/Venue browsing | ✅ | Working |
| Performance detail pages | ✅ | With navigation |
| Slug-based API lookups | ✅ | All entities |
| Comment sections | ✅ | Threaded |
| Review system | ✅ | With ratings |
| Chase song list | ✅ | Add/remove works |
| Attendance tracking | ✅ | Basic |
| Profile page | ✅ | With stats |
| Activity feed | ✅ | Global |
| Heady Version display | ✅ | Top performances |
| Admin panel | ✅ | User/content management |
| Mod panel | ✅ | Reports/nicknames |
| Theme toggle | ✅ | Light/dark |
| Settings/preferences | ✅ | Wiki mode |
---
## 📋 Implementation Plan
### Sprint 1: Critical Infrastructure (Est. 4-6 hours)
#### 1.1 Email Service Integration
```
- [ ] Create EmailService class with AWS SES
- [ ] Implement send_verification_email()
- [ ] Implement send_password_reset_email()
- [ ] Wire up auth endpoints
- [ ] Test email flow end-to-end
```
#### 1.2 XP Award Hooks
```
- [ ] Hook award_xp() into attendance.py
- [ ] Hook award_xp() into reviews.py
- [ ] Hook award_xp() into ratings endpoint
- [ ] Call check_and_award_badges() automatically
- [ ] Add "XP earned" toast feedback on frontend
```
---
### Sprint 2: URL & UX Polish (Est. 3-4 hours)
#### 2.1 Slug URLs on Frontend
```
- [ ] Add slug to Song, Show, Venue, Performance response schemas
- [ ] Update Link components to use slug
- [ ] Verify all routes work with slugs
- [ ] Update internal links in ActivityFeed
- [ ] Update search results to use slugs
```
#### 2.2 Performance Rating Widget
```
- [ ] Add RatingInput to performance detail page
- [ ] Connect to POST /ratings endpoint
- [ ] Show user's existing rating if any
- [ ] Animate rating confirmation
- [ ] Award XP on rating
```
---
### Sprint 3: Feature Completion (Est. 4-5 hours)
#### 3.1 Chase Song Completion
```
- [ ] Add "Mark Caught" button on show detail page
- [ ] Show user's chase songs that match show setlist
- [ ] Animate "caught" celebration
- [ ] Award badge for catching 5 songs
```
#### 3.2 Notification Triggers
```
- [ ] Create notification on badge earned
- [ ] Create notification on comment reply
- [ ] Create notification on review reaction
- [ ] Add toast/sound for new notifications
```
#### 3.3 Onboarding Experience
```
- [ ] Create multi-step welcome wizard
- [ ] Prompt profile setup (bio, avatar)
- [ ] Highlight key features
- [ ] Set first badge on completion
```
---
### Sprint 4: Social Enhancement (Est. 3-4 hours)
#### 4.1 XP Leaderboard Integration
```
- [ ] Add XP leaderboard to home page
- [ ] Add leaderboard to /leaderboards page
- [ ] Add "Your Rank" indicator
- [ ] Weekly/monthly/all-time views
```
#### 4.2 Groups Upgrade
```
- [ ] Show member activity in group
- [ ] Group XP leaderboard
- [ ] Group attendance stats
```
---
## 📊 Priority Matrix
| Item | Impact | Effort | Priority |
|------|--------|--------|----------|
| Email service | High | Medium | P1 |
| XP award hooks | High | Low | P1 |
| Slug URLs on frontend | Medium | Low | P2 |
| Performance rating widget | High | Low | P2 |
| Chase "Mark Caught" | Medium | Low | P2 |
| Notification triggers | Medium | Medium | P3 |
| Onboarding wizard | Medium | Medium | P3 |
| Groups enhancement | Low | High | P4 |
---
## Recommended Execution Order
1. **Now**: XP award hooks (quick win, high impact)
2. **Today**: Performance rating widget
3. **Today**: Slug URLs on frontend
4. **Next**: Email service (requires AWS config)
5. **Next**: Chase song completion
6. **Later**: Notifications, onboarding, groups
---
## Quick Wins (Can Do in 30 min each)
1. ✨ Wire XP awards to attendance/review endpoints
2. 🎯 Add performance rating widget
3. 🔗 Update frontend links to use slugs
4. 🏆 Add XP leaderboard to home page
5. 🎵 Add "Mark Caught" button to show pages

View file

@ -0,0 +1,127 @@
# AWS SES Setup - Browser Agent Handoff
## Objective
Configure AWS SES for the Elmeg platform to enable transactional emails (verification, password reset).
**Domain:** `elmeg.xyz`
**Production URL:** `https://elmeg.xyz`
**Sender Email:** `noreply@elmeg.xyz`
---
## Step 1: Verify Domain in SES
1. Go to: <https://console.aws.amazon.com/ses>
2. Select region **US East (N. Virginia) us-east-1** from top-right dropdown
3. Left sidebar → **Verified identities** → Click **Create identity**
4. Select **Domain**
5. Enter: `elmeg.xyz`
6. Keep "Use a custom MAIL FROM domain" unchecked
7. Click **Create identity**
8. Copy the DNS records shown:
- 1 TXT record (for verification)
- 3 CNAME records (for DKIM)
9. **Save these records** - they need to be added to elmeg.xyz DNS
---
## Step 2: Add DNS Records
Go to the DNS provider for `elmeg.xyz` and add:
| Type | Name | Value |
|------|------|-------|
| TXT | `_amazonses.elmeg.xyz` | (from SES console) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 1 from SES) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 2 from SES) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 3 from SES) |
Wait for verification (can take 5-72 hours).
---
## Step 3: Request Production Access
By default SES is in sandbox mode (can only send to verified emails).
1. In SES console → **Account dashboard** (left sidebar)
2. Find "Your account is in the Amazon SES sandbox" banner
3. Click **Request production access**
4. Fill form:
- **Mail type:** Transactional
- **Website URL:** <https://elmeg.xyz>
- **Use case description:**
> "Transactional emails for user account lifecycle on a live music rating platform. Emails include: account verification, password reset, and account notifications. Expected volume: under 1000 emails/month initially."
5. Submit and wait for approval (~24 hours)
---
## Step 4: Create IAM User for SES
1. Go to: <https://console.aws.amazon.com/iam>
2. Left sidebar → **Users** → **Create user**
3. User name: `elmeg-ses-sender`
4. Click **Next**
5. Select **Attach policies directly**
6. Click **Create policy** (opens new tab):
- Select **JSON** tab
- Paste:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}
]
}
```
- Click **Next**
- Policy name: `SES-SendEmail-Only`
- Click **Create policy**
7. Back on user creation, refresh policies, search `SES-SendEmail-Only`, select it
8. Click **Next** → **Create user**
---
## Step 5: Create Access Key
1. Click on user `elmeg-ses-sender`
2. Tab: **Security credentials**
3. Scroll to **Access keys** → **Create access key**
4. Select: **Application running outside AWS**
5. Click **Next** → **Create access key**
6. **SAVE BOTH VALUES:**
- Access key ID: `AKIA...`
- Secret access key: `...`
7. Click **Done**
---
## Step 6: Return Values
Return these values to the user:
```
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_SES_REGION=us-east-1
EMAIL_FROM=noreply@elmeg.xyz
FRONTEND_URL=https://elmeg.xyz
```
Also return the DNS records that need to be added for domain verification.
---
## Notes
- **Never share or commit AWS keys**
- SES sandbox removal takes ~24hrs
- Domain verification DNS propagation can take up to 72hrs
- DKIM is required or emails will be marked as spam

141
docs/AWS_SES_SETUP.md Normal file
View file

@ -0,0 +1,141 @@
# AWS SES Email Setup
## Environment Configuration
### Production (`elmeg.xyz`)
```bash
AWS_ACCESS_KEY_ID=AKIA... # IAM user with SES send-only perms
AWS_SECRET_ACCESS_KEY=...
AWS_SES_REGION=us-east-1 # Must match region where domain is verified
EMAIL_FROM=noreply@elmeg.xyz # Must be on SES-verified domain
FRONTEND_URL=https://elmeg.xyz
NODE_ENV=production
APP_ENV=production
```
### Development (`elmeg.runfoo.run`)
```bash
FRONTEND_URL=https://elmeg.runfoo.run
APP_ENV=development
# AWS keys optional in dev - emails log to console instead
```
---
## SES Setup Checklist
### 1. Verify Domain in SES
1. AWS Console → SES → Verified Identities → Create identity
2. Select "Domain" → enter `elmeg.xyz`
3. Add DNS records AWS provides:
- **TXT record** for domain verification
- **3 CNAME records** for DKIM signing
> [!IMPORTANT]
> Add DKIM records or mail will get flagged as spam
### 2. Move Out of Sandbox
By default SES is sandboxed (can only send to verified emails).
1. SES → Account dashboard → Request production access
2. Fill out:
- Mail type: **Transactional**
- Website URL: `https://elmeg.xyz`
- Use case: "User registration verification, password reset for live music rating platform"
3. Wait for approval (~24hrs)
### 3. Create IAM User
1. IAM → Users → Create user: `elmeg-ses-sender`
2. Attach inline policy (SES send only):
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
```
3. Security credentials → Create access key
4. Save credentials securely
> [!CAUTION]
> **Never commit AWS keys.** Use environment variables only. Never use root AWS credentials - always create a scoped IAM user.
---
## DNS Records Required
Add these to `elmeg.xyz` DNS (exact values from SES console):
| Type | Name | Value |
|------|------|-------|
| TXT | `_amazonses.elmeg.xyz` | (provided by SES) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 1) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 2) |
| CNAME | `xxxx._domainkey.elmeg.xyz` | (DKIM 3) |
---
## Deployment
### Add to production server
```bash
ssh root@tangible-aacorn
cd /srv/containers/elmeg-demo
```
Edit `docker-compose.yml` backend environment:
```yaml
backend:
environment:
- DATABASE_URL=postgresql://elmeg:elmeg@db:5432/elmeg
- AWS_ACCESS_KEY_ID=AKIA...
- AWS_SECRET_ACCESS_KEY=...
- AWS_SES_REGION=us-east-1
- EMAIL_FROM=noreply@elmeg.xyz
- FRONTEND_URL=https://elmeg.xyz
- APP_ENV=production
```
Rebuild and restart:
```bash
docker compose build backend
docker compose restart backend
```
---
## Cost
- **$0.10 per 1,000 emails**
- No monthly minimum
- First 62,000 emails/month free if sending from EC2
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Email address is not verified" | Domain not verified, or still in sandbox |
| "Access Denied" | Check IAM policy has `ses:SendEmail` |
| Emails not sending | Check `docker compose logs backend` |
| Emails in spam | Verify DKIM records are set correctly |
| Wrong links in emails | Check `FRONTEND_URL` matches prod domain |

View file

@ -0,0 +1,62 @@
# Handoff - 2025-12-21
## Work Completed
### Slug Integration
- **Backend**: Updated `Show`, `Song`, `Venue`, `Tour` models/schemas to support `slug`.
- Updated API routers (`shows.py`, `songs.py`) to lookup by slug or ID.
- Migrated database schema to include `slug` columns using Alembic.
- Added `youtube_link` columns via script.
- Backfilled slugs using `backend/fix_db_data.py`.
- **Frontend**: Updated routing and links for entities.
- `/shows/[id]` -> `/shows/${show.slug || show.id}`
- `/songs/[id]` -> `/songs/${song.slug || song.id}`
- `/venues/[id]` -> `/venues/${venue.slug || venue.id}`
- Updated interfaces to include `slug`.
- Updated `PerformanceList` component to use slugs.
### Data Fixes
- **Set Names**:
- Identified issues with `set_name` being null due to API parameter mismatch (`setnumber` vs `set`).
- Updated `import_elgoose.py` to correctly extract and format "Set 1", "Set 2", "Encore" from `setnumber`.
- Attempted to backfill existing data but hit an infinite loop issue with API fetching (Slugs were backfilled successfully). Data can be fixed by re-running a corrected importer or custom script.
- **Slugs**:
- `import_elgoose.py` updated to generate slugs for new imports.
- `fix_db_data.py` successfully backfilled slugs for existing Venues, Songs, Shows, and Tours.
### UI Fixes
- **Components**: Created missing Shadcn UI components (`progress`, `checkbox`).
- **Show Page**: Updated setlist links to point to `/performances/[id]` instead of `/songs/[id]`.
- **Performance Page**: Added "Top Rated Versions" list ranking other performances of the same song.
- **Reviews**: Updated Review Header formatting to be a single line (Song - Date).
- **YouTube**: Created `import_youtube.py` script to link videos to Performances and Shows. ShowPage already supports full show embeds.
- **Auth**: Updated `AuthContext` to expose `token` for the Admin page.
- **Build**: Resolved typescript errors; build process starts correctly.
## Current State
- **Application**: Fully functional slug-based navigation. Links prioritize slugs but fallback to IDs.
- **Database**:
- `slug` columns added and backfilled.
- `youtube_link` columns added to `Show`, `Song`, `Performance` tables (manual migration `add_youtube_links.py` applied).
- `set_name` still missing for most existing performances (displays as "Set ?").
- **Codebase**:
- Clean and updated. `check_api.py` removed.
- `fix_db_data.py` exists but requires a fix for infinite looping (the API likely ignores the `page` parameter or cycles data; the script needs to check for duplicate items to break the loop).
## Next Steps
1. **Monitor Production Fix**:
- The `fix_db_data.py` script was deployed to `tangible-aacorn` (elmeg.xyz) and ran successfully.
- Verified that 0 performances remain with "Set ?".
- `slug`s are also populated.
2. **Notifications**: Internal notifications are implemented (bell icon). External integrations (Discord, Telegram) are **DEPRECATED**.
3. **Audit Results**: Site structure is complete. Key pages (About, Terms, Privacy, Profile, Settings) are implemented and responsive. Features align with "Heady Version" goals.
## Technical Notes
- **Database Migrations**: Alembic history was manually adjusted to ignore existing `reaction`/`badge` tables to allow `slug` migration to pass on the dev database.
- **Importer**: `import_elgoose.py` logic is updated for *future* imports.

View file

@ -0,0 +1,36 @@
# Handoff - 2025-12-22
## Work Completed
### Database & Migrations
- **Performance Slug**: Identified and resolved missing `slug` column on `Performance` table.
- Fixed migration `65c515b4722a_add_slugs.py` to include `server_default` for NOT NULL columns (User table), allowing SQLite migration to succeed.
- Applied migration `65c515b4722a` successfully.
- **Data Fixes**: Updated `fix_db_data.py`:
- Added robust pagination for API fetching.
- Implemented logic to ensure slug uniqueness for Shows, Venues, Songs, etc. across the board.
- Added `Performance` slug generation.
- Attempting to fix `set_name` backfill.
### Notification System
- **Status**: Not started yet. Pending completion of data fixes.
## Current State
- **Database**:
- `slug` column added to `Performance` and verified populated for 100% of records (Shows, Venues, Songs, Tours, Performances).
- Migration `65c515b4722a_add_slugs` applied successfully.
- **Data**:
- `fix_db_data.py` completed slug generation.
- `set_name` backfill failed due to API mapping issues (missing external IDs to link setlists). Existing `set_name` fields remain mostly NULL.
- **Frontend**: Links are using slugs. API supports slug lookup.
## Next Steps
1. **Fix Set Names**: Investigate `fix_db_data.py` mapping logic. Needs a way to reliably link API `setlists` response to DB `shows`. Maybe fuzzy match date + venue?
2. **Notification System**: Implement Discord/Telegram notifications.
- Create `backend/services/notification_service.py`.
- Setup Webhooks/Bots.
3. **Frontend Verification**: Click testing to ensure slug routes load correctly.

View file

@ -0,0 +1,323 @@
# Platform Enhancement Spec v2.0
> **Sprint Goal**: Complete user lifecycle management, robust admin tools, and enhanced content features.
---
## Phase 1: User Account Lifecycle
### 1.1 Email Verification
**Goal**: Ensure valid email addresses and reduce spam accounts.
#### User Stories
- As a **new user**, I want to receive a verification email so I can confirm my account.
- As a **user**, I want to resend verification if I didn't receive it.
- As an **unverified user**, I see limited functionality until verified.
#### Implementation
1. **Model Changes** - Add to `User`:
- `email_verified: bool = Field(default=False)`
- `verification_token: Optional[str]`
- `verification_token_expires: Optional[datetime]`
2. **API Endpoints**:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/auth/register` | Creates user + sends verification email |
| POST | `/auth/verify-email` | Verifies token, sets `email_verified=True` |
| POST | `/auth/resend-verification` | Generates new token, sends email |
3. **Frontend Pages**:
- `/verify-email?token=xxx` - Handles verification link
- Registration success page prompts to check email
4. **Email Template**: HTML email with verification link (48hr expiry)
---
### 1.2 Password Reset
**Goal**: Self-service password recovery without admin intervention.
#### User Stories
- As a **user**, I want to reset my password if I forgot it.
- As a **user**, I want a secure, time-limited reset link.
#### Implementation
1. **Model Changes** - Add to `User`:
- `reset_token: Optional[str]`
- `reset_token_expires: Optional[datetime]`
2. **API Endpoints**:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/auth/forgot-password` | Sends reset email (rate limited) |
| POST | `/auth/reset-password` | Validates token + sets new password |
3. **Frontend Pages**:
- `/forgot-password` - Email input form
- `/reset-password?token=xxx` - New password form
4. **Security**:
- Tokens expire in 1 hour
- Single-use tokens (invalidated after use)
- Rate limiting on forgot-password endpoint
---
### 1.3 Email Service Abstraction
**Goal**: Provider-agnostic email sending.
#### Implementation
Create `backend/email_service.py`:
```python
class EmailService:
async def send_verification_email(user, token)
async def send_password_reset_email(user, token)
async def send_notification_email(user, subject, body)
```
#### Environment Variables
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=<secret>
EMAIL_FROM=noreply@elmeg.xyz
```
---
## Phase 2: Admin Panel (`/admin`)
### 2.1 Overview
**Goal**: Full content management for administrators.
#### User Stories
- As an **admin**, I want to manage all users (roles, bans, verification status).
- As an **admin**, I want to CRUD shows, songs, venues, and tours.
- As an **admin**, I want to see platform statistics.
---
### 2.2 Features
#### Users Tab
- DataTable with search/filter
- Columns: Email, Username, Role, Verified, Active, Joined
- Actions: Edit role, Toggle ban, Force verify, View activity
#### Content Tabs (Shows, Songs, Venues, Tours)
- DataTable with CRUD actions
- Create/Edit modals with form validation
- YouTube link fields for Shows/Songs/Performances
- Bulk delete with confirmation
#### Stats Dashboard
- Total users, verified users
- Total shows, songs, venues
- Recent signups chart
- Activity heatmap
---
### 2.3 Access Control
```python
# backend/routers/admin.py
allow_admin = RoleChecker(["admin"])
@router.get("/users")
def list_users(user: User = Depends(allow_admin), ...):
```
---
## Phase 3: Enhanced Mod Panel (`/mod`)
### 3.1 Current Features
- ✅ Nickname approval queue
- ✅ Report queue (comments, reviews)
### 3.2 New Features
#### User Lookup
- Search user by email/username
- View user's full activity history:
- Comments, reviews, ratings
- Attendance history
- Reports submitted/received
#### Temp Bans
- Ban duration selector (1hr, 24hr, 7d, 30d, permanent)
- Ban reason (required)
- Auto-unban via scheduled job
#### Bulk Actions
- Select multiple reports → Dismiss All / Resolve All
- Select multiple nicknames → Approve All / Reject All
#### Audit Log
- Recent moderation actions by all mods
- Who did what, when
---
## Phase 4: YouTube Integration
### 4.1 Current State
- ✅ `youtube_link` field on Show, Song, Performance
- ✅ `YouTubeEmbed` component
- ✅ Show detail page displays embed
### 4.2 Enhancements
#### Song Page YouTube
- Display YouTube embed of **#1 Heady Version**
- Performance list shows YouTube icon if link exists
#### Admin Integration
- YouTube URL field in Show/Song/Performance edit forms
- URL validation (must be valid YouTube URL)
---
## Phase 5: Song Page Enhancement ("Heady Version")
### 5.1 Concept
A **Song** is the abstract composition (e.g., "Hungersite").
A **Performance** is a specific rendition (e.g., "Hungersite @ Red Rocks 2023").
The "Heady Version" is the highest-rated performance of that song.
---
### 5.2 Song Page Layout
```
┌─────────────────────────────────────────────────────┐
│ 🎵 HUNGERSITE │
│ Original: Goose │ Times Played: 127 │
├─────────────────────────────────────────────────────┤
│ ▶ HEADY VERSION │
│ [YouTube Embed of #1 Performance] │
│ 2023-07-21 @ Red Rocks ★ 9.4 (47 ratings) │
├─────────────────────────────────────────────────────┤
│ 📊 HEADY LEADERBOARD │
│ 🥇 Red Rocks 2023-07-21 9.4★ (47) │
│ 🥈 MSG 2024-03-15 9.1★ (32) │
│ 🥉 Legend Valley 2022-09-10 8.9★ (28) │
│ 4. Dillon 2023-08-15 8.7★ (19) │
│ 5. The Capitol 2024-01-20 8.5★ (22) │
├─────────────────────────────────────────────────────┤
│ 📈 RATING TREND │
│ [Line chart: avg rating over time] │
├─────────────────────────────────────────────────────┤
│ 📅 ALL PERFORMANCES │
│ [Sortable by: Date | Rating] │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 2024-11-15 @ Orpheum 8.2★ ▶ [YouTube] │ │
│ │ 2024-10-20 @ Red Rocks 9.4★ ▶ [YouTube] │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
---
### 5.3 API Endpoints
| Endpoint | Returns |
|----------|---------|
| `GET /songs/{id}` | Song + stats (times_played, avg_rating, heady_version) |
| `GET /songs/{id}/performances` | All performances with ratings, sorted |
| `GET /songs/{id}/heady-version` | Top-rated performance with YouTube link |
---
## Migration
### Database Changes
```sql
ALTER TABLE "user" ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
ALTER TABLE "user" ADD COLUMN verification_token VARCHAR;
ALTER TABLE "user" ADD COLUMN verification_token_expires TIMESTAMP;
ALTER TABLE "user" ADD COLUMN reset_token VARCHAR;
ALTER TABLE "user" ADD COLUMN reset_token_expires TIMESTAMP;
```
---
## Environment Requirements
```env
# Required for email
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASSWORD=<your-sendgrid-key>
EMAIL_FROM=noreply@elmeg.xyz
```
---
## Acceptance Criteria
### Phase 1
- [ ] User registers → receives verification email
- [ ] User clicks link → account verified
- [ ] Unverified user sees "verify email" banner
- [ ] User requests password reset → receives email
- [ ] User resets password → can login with new password
### Phase 2
- [ ] Admin can list/search all users
- [ ] Admin can change user roles
- [ ] Admin can CRUD shows/songs/venues/tours
- [ ] Admin can add YouTube links via UI
### Phase 3
- [ ] Mod can lookup user activity
- [ ] Mod can temp ban users
- [ ] Mod can bulk approve/reject
### Phase 4
- [ ] YouTube embeds on song pages
- [ ] YouTube icons on performance lists
### Phase 5
- [ ] Song page shows Heady Version embed
- [ ] Song page shows Heady Leaderboard
- [ ] Song page shows rating trend chart
- [ ] Performance list sortable by date/rating

View file

@ -0,0 +1,177 @@
# Video Integration Specification
**Date:** 2025-12-22
**Status:** In Progress
## Overview
This spec outlines the complete video integration for elmeg.xyz, ensuring YouTube videos are properly displayed and discoverable across the application.
---
## Current State
### Database Schema ✅
- `Performance.youtube_link` - Individual performance video URL
- `Show.youtube_link` - Full show video URL
- `Song.youtube_link` - Studio/canonical video URL
### Import Pipeline ✅
- `import_youtube.py` processes `youtube_videos.json`
- Handles: single songs, sequences (→), and full shows
- Sequences link the SAME video to ALL performances in the sequence
### Frontend Display (Current)
| Page | Video Display | Status |
|------|--------------|--------|
| Show Page | Full show video (`show.youtube_link`) | ✅ Working |
| Song Page | Top performance video or song video | ✅ Working |
| Performance Page | Should show `performance.youtube_link` | ❌ MISSING |
| Videos Page | Lists all videos | ✅ Working |
### Visual Indicators (Current)
| Location | Indicator | Status |
|----------|-----------|--------|
| Setlist items | Video icon for performances with video | ❌ MISSING |
| Archive/Show list | Video badge for shows with video | ❌ MISSING |
---
## Implementation Plan
### Phase 1: Performance Page Video Display ⚡ HIGH PRIORITY
**File:** `frontend/app/performances/[id]/page.tsx`
**Requirements:**
1. Import `YouTubeEmbed` component
2. Add video section ABOVE the "Version Timeline" card when `performance.youtube_link` exists
3. Style consistently with show page video section
**UI Placement:**
```
[Hero Banner]
[VIDEO EMBED] <-- NEW: Only when youtube_link exists
[Version Timeline]
[About This Performance]
[Comments]
[Reviews]
```
### Phase 2: Setlist Video Indicators
**File:** `frontend/app/shows/[id]/page.tsx`
**Requirements:**
1. Add small YouTube icon (📹 or `<Youtube>`) next to song title when `perf.youtube_link` exists
2. Make icon clickable - links to performance page (where video is embedded)
3. Use red color for YouTube brand recognition
**Visual Design:**
```
1. Dramophone 📹 >
2. The Empress of Organos 📹
```
### Phase 3: Archive Video Badge
**File:** `frontend/app/archive/page.tsx` (or show list component)
**Requirements:**
1. Add video badge to show cards that have:
- `show.youtube_link` (full show video), OR
- Any `performance.youtube_link` in their setlist
2. API enhancement: Add `has_videos` or `video_count` to show list endpoint
**Backend Enhancement:**
```python
# In routers/shows.py - list_shows endpoint
# Add computed field: has_videos = show.youtube_link is not None or any performance has youtube_link
```
**Visual Design:**
- Small YouTube icon in corner of show card
- Tooltip: "Full show video available" or "X song videos available"
---
## Data Flow
```
YouTube Video → import_youtube.py → Database
┌──────────────┼──────────────┐
↓ ↓ ↓
Show.youtube_link Performance.youtube_link Song.youtube_link
↓ ↓ ↓
Show Page Performance Page Song Page
```
---
## API Changes Required
### 1. Shows List Enhancement (Phase 3)
**Endpoint:** `GET /shows/`
**New Response Fields:**
```json
{
"id": 123,
"date": "2025-12-13T00:00:00",
"has_video": true, // NEW: true if show.youtube_link OR any perf.youtube_link
"video_count": 3 // NEW: count of performances with videos
}
```
### 2. Performance Detail (Already Exists)
**Endpoint:** `GET /performances/{id}`
**Verify Field Included:**
```json
{
"youtube_link": "https://www.youtube.com/watch?v=zQI6-LloYwI"
}
```
---
## Testing Checklist
- [ ] Dramophone 2025-12-13 shows video on performance page
- [ ] Empress of Organos 2025-12-13 shows SAME video on performance page
- [ ] Setlist on 2025-12-13 show shows video icons for both songs
- [ ] Archive view shows video indicator for 2025-12-13 show
- [ ] Video page accurately reflects all linked videos
---
## Files Modified
### Phase 1
- `frontend/app/performances/[id]/page.tsx` - Add video embed
### Phase 2
- `frontend/app/shows/[id]/page.tsx` - Add video icons to setlist
### Phase 3
- `backend/routers/shows.py` - Add has_videos to list response
- `frontend/app/archive/page.tsx` - Add video badge to cards

147
email/README.md Normal file
View file

@ -0,0 +1,147 @@
# Elmeg Email Service
Transactional email layer for Elmeg using Amazon SES v2 with stored templates.
## Purpose
This module provides a production-ready email service for user-initiated transactional emails:
- **Email Verification** Sent after user registration
- **Password Reset** Sent when user requests password recovery
- **Security Alerts** Sent for account security events (new logins, password changes)
> **Compliance Note:** This service is strictly for transactional, user-initiated emails. No newsletters, marketing emails, or cold outreach. No purchased or third-party email lists are used.
## Requirements
- Node.js >= 18.0.0
- AWS account with SES verified domain
- SES templates deployed (see below)
## Environment Variables
```bash
# Required
AWS_ACCESS_KEY_ID=AKIA... # IAM user with SES permissions
AWS_SECRET_ACCESS_KEY=... # IAM user secret key
AWS_SES_REGION=us-east-1 # SES region (domain must be verified here)
EMAIL_FROM=noreply@elmeg.xyz # Verified sender address
# Optional
FRONTEND_URL=https://elmeg.xyz # For generating email links
SUPPORT_EMAIL=support@elmeg.xyz # Contact email in templates
```
## Installation
```bash
cd email
npm install
npm run build
```
## Deploy Templates to SES
Before sending emails, deploy the templates to AWS SES:
```bash
npm run deploy-templates
```
This creates/updates three templates in SES:
- `ELMEG_EMAIL_VERIFICATION`
- `ELMEG_PASSWORD_RESET`
- `ELMEG_SECURITY_ALERT`
## Usage
```typescript
import {
sendVerificationEmail,
sendPasswordResetEmail,
sendSecurityAlertEmail,
generateVerificationLink,
generateResetLink,
} from "@elmeg/email-service";
// After user registration
await sendVerificationEmail({
to: "user@example.com",
userName: "John",
verificationLink: generateVerificationLink(token),
});
// After password reset request
await sendPasswordResetEmail({
to: "user@example.com",
userName: "John",
resetLink: generateResetLink(token),
});
// After suspicious login
await sendSecurityAlertEmail({
to: "user@example.com",
userName: "John",
securityEventDescription: "New sign-in from Chrome on Windows at 10:30 AM",
});
```
## Template Placeholders
| Placeholder | Description | Templates |
|-------------|-------------|-----------|
| `{{app_name}}` | "Elmeg" | All |
| `{{user_name}}` | User's name or email prefix | All |
| `{{support_email}}` | Support contact | All |
| `{{verification_link}}` | Email verification URL | Verification |
| `{{reset_link}}` | Password reset URL | Password Reset |
| `{{security_event_description}}` | Event details | Security Alert |
## AWS SES Setup Checklist
1. **Verify Domain** Add `elmeg.xyz` in SES console with DKIM records
2. **Request Production Access** Move out of sandbox to send to any address
3. **Create IAM User** With `ses:SendEmail` and `ses:SendTemplatedEmail` permissions
4. **Deploy Templates** Run `npm run deploy-templates`
## Compliance & Best Practices
| Requirement | Implementation |
|-------------|----------------|
| User-initiated only | All emails triggered by user actions |
| No purchased lists | Only registered users receive emails |
| Bounce handling | SES automatically suppresses bounced addresses |
| Complaint handling | SES suppresses addresses that report spam |
| Unsubscribe | N/A for transactional (required action emails) |
## Error Handling
All send functions return a structured result:
```typescript
interface EmailResult {
success: boolean;
messageId?: string; // SES message ID on success
error?: {
code: string; // Error code from SES
message: string; // Human-readable error message
};
}
```
## File Structure
```
email/
├── src/
│ ├── email-service.ts # Main service module
│ └── examples.ts # Usage examples
├── scripts/
│ └── deploy-templates.ts # Template deployment script
├── templates/
│ └── README.md # Template documentation
├── package.json
├── tsconfig.json
└── README.md # This file
```

5183
email/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
email/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "@elmeg/email-service",
"version": "1.0.0",
"description": "Transactional email service for Elmeg using AWS SES v2",
"main": "dist/email-service.js",
"types": "dist/email-service.d.ts",
"scripts": {
"build": "tsc",
"test": "jest",
"deploy-templates": "ts-node scripts/deploy-templates.ts"
},
"keywords": [
"email",
"ses",
"aws",
"transactional"
],
"author": "Elmeg",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-sesv2": "^3.956.0"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.19.27",
"jest": "^29.7.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,234 @@
/**
* Deploy SES Templates to AWS
*
* Run this script to create or update the email templates in AWS SES.
* Usage: npx ts-node scripts/deploy-templates.ts
*/
import { SESv2Client, CreateEmailTemplateCommand, UpdateEmailTemplateCommand } from "@aws-sdk/client-sesv2";
const sesClient = new SESv2Client({
region: process.env.AWS_SES_REGION || "us-east-1",
});
const APP_NAME = "Elmeg";
const SUPPORT_EMAIL = "support@elmeg.xyz";
// Template definitions
const templates = [
{
TemplateName: "ELMEG_EMAIL_VERIFICATION",
TemplateContent: {
Subject: "Verify your Elmeg account",
Html: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Verify your email address</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Thanks for signing up for {{app_name}}. Please verify your email address by clicking the button below.</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #4f46e5; border-radius: 6px;">
<a href="{{verification_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Verify Email Address</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">This link will expire after 24 hours for your security.</p>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">If you did not create an account, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="margin: 0; color: #999999; font-size: 12px;">If the button above doesn't work, copy and paste this URL: {{verification_link}}</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} Contact: {{support_email}}</p>
</td>
</tr>
</table>
</body>
</html>`,
Text: `Verify your Elmeg account
Hi {{user_name}},
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the link below:
{{verification_link}}
This link will expire after 24 hours for your security.
If you did not create an account, you can safely ignore this email.
---
{{app_name}} Contact: {{support_email}}`,
},
},
{
TemplateName: "ELMEG_PASSWORD_RESET",
TemplateContent: {
Subject: "Reset your Elmeg password",
Html: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Reset your password</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">We received a request to reset the password for your {{app_name}} account. Click the button below to choose a new password.</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #4f46e5; border-radius: 6px;">
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Reset Password</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">This link will expire after 1 hour for your security.</p>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">If you did not request a password reset, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="margin: 0; color: #999999; font-size: 12px;">If the button above doesn't work, copy and paste this URL: {{reset_link}}</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} Contact: {{support_email}}</p>
</td>
</tr>
</table>
</body>
</html>`,
Text: `Reset your Elmeg password
Hi {{user_name}},
We received a request to reset the password for your {{app_name}} account.
Click the link below to choose a new password:
{{reset_link}}
This link will expire after 1 hour for your security.
If you did not request a password reset, you can safely ignore this email.
---
{{app_name}} Contact: {{support_email}}`,
},
},
{
TemplateName: "ELMEG_SECURITY_ALERT",
TemplateContent: {
Subject: "Security alert for your Elmeg account",
Html: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" style="margin-bottom: 20px;">
<tr>
<td style="background-color: #fef3c7; border-radius: 6px; padding: 12px 16px;">
<p style="margin: 0; color: #92400e; font-size: 14px; font-weight: 600;"> Security Notice</p>
</td>
</tr>
</table>
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Account activity detected</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">Hi {{user_name}},</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">We detected the following activity on your {{app_name}} account:</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="background-color: #f3f4f6; border-radius: 6px; padding: 16px;">
<p style="margin: 0; color: #374151; font-size: 15px; line-height: 1.5;">{{security_event_description}}</p>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">If this was you, no further action is needed.</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">If you did not perform this action, please secure your account immediately.</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 12px;">{{app_name}} Contact: {{support_email}}</p>
</td>
</tr>
</table>
</body>
</html>`,
Text: `Security alert for your Elmeg account
Hi {{user_name}},
We detected the following activity on your {{app_name}} account:
{{security_event_description}}
If this was you, no further action is needed.
If you did not perform this action, please secure your account immediately.
---
{{app_name}} Contact: {{support_email}}`,
},
},
];
async function deployTemplates() {
console.log("🚀 Deploying SES email templates...\n");
for (const template of templates) {
try {
// Try to create the template first
const createCommand = new CreateEmailTemplateCommand(template);
await sesClient.send(createCommand);
console.log(`✅ Created template: ${template.TemplateName}`);
} catch (error: unknown) {
const err = error as { name?: string };
if (err.name === "AlreadyExistsException") {
// Template exists, update it
try {
const updateCommand = new UpdateEmailTemplateCommand(template);
await sesClient.send(updateCommand);
console.log(`🔄 Updated template: ${template.TemplateName}`);
} catch (updateError) {
console.error(`❌ Failed to update ${template.TemplateName}:`, updateError);
}
} else {
console.error(`❌ Failed to create ${template.TemplateName}:`, error);
}
}
}
console.log("\n✅ Template deployment complete!");
}
deployTemplates().catch(console.error);

209
email/src/email-service.ts Normal file
View file

@ -0,0 +1,209 @@
/**
* Elmeg Email Service - AWS SES v2 Integration
*
* Transactional email layer for user-initiated emails only.
* Uses AWS SES stored templates for consistent, reliable delivery.
*/
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
// Configuration from environment variables
const config = {
region: process.env.AWS_SES_REGION || "us-east-1",
fromAddress: process.env.EMAIL_FROM || "noreply@elmeg.xyz",
appName: "Elmeg",
supportEmail: process.env.SUPPORT_EMAIL || "support@elmeg.xyz",
frontendUrl: process.env.FRONTEND_URL || "https://elmeg.xyz",
};
// SES Template Names
export const SES_TEMPLATES = {
EMAIL_VERIFICATION: "ELMEG_EMAIL_VERIFICATION",
PASSWORD_RESET: "ELMEG_PASSWORD_RESET",
SECURITY_ALERT: "ELMEG_SECURITY_ALERT",
} as const;
// Initialize SES v2 client
const sesClient = new SESv2Client({
region: config.region,
// Credentials are loaded automatically from env vars:
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
});
// =============================================================================
// Types
// =============================================================================
export interface SendVerificationEmailParams {
to: string;
userName: string;
verificationLink: string;
}
export interface SendPasswordResetEmailParams {
to: string;
userName: string;
resetLink: string;
}
export interface SendSecurityAlertEmailParams {
to: string;
userName: string;
securityEventDescription: string;
}
export interface EmailResult {
success: boolean;
messageId?: string;
error?: {
code: string;
message: string;
};
}
export class EmailError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = "EmailError";
this.code = code;
}
}
// =============================================================================
// Email Sending Functions
// =============================================================================
/**
* Send email verification email to new users
*/
export async function sendVerificationEmail(
params: SendVerificationEmailParams
): Promise<EmailResult> {
const templateData = {
user_name: params.userName,
verification_link: params.verificationLink,
app_name: config.appName,
support_email: config.supportEmail,
};
return sendTemplatedEmail(
params.to,
SES_TEMPLATES.EMAIL_VERIFICATION,
templateData
);
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
params: SendPasswordResetEmailParams
): Promise<EmailResult> {
const templateData = {
user_name: params.userName,
reset_link: params.resetLink,
app_name: config.appName,
support_email: config.supportEmail,
};
return sendTemplatedEmail(
params.to,
SES_TEMPLATES.PASSWORD_RESET,
templateData
);
}
/**
* Send security alert email for account events
*/
export async function sendSecurityAlertEmail(
params: SendSecurityAlertEmailParams
): Promise<EmailResult> {
const templateData = {
user_name: params.userName,
security_event_description: params.securityEventDescription,
app_name: config.appName,
support_email: config.supportEmail,
};
return sendTemplatedEmail(
params.to,
SES_TEMPLATES.SECURITY_ALERT,
templateData
);
}
// =============================================================================
// Core Email Function
// =============================================================================
async function sendTemplatedEmail(
to: string,
templateName: string,
templateData: Record<string, string>
): Promise<EmailResult> {
try {
const command = new SendEmailCommand({
FromEmailAddress: config.fromAddress,
Destination: {
ToAddresses: [to],
},
Content: {
Template: {
TemplateName: templateName,
TemplateData: JSON.stringify(templateData),
},
},
});
const response = await sesClient.send(command);
return {
success: true,
messageId: response.MessageId,
};
} catch (error: unknown) {
const err = error as { name?: string; message?: string; Code?: string };
console.error(`[Email] Failed to send ${templateName} to ${to}:`, err.message);
return {
success: false,
error: {
code: err.Code || err.name || "UNKNOWN_ERROR",
message: err.message || "Failed to send email",
},
};
}
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Generate a verification link for a user
*/
export function generateVerificationLink(token: string): string {
return `${config.frontendUrl}/verify-email?token=${encodeURIComponent(token)}`;
}
/**
* Generate a password reset link for a user
*/
export function generateResetLink(token: string): string {
return `${config.frontendUrl}/reset-password?token=${encodeURIComponent(token)}`;
}
/**
* Check if the email service is properly configured
*/
export function isEmailConfigured(): boolean {
return !!(
process.env.AWS_ACCESS_KEY_ID &&
process.env.AWS_SECRET_ACCESS_KEY &&
process.env.AWS_SES_REGION
);
}

176
email/src/examples.ts Normal file
View file

@ -0,0 +1,176 @@
/**
* Elmeg Email Service - Usage Examples
*
* These examples show how to integrate the email service
* into your application's user flows.
*/
import {
sendVerificationEmail,
sendPasswordResetEmail,
sendSecurityAlertEmail,
generateVerificationLink,
generateResetLink,
isEmailConfigured,
} from "./email-service";
// =============================================================================
// Example 1: User Registration Flow
// =============================================================================
async function handleUserRegistration(
userEmail: string,
userName: string,
verificationToken: string
) {
// Check if email is configured
if (!isEmailConfigured()) {
console.warn("[Email] Email service not configured, skipping verification email");
return;
}
// Generate the verification link
const verificationLink = generateVerificationLink(verificationToken);
// Send the verification email
const result = await sendVerificationEmail({
to: userEmail,
userName: userName || userEmail.split("@")[0],
verificationLink,
});
if (result.success) {
console.log(`[Email] Verification email sent to ${userEmail}, messageId: ${result.messageId}`);
} else {
console.error(`[Email] Failed to send verification email: ${result.error?.message}`);
// Handle error - maybe retry or alert admin
}
}
// =============================================================================
// Example 2: Forgot Password Flow
// =============================================================================
async function handleForgotPassword(
userEmail: string,
userName: string,
resetToken: string
) {
if (!isEmailConfigured()) {
console.warn("[Email] Email service not configured, skipping password reset email");
return;
}
const resetLink = generateResetLink(resetToken);
const result = await sendPasswordResetEmail({
to: userEmail,
userName: userName || userEmail.split("@")[0],
resetLink,
});
if (result.success) {
console.log(`[Email] Password reset email sent to ${userEmail}`);
} else {
console.error(`[Email] Failed to send password reset email: ${result.error?.message}`);
}
}
// =============================================================================
// Example 3: Security Alert - New Login
// =============================================================================
async function handleNewLogin(
userEmail: string,
userName: string,
loginDetails: { ip: string; browser: string; location?: string; timestamp: Date }
) {
if (!isEmailConfigured()) {
return;
}
const eventDescription = [
`New sign-in to your account`,
``,
`Time: ${loginDetails.timestamp.toLocaleString()}`,
`IP Address: ${loginDetails.ip}`,
`Browser: ${loginDetails.browser}`,
loginDetails.location ? `Location: ${loginDetails.location}` : null,
]
.filter(Boolean)
.join("\n");
const result = await sendSecurityAlertEmail({
to: userEmail,
userName: userName || userEmail.split("@")[0],
securityEventDescription: eventDescription,
});
if (!result.success) {
console.error(`[Email] Failed to send security alert: ${result.error?.message}`);
}
}
// =============================================================================
// Example 4: Security Alert - Password Changed
// =============================================================================
async function handlePasswordChanged(
userEmail: string,
userName: string,
timestamp: Date
) {
if (!isEmailConfigured()) {
return;
}
const eventDescription = `Your password was changed on ${timestamp.toLocaleString()}. If you did not make this change, please contact support immediately.`;
await sendSecurityAlertEmail({
to: userEmail,
userName: userName || userEmail.split("@")[0],
securityEventDescription: eventDescription,
});
}
// =============================================================================
// Example 5: Express.js Route Handler Integration
// =============================================================================
/*
import express from "express";
import { sendVerificationEmail, generateVerificationLink } from "./email-service";
const router = express.Router();
router.post("/register", async (req, res) => {
const { email, password, name } = req.body;
// ... create user in database ...
const user = await createUser({ email, password, name });
// Generate verification token
const verificationToken = generateSecureToken();
await saveVerificationToken(user.id, verificationToken);
// Send verification email
const verificationLink = generateVerificationLink(verificationToken);
const emailResult = await sendVerificationEmail({
to: email,
userName: name || email.split("@")[0],
verificationLink,
});
if (!emailResult.success) {
console.error("Failed to send verification email:", emailResult.error);
// Don't fail registration, just log the error
}
res.status(201).json({
message: "Account created. Please check your email to verify your account."
});
});
export default router;
*/

306
email/templates/README.md Normal file
View file

@ -0,0 +1,306 @@
# AWS SES Email Templates for Elmeg
## Template 1: Email Verification
**Template Name:** `ELMEG_EMAIL_VERIFICATION`
### Subject
```
Verify your Elmeg account
```
### HTML Body
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Verify your email address</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
Hi {{user_name}},
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the button below.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #4f46e5; border-radius: 6px;">
<a href="{{verification_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Verify Email Address</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
This link will expire after 24 hours for your security.
</p>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
If you did not create an account, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
If the button above doesn't work, copy and paste this URL into your browser:
</p>
<p style="margin: 10px 0 0; color: #4f46e5; font-size: 12px; word-break: break-all;">
{{verification_link}}
</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
{{app_name}} The Goose Community Archive
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Questions? Contact us at {{support_email}}
</p>
</td>
</tr>
</table>
</body>
</html>
```
### Plain Text Body
```
Verify your Elmeg account
Hi {{user_name}},
Thanks for signing up for {{app_name}}. Please verify your email address by clicking the link below:
{{verification_link}}
This link will expire after 24 hours for your security.
If you did not create an account, you can safely ignore this email.
---
{{app_name}} The Goose Community Archive
Questions? Contact us at {{support_email}}
```
---
## Template 2: Password Reset
**Template Name:** `ELMEG_PASSWORD_RESET`
### Subject
```
Reset your Elmeg password
```
### HTML Body
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset your password</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Reset your password</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
Hi {{user_name}},
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
We received a request to reset the password for your {{app_name}} account. Click the button below to choose a new password.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
<tr>
<td style="background-color: #4f46e5; border-radius: 6px;">
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 32px; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: 600;">Reset Password</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
This link will expire after 1 hour for your security.
</p>
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; line-height: 1.5;">
If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.
</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
If the button above doesn't work, copy and paste this URL into your browser:
</p>
<p style="margin: 10px 0 0; color: #4f46e5; font-size: 12px; word-break: break-all;">
{{reset_link}}
</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
{{app_name}} The Goose Community Archive
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Questions? Contact us at {{support_email}}
</p>
</td>
</tr>
</table>
</body>
</html>
```
### Plain Text Body
```
Reset your Elmeg password
Hi {{user_name}},
We received a request to reset the password for your {{app_name}} account.
Click the link below to choose a new password:
{{reset_link}}
This link will expire after 1 hour for your security.
If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.
---
{{app_name}} The Goose Community Archive
Questions? Contact us at {{support_email}}
```
---
## Template 3: Security Alert
**Template Name:** `ELMEG_SECURITY_ALERT`
### Subject
```
Security alert for your Elmeg account
```
### HTML Body
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Security Alert</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: 0 auto; background-color: #ffffff;">
<tr>
<td style="padding: 40px 30px; text-align: center; background-color: #1a1a2e;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">{{app_name}}</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" style="margin-bottom: 20px;">
<tr>
<td style="background-color: #fef3c7; border-radius: 6px; padding: 12px 16px;">
<p style="margin: 0; color: #92400e; font-size: 14px; font-weight: 600;">⚠️ Security Notice</p>
</td>
</tr>
</table>
<h2 style="margin: 0 0 20px; color: #1a1a2e; font-size: 22px; font-weight: 600;">Account activity detected</h2>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
Hi {{user_name}},
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
We detected the following activity on your {{app_name}} account:
</p>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="background-color: #f3f4f6; border-radius: 6px; padding: 16px;">
<p style="margin: 0; color: #374151; font-size: 15px; line-height: 1.5;">
{{security_event_description}}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
If this was you, no further action is needed.
</p>
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.5;">
If you did not perform this action, we recommend you secure your account immediately by changing your password.
</p>
<hr style="border: none; border-top: 1px solid #eeeeee; margin: 30px 0;">
<p style="margin: 0; color: #999999; font-size: 12px; line-height: 1.5;">
This is an automated security notification. If you have concerns about your account security, please contact us at {{support_email}}.
</p>
</td>
</tr>
<tr>
<td style="padding: 30px; background-color: #f9fafb; text-align: center;">
<p style="margin: 0 0 10px; color: #666666; font-size: 14px;">
{{app_name}} The Goose Community Archive
</p>
<p style="margin: 0; color: #999999; font-size: 12px;">
Questions? Contact us at {{support_email}}
</p>
</td>
</tr>
</table>
</body>
</html>
```
### Plain Text Body
```
Security alert for your Elmeg account
Hi {{user_name}},
We detected the following activity on your {{app_name}} account:
{{security_event_description}}
If this was you, no further action is needed.
If you did not perform this action, we recommend you secure your account immediately by changing your password.
---
This is an automated security notification. If you have concerns about your account security, please contact us at {{support_email}}.
{{app_name}} The Goose Community Archive
```
---
## Template Placeholders Reference
| Placeholder | Description | Used In |
|-------------|-------------|---------|
| `{{app_name}}` | Application name ("Elmeg") | All templates |
| `{{user_name}}` | User's display name or email | All templates |
| `{{support_email}}` | Support contact email | All templates |
| `{{verification_link}}` | Email verification URL | Email Verification |
| `{{reset_link}}` | Password reset URL | Password Reset |
| `{{security_event_description}}` | Description of the security event | Security Alert |

26
email/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View file

@ -1,30 +1,410 @@
"use client"
import { useEffect, useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import {
Users, Music2, MapPin, Calendar, BarChart3,
Shield, ShieldCheck, Search, Check, X, Edit
} from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
export default function AdminDashboard() {
interface PlatformStats {
total_users: number
verified_users: number
total_shows: number
total_songs: number
total_venues: number
total_ratings: number
total_reviews: number
total_comments: number
}
interface UserItem {
id: number
email: string
username: string | null
role: string
is_active: boolean
email_verified: boolean
}
export default function AdminPage() {
const { user, token } = useAuth()
const router = useRouter()
const [stats, setStats] = useState<PlatformStats | null>(null)
const [users, setUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingUser, setEditingUser] = useState<UserItem | null>(null)
useEffect(() => {
if (!user) {
router.push("/login")
return
}
if (user.role !== "admin") {
router.push("/")
return
}
fetchData()
}, [user, router])
const fetchData = async () => {
if (!token) return
try {
const [statsRes, usersRes] = await Promise.all([
fetch(`${getApiUrl()}/admin/stats`, {
headers: { Authorization: `Bearer ${token}` }
}),
fetch(`${getApiUrl()}/admin/users`, {
headers: { Authorization: `Bearer ${token}` }
})
])
if (statsRes.ok) setStats(await statsRes.json())
if (usersRes.ok) setUsers(await usersRes.json())
} catch (e) {
console.error("Failed to fetch admin data", e)
} finally {
setLoading(false)
}
}
const updateUser = async (userId: number, updates: Partial<UserItem>) => {
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/admin/users/${userId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(updates)
})
if (res.ok) {
fetchData()
setEditingUser(null)
}
} catch (e) {
console.error("Failed to update user", e)
}
}
const filteredUsers = users.filter(u =>
u.email.toLowerCase().includes(search.toLowerCase()) ||
(u.username && u.username.toLowerCase().includes(search.toLowerCase()))
)
if (loading) {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Nicknames</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">--</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Reports</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">--</div>
</CardContent>
</Card>
<div className="container py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-48" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => <div key={i} className="h-24 bg-muted rounded" />)}
</div>
</div>
<p className="text-muted-foreground">Select a category from the sidebar to manage content.</p>
</div>
)
}
if (!user || user.role !== "admin") {
return null
}
return (
<div className="container py-8 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold flex items-center gap-2">
<Shield className="h-8 w-8" />
Admin Dashboard
</h1>
</div>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-blue-500" />
<div>
<p className="text-2xl font-bold">{stats.total_users}</p>
<p className="text-sm text-muted-foreground">Total Users</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-green-500" />
<div>
<p className="text-2xl font-bold">{stats.verified_users}</p>
<p className="text-sm text-muted-foreground">Verified</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-purple-500" />
<div>
<p className="text-2xl font-bold">{stats.total_shows}</p>
<p className="text-sm text-muted-foreground">Shows</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Music2 className="h-5 w-5 text-orange-500" />
<div>
<p className="text-2xl font-bold">{stats.total_songs}</p>
<p className="text-sm text-muted-foreground">Songs</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
<Tabs defaultValue="users">
<TabsList>
<TabsTrigger value="users">Users</TabsTrigger>
<TabsTrigger value="content">Content</TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">User</th>
<th className="text-left p-3 font-medium">Role</th>
<th className="text-left p-3 font-medium">Status</th>
<th className="text-left p-3 font-medium">Verified</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(u => (
<tr key={u.id} className="border-t">
<td className="p-3">
<div>
<p className="font-medium">{u.username || "No username"}</p>
<p className="text-sm text-muted-foreground">{u.email}</p>
</div>
</td>
<td className="p-3">
<Badge variant={u.role === "admin" ? "default" : u.role === "moderator" ? "secondary" : "outline"}>
{u.role}
</Badge>
</td>
<td className="p-3">
{u.is_active ? (
<Badge variant="outline" className="text-green-600 border-green-600">Active</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600">Banned</Badge>
)}
</td>
<td className="p-3">
{u.email_verified ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<X className="h-4 w-4 text-red-500" />
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingUser(u)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="content" className="space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Shows
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_shows || 0} shows in database
</p>
<Button variant="outline" size="sm">
Manage Shows
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Music2 className="h-5 w-5" />
Songs
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_songs || 0} songs in database
</p>
<Button variant="outline" size="sm">
Manage Songs
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Venues
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
{stats?.total_venues || 0} venues in database
</p>
<Button variant="outline" size="sm">
Manage Venues
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-sm">
<p>{stats?.total_ratings || 0} ratings</p>
<p>{stats?.total_reviews || 0} reviews</p>
<p>{stats?.total_comments || 0} comments</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
{editingUser && (
<div className="space-y-4 py-4">
<div>
<p className="font-medium">{editingUser.email}</p>
<p className="text-sm text-muted-foreground">{editingUser.username || "No username"}</p>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select
value={editingUser.role}
onValueChange={(value) => setEditingUser({ ...editingUser, role: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label>Account Active</Label>
<Button
variant={editingUser.is_active ? "default" : "destructive"}
size="sm"
onClick={() => setEditingUser({ ...editingUser, is_active: !editingUser.is_active })}
>
{editingUser.is_active ? "Active" : "Banned"}
</Button>
</div>
<div className="flex items-center justify-between">
<Label>Email Verified</Label>
<Button
variant={editingUser.email_verified ? "default" : "outline"}
size="sm"
onClick={() => setEditingUser({ ...editingUser, email_verified: !editingUser.email_verified })}
>
{editingUser.email_verified ? "Verified" : "Unverified"}
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingUser(null)}>
Cancel
</Button>
<Button onClick={() => editingUser && updateUser(editingUser.id, {
role: editingUser.role,
is_active: editingUser.is_active,
email_verified: editingUser.email_verified
})}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -0,0 +1,110 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Mail, ArrowLeft, CheckCircle } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("")
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const res = await fetch(`${getApiUrl()}/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
})
if (res.ok) {
setSubmitted(true)
} else {
const data = await res.json()
setError(data.detail || "Failed to send reset email")
}
} catch (e) {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
if (submitted) {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<CardTitle>Check Your Email</CardTitle>
<CardDescription>
If an account exists with that email, we've sent password reset instructions.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button variant="outline" asChild>
<Link href="/login">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Login
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<Mail className="h-12 w-12 text-primary mx-auto mb-4" />
<CardTitle>Forgot Password?</CardTitle>
<CardDescription>
Enter your email and we'll send you a reset link.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Sending..." : "Send Reset Link"}
</Button>
<div className="text-center">
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
<ArrowLeft className="inline h-3 w-3 mr-1" />
Back to Login
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -3,11 +3,12 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: 'Space Grotesk', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -43,73 +44,75 @@
--radius-xl: calc(var(--radius) + 4px);
}
/* Light Mode - Ersen Style */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--radius: 0.3rem;
--background: hsl(240, 5%, 98%);
--foreground: hsl(240, 10%, 3.9%);
--card: hsl(0, 0%, 100%);
--card-foreground: hsl(240, 10%, 3.9%);
--popover: hsl(0, 0%, 100%);
--popover-foreground: hsl(240, 10%, 3.9%);
--primary: hsl(221.2, 83.2%, 53.3%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(240, 5.9%, 90%);
--secondary-foreground: hsl(240, 5.9%, 10%);
--muted: hsl(240, 4.8%, 95.9%);
--muted-foreground: hsl(240, 3.8%, 46.1%);
--accent: hsl(240, 4.8%, 95.9%);
--accent-foreground: hsl(240, 5.9%, 10%);
--destructive: hsl(0, 84.2%, 60.2%);
--border: hsl(240, 5.9%, 90%);
--input: hsl(240, 5.9%, 90%);
--ring: hsl(221.2, 83.2%, 53.3%);
--chart-1: hsl(12, 76%, 61%);
--chart-2: hsl(173, 58%, 39%);
--chart-3: hsl(197, 37%, 24%);
--chart-4: hsl(43, 74%, 66%);
--chart-5: hsl(27, 87%, 67%);
--sidebar: hsl(0, 0%, 100%);
--sidebar-foreground: hsl(240, 10%, 3.9%);
--sidebar-primary: hsl(221.2, 83.2%, 53.3%);
--sidebar-primary-foreground: hsl(0, 0%, 100%);
--sidebar-accent: hsl(240, 4.8%, 95.9%);
--sidebar-accent-foreground: hsl(240, 5.9%, 10%);
--sidebar-border: hsl(240, 5.9%, 90%);
--sidebar-ring: hsl(221.2, 83.2%, 53.3%);
}
/* Dark Mode - Ersen Style */
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--background: hsl(240, 10%, 3.9%);
--foreground: hsl(0, 0%, 95%);
--card: hsl(240, 10%, 5%);
--card-foreground: hsl(0, 0%, 95%);
--popover: hsl(240, 10%, 5%);
--popover-foreground: hsl(0, 0%, 95%);
--primary: hsl(221.2, 83.2%, 53.3%);
--primary-foreground: hsl(0, 0%, 100%);
--secondary: hsl(240, 3.7%, 15.9%);
--secondary-foreground: hsl(0, 0%, 95%);
--muted: hsl(240, 3.7%, 15.9%);
--muted-foreground: hsl(240, 5%, 64.9%);
--accent: hsl(240, 3.7%, 15.9%);
--accent-foreground: hsl(0, 0%, 95%);
--destructive: hsl(0, 62.8%, 50.6%);
--border: hsl(240, 3.7%, 15.9%);
--input: hsl(240, 3.7%, 15.9%);
--ring: hsl(221.2, 83.2%, 53.3%);
--chart-1: hsl(220, 70%, 50%);
--chart-2: hsl(160, 60%, 45%);
--chart-3: hsl(30, 80%, 55%);
--chart-4: hsl(280, 65%, 60%);
--chart-5: hsl(340, 75%, 55%);
--sidebar: hsl(240, 10%, 5%);
--sidebar-foreground: hsl(0, 0%, 95%);
--sidebar-primary: hsl(221.2, 83.2%, 53.3%);
--sidebar-primary-foreground: hsl(0, 0%, 100%);
--sidebar-accent: hsl(240, 3.7%, 15.9%);
--sidebar-accent-foreground: hsl(0, 0%, 95%);
--sidebar-border: hsl(240, 3.7%, 15.9%);
--sidebar-ring: hsl(221.2, 83.2%, 53.3%);
}
@layer base {
@ -119,5 +122,22 @@
body {
@apply bg-background text-foreground;
font-family: 'Space Grotesk', system-ui, sans-serif;
}
code,
pre,
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-weight: 600;
}
}

View file

@ -1,14 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { Space_Grotesk, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/layout/navbar";
import { cn } from "@/lib/utils";
import { PreferencesProvider } from "@/contexts/preferences-context";
import { AuthProvider } from "@/contexts/auth-context";
import { ThemeProvider } from "@/components/theme-provider";
import { Footer } from "@/components/layout/footer";
const inter = Inter({ subsets: ["latin"] });
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-sans",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
export const metadata: Metadata = {
title: "Elmeg - Fandom Archive",
@ -21,8 +29,18 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={cn(inter.className, "min-h-screen bg-background font-sans antialiased flex flex-col")}>
<html lang="en" suppressHydrationWarning>
<body className={cn(
spaceGrotesk.variable,
jetbrainsMono.variable,
"min-h-screen bg-background font-sans antialiased flex flex-col"
)}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<PreferencesProvider>
<Navbar />
@ -32,6 +50,7 @@ export default function RootLayout({
<Footer />
</PreferencesProvider>
</AuthProvider>
</ThemeProvider>
</body>
</html>
);

View file

@ -85,6 +85,9 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
/>
<Link href="/forgot-password" className="text-xs text-muted-foreground hover:underline">
Forgot password?
</Link>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">

View file

@ -2,11 +2,29 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Check, X, ShieldAlert, AlertTriangle } from "lucide-react"
import { Check, X, ShieldAlert, Search, Ban, UserCheck, CheckCircle } from "lucide-react"
import { useEffect, useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getApiUrl } from "@/lib/api-config"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
interface PendingNickname {
id: number
@ -27,12 +45,49 @@ interface PendingReport {
status: string
}
interface QueueStats {
pending_nicknames: number
pending_reports: number
total_bans: number
}
interface UserLookup {
id: number
email: string
username: string | null
role: string
is_active: boolean
email_verified: boolean
stats: {
ratings: number
reviews: number
comments: number
attendances: number
reports_submitted: number
}
}
export default function ModDashboardPage() {
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
const [pendingReports, setPendingReports] = useState<PendingReport[]>([])
const [stats, setStats] = useState<QueueStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
// User lookup
const [lookupQuery, setLookupQuery] = useState("")
const [lookupUser, setLookupUser] = useState<UserLookup | null>(null)
const [lookupLoading, setLookupLoading] = useState(false)
// Ban dialog
const [banDialogOpen, setBanDialogOpen] = useState(false)
const [banDuration, setBanDuration] = useState("24")
const [banReason, setBanReason] = useState("")
// Bulk selection
const [selectedNicknames, setSelectedNicknames] = useState<number[]>([])
const [selectedReports, setSelectedReports] = useState<number[]>([])
useEffect(() => {
fetchQueue()
}, [])
@ -46,9 +101,10 @@ export default function ModDashboardPage() {
}
try {
const [nicknamesRes, reportsRes] = await Promise.all([
const [nicknamesRes, reportsRes, statsRes] = await Promise.all([
fetch(`${getApiUrl()}/moderation/queue/nicknames`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${getApiUrl()}/moderation/queue/reports`, { headers: { Authorization: `Bearer ${token}` } })
fetch(`${getApiUrl()}/moderation/queue/reports`, { headers: { Authorization: `Bearer ${token}` } }),
fetch(`${getApiUrl()}/moderation/queue/stats`, { headers: { Authorization: `Bearer ${token}` } })
])
if (nicknamesRes.status === 403 || reportsRes.status === 403) {
@ -61,6 +117,7 @@ export default function ModDashboardPage() {
setPendingNicknames(await nicknamesRes.json())
setPendingReports(await reportsRes.json())
if (statsRes.ok) setStats(await statsRes.json())
} catch (err) {
console.error(err)
setError("Failed to load moderation queue")
@ -69,20 +126,86 @@ export default function ModDashboardPage() {
}
}
const handleUserLookup = async () => {
if (!lookupQuery.trim()) return
const token = localStorage.getItem("token")
if (!token) return
setLookupLoading(true)
try {
const res = await fetch(`${getApiUrl()}/moderation/users/lookup?query=${encodeURIComponent(lookupQuery)}`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
setLookupUser(await res.json())
} else {
setLookupUser(null)
alert("User not found")
}
} catch (e) {
console.error(e)
} finally {
setLookupLoading(false)
}
}
const handleBanUser = async () => {
if (!lookupUser) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/users/ban`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
user_id: lookupUser.id,
duration_hours: parseInt(banDuration),
reason: banReason
})
})
if (res.ok) {
setLookupUser({ ...lookupUser, is_active: false })
setBanDialogOpen(false)
setBanReason("")
}
} catch (e) {
console.error(e)
}
}
const handleUnbanUser = async () => {
if (!lookupUser) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/users/${lookupUser.id}/unban`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
setLookupUser({ ...lookupUser, is_active: true })
}
} catch (e) {
console.error(e)
}
}
const handleNicknameAction = async (id: number, action: "approve" | "reject") => {
const token = localStorage.getItem("token")
if (!token) return
try {
// Note: Updated backend uses PUT for nicknames
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error(`Failed to ${action}`)
// Remove from list
setPendingNicknames(prev => prev.filter(n => n.id !== id))
} catch (err) {
console.error(err)
@ -95,14 +218,12 @@ export default function ModDashboardPage() {
if (!token) return
try {
// Note: Updated backend uses PUT for reports
const res = await fetch(`${getApiUrl()}/moderation/reports/${id}/${action}`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error(`Failed to ${action}`)
setPendingReports(prev => prev.filter(r => r.id !== id))
} catch (err) {
console.error(err)
@ -110,6 +231,52 @@ export default function ModDashboardPage() {
}
}
const handleBulkNicknames = async (action: "approve" | "reject") => {
if (selectedNicknames.length === 0) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/nicknames/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedNicknames, action })
})
if (res.ok) {
setPendingNicknames(prev => prev.filter(n => !selectedNicknames.includes(n.id)))
setSelectedNicknames([])
}
} catch (e) {
console.error(e)
}
}
const handleBulkReports = async (action: "resolve" | "dismiss") => {
if (selectedReports.length === 0) return
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/moderation/reports/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ ids: selectedReports, action })
})
if (res.ok) {
setPendingReports(prev => prev.filter(r => !selectedReports.includes(r.id)))
setSelectedReports([])
}
} catch (e) {
console.error(e)
}
}
if (loading) return <div className="p-8 text-center">Loading dashboard...</div>
if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
@ -120,16 +287,51 @@ export default function ModDashboardPage() {
Moderator Dashboard
</h1>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.pending_nicknames}</p>
<p className="text-sm text-muted-foreground">Pending Nicknames</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.pending_reports}</p>
<p className="text-sm text-muted-foreground">Pending Reports</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-2xl font-bold">{stats.total_bans}</p>
<p className="text-sm text-muted-foreground">Banned Users</p>
</CardContent>
</Card>
</div>
)}
<Tabs defaultValue="reports">
<TabsList>
<TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger>
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
<TabsTrigger value="users">User Lookup</TabsTrigger>
</TabsList>
<TabsContent value="reports">
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>User Reports</CardTitle>
{selectedReports.length > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={() => handleBulkReports("resolve")}>
Resolve All ({selectedReports.length})
</Button>
<Button size="sm" variant="outline" onClick={() => handleBulkReports("dismiss")}>
Dismiss All
</Button>
</div>
)}
</CardHeader>
<CardContent>
{pendingReports.length === 0 ? (
@ -138,6 +340,17 @@ export default function ModDashboardPage() {
<div className="space-y-4">
{pendingReports.map(report => (
<div key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedReports.includes(report.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedReports([...selectedReports, report.id])
} else {
setSelectedReports(selectedReports.filter(id => id !== report.id))
}
}}
/>
<div>
<div className="flex items-center gap-2 mb-1">
<Badge variant="destructive" className="uppercase text-[10px]">
@ -146,12 +359,13 @@ export default function ModDashboardPage() {
<span className="font-semibold">{report.entity_type} #{report.entity_id}</span>
</div>
{report.details && (
<p className="text-sm italic text-muted-foreground mb-2">"{report.details}"</p>
<p className="text-sm italic text-muted-foreground mb-2">&quot;{report.details}&quot;</p>
)}
<p className="text-xs text-muted-foreground">
Reported by User #{report.user_id} {new Date(report.created_at).toLocaleString()}
</p>
</div>
</div>
<div className="flex gap-2 items-center">
<Button
size="sm"
@ -159,7 +373,7 @@ export default function ModDashboardPage() {
className="bg-red-600 hover:bg-red-700"
onClick={() => handleReportAction(report.id, "resolve")}
>
<Check className="h-4 w-4 mr-1" /> Resolve (Ban/Delete)
<Check className="h-4 w-4 mr-1" /> Resolve
</Button>
<Button
size="sm"
@ -179,8 +393,18 @@ export default function ModDashboardPage() {
<TabsContent value="nicknames">
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Pending Nicknames</CardTitle>
{selectedNicknames.length > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="default" onClick={() => handleBulkNicknames("approve")}>
<CheckCircle className="h-4 w-4 mr-1" /> Approve All ({selectedNicknames.length})
</Button>
<Button size="sm" variant="outline" onClick={() => handleBulkNicknames("reject")}>
Reject All
</Button>
</div>
)}
</CardHeader>
<CardContent>
{pendingNicknames.length === 0 ? (
@ -189,14 +413,23 @@ export default function ModDashboardPage() {
<div className="space-y-4">
{pendingNicknames.map((item) => (
<div key={item.id} className="flex items-center justify-between border p-4 rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={selectedNicknames.includes(item.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedNicknames([...selectedNicknames, item.id])
} else {
setSelectedNicknames(selectedNicknames.filter(id => id !== item.id))
}
}}
/>
<div>
<p className="font-bold text-lg">"{item.nickname}"</p>
<p className="font-bold text-lg">&quot;{item.nickname}&quot;</p>
<p className="text-sm text-muted-foreground">
Performance #{item.performance_id} User #{item.suggested_by}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(item.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
@ -223,7 +456,139 @@ export default function ModDashboardPage() {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle>User Lookup</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by email or username..."
value={lookupQuery}
onChange={(e) => setLookupQuery(e.target.value)}
className="pl-9"
onKeyDown={(e) => e.key === "Enter" && handleUserLookup()}
/>
</div>
<Button onClick={handleUserLookup} disabled={lookupLoading}>
{lookupLoading ? "Searching..." : "Search"}
</Button>
</div>
{lookupUser && (
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="font-bold text-lg">{lookupUser.username || "No username"}</p>
<p className="text-sm text-muted-foreground">{lookupUser.email}</p>
<div className="flex gap-2 mt-2">
<Badge>{lookupUser.role}</Badge>
{lookupUser.is_active ? (
<Badge variant="outline" className="text-green-600 border-green-600">Active</Badge>
) : (
<Badge variant="destructive">Banned</Badge>
)}
{lookupUser.email_verified && (
<Badge variant="outline">Verified</Badge>
)}
</div>
</div>
{lookupUser.is_active ? (
<Button
variant="destructive"
size="sm"
onClick={() => setBanDialogOpen(true)}
>
<Ban className="h-4 w-4 mr-1" /> Ban User
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleUnbanUser}
>
<UserCheck className="h-4 w-4 mr-1" /> Unban
</Button>
)}
</div>
<div className="grid grid-cols-5 gap-4 text-center">
<div>
<p className="text-2xl font-bold">{lookupUser.stats.ratings}</p>
<p className="text-xs text-muted-foreground">Ratings</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.reviews}</p>
<p className="text-xs text-muted-foreground">Reviews</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.comments}</p>
<p className="text-xs text-muted-foreground">Comments</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.attendances}</p>
<p className="text-xs text-muted-foreground">Shows</p>
</div>
<div>
<p className="text-2xl font-bold">{lookupUser.stats.reports_submitted}</p>
<p className="text-xs text-muted-foreground">Reports</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Ban Dialog */}
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
Banning <strong>{lookupUser?.email}</strong>
</p>
<div className="space-y-2">
<Label>Ban Duration</Label>
<Select value={banDuration} onValueChange={setBanDuration}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 hour</SelectItem>
<SelectItem value="24">24 hours</SelectItem>
<SelectItem value="168">7 days</SelectItem>
<SelectItem value="720">30 days</SelectItem>
<SelectItem value="0">Permanent</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason</Label>
<Textarea
placeholder="Reason for ban..."
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBanDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleBanUser}>
<Ban className="h-4 w-4 mr-1" /> Confirm Ban
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -1,4 +1,5 @@
import { ActivityFeed } from "@/components/feed/activity-feed"
import { XPLeaderboard } from "@/components/gamification/xp-leaderboard"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
@ -7,22 +8,26 @@ import { getApiUrl } from "@/lib/api-config"
interface Show {
id: number
slug?: string
date: string
venue?: {
id: number
name: string
slug?: string
city?: string
state?: string
}
tour?: {
id: number
name: string
slug?: string
}
}
interface Song {
id: number
title: string
slug?: string
performance_count?: number
avg_rating?: number
}
@ -86,13 +91,13 @@ export default async function Home() {
<p className="max-w-[600px] text-lg text-muted-foreground">
The ultimate community archive for Goose history.
<br />
Discover shows, rate performances, and connect with fans.
Discover shows, rate performances, and find the best jams.
</p>
<div className="flex gap-4">
<Link href="/leaderboards">
<Link href="/performances">
<Button size="lg" className="gap-2">
<Trophy className="h-4 w-4" />
View Leaderboards
Top Performances
</Button>
</Link>
<Link href="/shows">
@ -117,7 +122,7 @@ export default async function Home() {
{recentShows.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{recentShows.map((show) => (
<Link key={show.id} href={`/shows/${show.id}`}>
<Link key={show.id} href={`/shows/${show.slug || show.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="font-semibold">
@ -174,7 +179,7 @@ export default async function Home() {
{topSongs.map((song, idx) => (
<li key={song.id}>
<Link
href={`/songs/${song.id}`}
href={`/songs/${song.slug || song.id}`}
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
>
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
@ -202,7 +207,7 @@ export default async function Home() {
</section>
{/* Activity Feed */}
<section className="space-y-4 lg:col-span-2">
<section className="space-y-4 lg:col-span-1">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Recent Activity</h2>
<Link href="/leaderboards" className="text-sm text-muted-foreground hover:underline flex items-center gap-1">
@ -211,6 +216,11 @@ export default async function Home() {
</div>
<ActivityFeed />
</section>
{/* XP Leaderboard */}
<section className="space-y-4 lg:col-span-1">
<XPLeaderboard />
</section>
</div>
{/* Quick Links */}

View file

@ -1,12 +1,15 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, Music2 } from "lucide-react"
import { ArrowLeft, Calendar, MapPin, ChevronRight, ChevronLeft, Music, Clock, Hash, Play, ExternalLink, Sparkles, Youtube } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { getApiUrl } from "@/lib/api-config"
import { CommentSection } from "@/components/social/comment-section"
import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper"
import { EntityRating } from "@/components/social/entity-rating"
import { Badge } from "@/components/ui/badge"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getPerformance(id: string) {
try {
@ -27,111 +30,350 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
notFound()
}
const showDate = new Date(performance.show.date)
const formattedDate = showDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
{/* Hero Banner - Distinct from Song page */}
<div className="relative -mx-4 -mt-4 px-4 pt-6 pb-8 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent border-b">
{/* Breadcrumbs */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-4">
<Link href={`/shows/${performance.show.id}`} className="hover:text-foreground transition-colors">
Show
</Link>
<ChevronRight className="h-4 w-4" />
<Link
href={`/songs/${performance.song.id}`}
className="hover:text-foreground transition-colors"
>
{performance.song.title}
</Link>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground font-medium">This Performance</span>
</nav>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div className="flex items-start gap-4">
<Link href={`/shows/${performance.show.id}`}>
<Button variant="ghost" size="icon">
<Button variant="outline" size="icon" className="mt-1">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{performance.song.title}
{performance.nicknames.length > 0 && (
<span className="text-xl text-muted-foreground font-normal">
"{performance.nicknames[0].nickname}"
</span>
{/* Context Badge */}
<div className="flex items-center gap-2 mb-2">
<Badge variant="secondary" className="bg-primary/10 text-primary border-primary/20">
<Sparkles className="h-3 w-3 mr-1" />
Specific Performance
</Badge>
{performance.set_name && (
<Badge variant="outline">{performance.set_name}</Badge>
)}
{performance.position && (
<Badge variant="outline" className="font-mono">
#{performance.position}
</Badge>
)}
</div>
{/* Song Title (links to song page) */}
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">
<Link
href={`/songs/${performance.song.id}`}
className="hover:text-primary transition-colors"
>
{performance.song.title}
</Link>
</h1>
<div className="flex items-center gap-4 text-muted-foreground mt-1">
<Link href={`/shows/${performance.show.id}`} className="hover:underline flex items-center gap-1">
{/* Nicknames */}
{performance.nicknames && performance.nicknames.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{performance.nicknames.map((nick: any) => (
<span
key={nick.id}
className="text-lg italic text-yellow-600 dark:text-yellow-400"
title={nick.description}
>
"{nick.nickname}"
</span>
))}
</div>
)}
{/* Show Context - THE KEY DIFFERENTIATOR */}
<div className="mt-4 p-3 rounded-lg bg-background/80 border inline-flex flex-col gap-1">
<Link
href={`/shows/${performance.show.id}`}
className="font-semibold text-lg hover:text-primary transition-colors flex items-center gap-2"
>
<Calendar className="h-4 w-4" />
{new Date(performance.show.date).toLocaleDateString()}
{formattedDate}
</Link>
{performance.show.venue && (
<div className="flex items-center gap-1">
<Link
href={`/venues/${performance.show.venue.id}`}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 transition-colors"
>
<MapPin className="h-4 w-4" />
{performance.show.venue.name}
</div>
{performance.show.venue.name}, {performance.show.venue.city}
{performance.show.venue.state && `, ${performance.show.venue.state}`}
</Link>
)}
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Performance Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="flex flex-col">
<span className="text-xs text-muted-foreground uppercase font-bold">Times Played</span>
<span className="text-2xl font-bold">{performance.times_played}</span>
{/* Rating Box */}
<div className="md:text-right">
<div className="text-xs uppercase font-medium text-muted-foreground mb-2">
Rate This Version
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground uppercase font-bold">Gap</span>
<span className="text-2xl font-bold">{performance.gap}</span>
<SocialWrapper type="ratings">
<EntityRating entityType="performance" entityId={performance.id} />
</SocialWrapper>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground uppercase font-bold">Set</span>
<span className="text-2xl font-bold">{performance.set_name || "-"}</span>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t">
{/* Video Section - Show when performance has a video */}
{performance.youtube_link && (
<Card className="border-2 border-red-500/20 bg-gradient-to-br from-red-500/5 to-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5 text-red-500" />
Video
</CardTitle>
</CardHeader>
<CardContent>
<YouTubeEmbed
url={performance.youtube_link}
title={`${performance.song.title} - ${formattedDate}`}
/>
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
<div className="flex flex-col gap-6">
{/* Version Navigation - Prominent */}
<Card className="border-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Version Timeline
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4">
{performance.previous_performance_id ? (
<Link href={`/performances/${performance.previous_performance_id}`}>
<Button variant="outline" size="sm">
&larr; Previous Version
<Link
href={`/performances/${performance.previous_performance_id}`}
className="flex-1"
>
<Button variant="outline" className="w-full justify-start gap-2">
<ChevronLeft className="h-4 w-4" />
<div className="text-left">
<div className="text-xs text-muted-foreground">Previous</div>
<div className="font-medium">Earlier Version</div>
</div>
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
&larr; First Time Played
</Button>
<div className="flex-1 p-3 rounded-md bg-muted/50 text-center text-sm text-muted-foreground">
🎉 Debut Performance
</div>
)}
<div className="text-center px-4">
<div className="text-2xl font-bold">#{performance.times_played || "?"}</div>
<div className="text-xs text-muted-foreground">of all time</div>
</div>
{performance.next_performance_id ? (
<Link href={`/performances/${performance.next_performance_id}`}>
<Button variant="outline" size="sm">
Next Version &rarr;
<Link
href={`/performances/${performance.next_performance_id}`}
className="flex-1"
>
<Button variant="outline" className="w-full justify-end gap-2">
<div className="text-right">
<div className="text-xs text-muted-foreground">Next</div>
<div className="font-medium">Later Version</div>
</div>
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
Last Time Played &rarr;
</Button>
)}
</div>
{performance.notes && (
<div className="mt-4 pt-4 border-t">
<h3 className="font-medium text-sm mb-1">Notes</h3>
<p className="text-sm text-muted-foreground">{performance.notes}</p>
<div className="flex-1 p-3 rounded-md bg-muted/50 text-center text-sm text-muted-foreground">
Most Recent 🕐
</div>
)}
{performance.segue && (
<div className="text-sm font-medium text-primary mt-2">
Segue into next song &gt;
</div>
)}
</CardContent>
</Card>
{/* Notes & Details */}
{(performance.notes || performance.segue || performance.track_url) && (
<Card>
<CardHeader>
<CardTitle>About This Performance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{performance.notes && (
<div>
<h3 className="font-medium text-sm mb-1 text-muted-foreground">Notes</h3>
<p className="text-foreground">{performance.notes}</p>
</div>
)}
{performance.segue && (
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 text-primary">
<Music className="h-4 w-4" />
<span className="font-medium">Segues into next song </span>
</div>
)}
{performance.track_url && (
<a
href={performance.track_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-primary hover:underline"
>
<Play className="h-4 w-4" />
Listen to this performance
<ExternalLink className="h-3 w-3" />
</a>
)}
</CardContent>
</Card>
)}
{/* Comments */}
<SocialWrapper type="comments">
<CommentSection entityType="performance" entityId={performance.id} />
</SocialWrapper>
{/* Reviews */}
<SocialWrapper type="reviews">
<EntityReviews entityType="performance" entityId={performance.id} />
<EntityReviews
entityType="performance"
entityId={performance.id}
entityName={`${performance.song.title} - ${formattedDate}`}
/>
</SocialWrapper>
</div>
<div className="flex flex-col gap-6">
{/* Could add "Other performances of this song" or "Other songs from this show" here */}
{/* Sidebar */}
<div className="flex flex-col gap-4">
{/* Quick Stats */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Performance Stats
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Set Position</span>
<span className="font-mono font-bold">
{performance.set_name || "—"} #{performance.position || "—"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Gap Since Last</span>
<span className="font-mono font-bold">
{performance.gap !== undefined ? `${performance.gap} shows` : "—"}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Times Played</span>
<span className="font-mono font-bold">
{performance.times_played || "—"}
</span>
</div>
</CardContent>
</Card>
{/* Top Rated Versions */}
{performance.other_performances && performance.other_performances.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-yellow-500" />
Top Rated Versions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{performance.other_performances.slice(0, 5).map((perf: any) => (
<Link
key={perf.id}
href={`/performances/${perf.slug || perf.id}`}
className="flex items-start justify-between group"
>
<div className="flex flex-col">
<span className="font-medium group-hover:text-primary transition-colors text-sm">
{new Date(perf.show_date).toLocaleDateString()}
</span>
<span className="text-xs text-muted-foreground">
{perf.venue_name}
</span>
</div>
{perf.avg_rating > 0 && (
<div className="flex items-center gap-1 bg-secondary px-1.5 py-0.5 rounded text-xs font-mono">
<span className="text-yellow-500 text-[10px]"></span>
<span>{perf.avg_rating.toFixed(1)}</span>
</div>
)}
</Link>
))}
<Link
href={`/songs/${performance.song.id}`}
className="block text-xs text-center text-muted-foreground hover:text-primary pt-2 border-t mt-2"
>
View all {performance.other_performances.length + 1} versions
</Link>
</CardContent>
</Card>
)}
{/* Quick Links */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Related Pages
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Link
href={`/songs/${performance.song.id}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
>
<Music className="h-4 w-4 text-muted-foreground" />
<span>All versions of {performance.song.title}</span>
</Link>
<Link
href={`/shows/${performance.show.id}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
>
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Full show setlist</span>
</Link>
{performance.show.venue && (
<Link
href={`/venues/${performance.show.venue.id}`}
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted transition-colors"
>
<MapPin className="h-4 w-4 text-muted-foreground" />
<span>{performance.show.venue.name}</span>
</Link>
)}
</CardContent>
</Card>
</div>
</div>
</div>

View file

@ -0,0 +1,142 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Trophy, Star, Calendar, MapPin, ExternalLink } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
interface TopPerformance {
performance: {
id: number
position: number
set_name: string
notes?: string
youtube_link?: string
}
song: {
id: number
title: string
}
show: {
id: number
date: string
}
venue: {
id: number
name: string
city: string
state?: string
}
avg_score: number
rating_count: number
}
async function getTopPerformances(): Promise<TopPerformance[]> {
try {
const res = await fetch(`${getApiUrl()}/leaderboards/performances/top?limit=50`, {
cache: 'no-store'
})
if (!res.ok) return []
return res.json()
} catch (e) {
console.error('Failed to fetch top performances:', e)
return []
}
}
export default async function PerformancesPage() {
const performances = await getTopPerformances()
return (
<div className="flex flex-col gap-6">
<div className="text-center py-8">
<h1 className="text-4xl font-bold tracking-tight flex items-center justify-center gap-3">
<Trophy className="h-10 w-10 text-yellow-500" />
Top Performances
</h1>
<p className="text-muted-foreground mt-2 text-lg">
The highest-rated jams as voted by the community
</p>
</div>
{performances.length > 0 ? (
<div className="space-y-3">
{performances.map((item, index) => (
<Link key={item.performance.id} href={`/performances/${item.performance.id}`}>
<Card className={`hover:bg-accent/50 transition-colors cursor-pointer ${index === 0 ? 'border-2 border-yellow-500 bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/10 dark:to-orange-900/10' :
index === 1 ? 'border-gray-300 dark:border-gray-600' :
index === 2 ? 'border-amber-600 dark:border-amber-700' : ''
}`}>
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Rank */}
<div className="text-3xl font-bold w-12 text-center shrink-0">
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : <span className="text-muted-foreground">{index + 1}</span>}
</div>
{/* Song & Show Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-lg truncate">{item.song.title}</span>
{item.performance.youtube_link && (
<Badge variant="outline" className="text-red-500 border-red-300">
<ExternalLink className="h-3 w-3 mr-1" />
Video
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(item.show.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{item.venue.name}
{item.venue.city && ` - ${item.venue.city}`}
{item.venue.state && `, ${item.venue.state}`}
</span>
</div>
{item.performance.notes && (
<p className="text-xs text-primary mt-1 italic">
{item.performance.notes}
</p>
)}
</div>
{/* Rating */}
<div className="text-right shrink-0">
<div className="flex items-center gap-1 text-yellow-600">
<Star className="h-5 w-5 fill-current" />
<span className="font-bold text-xl">{item.avg_score.toFixed(1)}</span>
</div>
<div className="text-xs text-muted-foreground">
{item.rating_count} {item.rating_count === 1 ? 'rating' : 'ratings'}
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="p-12 text-center">
<CardContent>
<Trophy className="h-16 w-16 mx-auto mb-4 text-muted-foreground/30" />
<h2 className="text-xl font-bold">No rated performances yet</h2>
<p className="text-muted-foreground mt-2">
Be the first to rate a performance! Browse shows and rate your favorite jams.
</p>
<Link href="/shows" className="text-primary hover:underline mt-4 inline-block">
Browse Shows
</Link>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -1 +1,192 @@
export default function PrivacyPage() { return <div className="max-w-prose mx-auto"><h1 className="text-3xl font-bold mb-4">Privacy Policy</h1><p>We respect your privacy. We do not sell your data.</p></div> }
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Privacy Policy - Elmeg",
description: "Privacy Policy for Elmeg, a community archive platform for live music fans.",
}
export default function PrivacyPage() {
return (
<div className="max-w-3xl mx-auto py-8">
<h1 className="text-4xl font-bold mb-2">Privacy Policy</h1>
<p className="text-muted-foreground mb-8">Last updated: December 21, 2024</p>
<div className="prose prose-neutral dark:prose-invert max-w-none space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
<p className="text-muted-foreground leading-relaxed">
Elmeg ("we," "our," or "us") respects your privacy and is committed to protecting your
personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard
your information when you use our Service.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">2. Information We Collect</h2>
<div className="text-muted-foreground leading-relaxed space-y-4">
<div>
<h3 className="text-lg font-medium text-foreground mb-2">Information You Provide</h3>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Account Information:</strong> Email address, username, and password when you register</li>
<li><strong>Profile Information:</strong> Display name, bio, and avatar (optional)</li>
<li><strong>User Content:</strong> Reviews, comments, ratings, and other contributions you make</li>
<li><strong>Communications:</strong> Messages you send to us for support</li>
</ul>
</div>
<div>
<h3 className="text-lg font-medium text-foreground mb-2">Information Collected Automatically</h3>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Log Data:</strong> IP address, browser type, pages visited, and access times</li>
<li><strong>Device Information:</strong> Device type, operating system, and unique device identifiers</li>
<li><strong>Cookies:</strong> Session cookies for authentication and preferences</li>
</ul>
</div>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Information</h2>
<div className="text-muted-foreground leading-relaxed">
<p className="mb-3">We use your information to:</p>
<ul className="list-disc pl-6 space-y-2">
<li>Provide, maintain, and improve the Service</li>
<li>Create and manage your account</li>
<li>Process your ratings, reviews, and other contributions</li>
<li>Send transactional emails (account verification, password resets, security alerts)</li>
<li>Respond to your inquiries and support requests</li>
<li>Detect and prevent fraud, abuse, and security issues</li>
<li>Analyze usage patterns to improve user experience</li>
<li>Comply with legal obligations</li>
</ul>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">4. Information Sharing</h2>
<div className="text-muted-foreground leading-relaxed space-y-3">
<p>
<strong className="text-foreground">We do not sell your personal data.</strong> We may
share your information only in the following circumstances:
</p>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Public Content:</strong> Reviews, comments, and ratings are visible to other users</li>
<li><strong>Service Providers:</strong> Third-party vendors who assist in operating the Service (e.g., email delivery, hosting)</li>
<li><strong>Legal Requirements:</strong> When required by law or to protect our rights and safety</li>
<li><strong>Business Transfers:</strong> In connection with a merger, acquisition, or sale of assets</li>
</ul>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">5. Data Security</h2>
<p className="text-muted-foreground leading-relaxed">
We implement industry-standard security measures to protect your personal data, including:
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground mt-3">
<li>Encryption of data in transit (HTTPS/TLS)</li>
<li>Secure password hashing</li>
<li>Regular security audits and updates</li>
<li>Access controls and authentication</li>
</ul>
<p className="text-muted-foreground leading-relaxed mt-3">
However, no method of transmission over the Internet is 100% secure, and we cannot
guarantee absolute security.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">6. Data Retention</h2>
<p className="text-muted-foreground leading-relaxed">
We retain your personal data for as long as your account is active or as needed to provide
the Service. We may retain certain information as required by law or for legitimate business
purposes (e.g., resolving disputes, enforcing agreements). If you delete your account, we
will delete or anonymize your personal data within 30 days, except where retention is required.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">7. Your Rights</h2>
<div className="text-muted-foreground leading-relaxed">
<p className="mb-3">Depending on your jurisdiction, you may have the right to:</p>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Access:</strong> Request a copy of your personal data</li>
<li><strong>Correction:</strong> Request correction of inaccurate data</li>
<li><strong>Deletion:</strong> Request deletion of your personal data</li>
<li><strong>Portability:</strong> Request transfer of your data to another service</li>
<li><strong>Objection:</strong> Object to certain processing of your data</li>
<li><strong>Withdrawal:</strong> Withdraw consent where processing is based on consent</li>
</ul>
<p className="mt-3">
To exercise these rights, contact us at{" "}
<a href="mailto:privacy@elmeg.xyz" className="text-primary hover:underline">
privacy@elmeg.xyz
</a>.
</p>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">8. Cookies</h2>
<div className="text-muted-foreground leading-relaxed">
<p className="mb-3">We use the following types of cookies:</p>
<ul className="list-disc pl-6 space-y-2">
<li><strong>Essential Cookies:</strong> Required for authentication and security</li>
<li><strong>Preference Cookies:</strong> Remember your settings (e.g., theme preference)</li>
</ul>
<p className="mt-3">
We do not use advertising or tracking cookies. You can control cookies through your
browser settings, though disabling essential cookies may affect Service functionality.
</p>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">9. Children's Privacy</h2>
<p className="text-muted-foreground leading-relaxed">
The Service is not intended for children under 13. We do not knowingly collect personal
data from children under 13. If you believe we have collected such data, please contact
us immediately, and we will take steps to delete it.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">10. International Data Transfers</h2>
<p className="text-muted-foreground leading-relaxed">
Your data may be processed in countries other than your own. We take appropriate
safeguards to ensure your data receives adequate protection in accordance with this
Privacy Policy and applicable law.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">11. Changes to This Policy</h2>
<p className="text-muted-foreground leading-relaxed">
We may update this Privacy Policy from time to time. We will notify you of material
changes via email or through the Service. Your continued use after such changes
constitutes acceptance of the updated policy.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
<div className="text-muted-foreground leading-relaxed">
<p>If you have questions about this Privacy Policy or our data practices, contact us at:</p>
<div className="mt-4 p-4 bg-muted/50 rounded-lg">
<p><strong className="text-foreground">Email:</strong>{" "}
<a href="mailto:privacy@elmeg.xyz" className="text-primary hover:underline">
privacy@elmeg.xyz
</a>
</p>
<p className="mt-2"><strong className="text-foreground">General Support:</strong>{" "}
<a href="mailto:support@elmeg.xyz" className="text-primary hover:underline">
support@elmeg.xyz
</a>
</p>
</div>
</div>
</section>
</div>
</div>
)
}

View file

@ -53,7 +53,7 @@ export default function PublicProfilePage({ params }: { params: Promise<{ id: st
try {
// Public fetch - no auth header needed strictly, but maybe good practice if protected
const token = localStorage.getItem("token")
const headers = token ? { Authorization: `Bearer ${token}` } : {}
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
const userRes = await fetch(`${getApiUrl()}/users/${id}`, { headers })
if (!userRes.ok) throw new Error("User not found")

View file

@ -11,6 +11,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { UserAttendanceList } from "@/components/profile/user-attendance-list"
import { UserReviewsList } from "@/components/profile/user-reviews-list"
import { UserGroupsList } from "@/components/profile/user-groups-list"
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
import { AttendanceSummary } from "@/components/profile/attendance-summary"
import { LevelProgressCard } from "@/components/gamification/level-progress"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { motion } from "framer-motion"
@ -176,10 +179,37 @@ export default function ProfilePage() {
</TabsList>
<TabsContent value="overview" className="space-y-6">
{/* Level Progress */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<LevelProgressCard />
</motion.div>
{/* Attendance Summary */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<AttendanceSummary />
</motion.div>
{/* Chase Songs */}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.2 }}
>
<ChaseSongsList />
</motion.div>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0.2 }}
>
<Card>
<CardHeader>

View file

@ -0,0 +1,171 @@
"use client"
import { Suspense, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, CheckCircle, XCircle, Loader2 } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
function ResetPasswordContent() {
const searchParams = useSearchParams()
const token = searchParams.get("token")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (password !== confirmPassword) {
setError("Passwords don't match")
return
}
if (password.length < 8) {
setError("Password must be at least 8 characters")
return
}
setLoading(true)
try {
const res = await fetch(`${getApiUrl()}/auth/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, new_password: password })
})
if (res.ok) {
setSuccess(true)
} else {
const data = await res.json()
setError(data.detail || "Failed to reset password")
}
} catch (_) {
setError("An error occurred. Please try again.")
} finally {
setLoading(false)
}
}
if (!token) {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<XCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<CardTitle>Invalid Link</CardTitle>
<CardDescription>
This password reset link is invalid or expired.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button asChild>
<Link href="/forgot-password">Request New Link</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
if (success) {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<CardTitle>Password Reset!</CardTitle>
<CardDescription>
Your password has been successfully updated.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Button asChild>
<Link href="/login">Continue to Login</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<Lock className="h-12 w-12 text-primary mx-auto mb-4" />
<CardTitle>Set New Password</CardTitle>
<CardDescription>
Enter your new password below.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Resetting..." : "Reset Password"}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
function LoadingFallback() {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardContent className="py-16 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
)
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ResetPasswordContent />
</Suspense>
)
}

View file

@ -19,8 +19,9 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false)
useEffect(() => {
if (user?.bio) {
setBio(user.bio)
// Bio might be in extended user response - check dynamically
if (user && 'bio' in user && typeof (user as Record<string, unknown>).bio === 'string') {
setBio((user as Record<string, unknown>).bio as string)
}
}, [user])
@ -98,7 +99,7 @@ export default function SettingsPage() {
<Switch
id="wiki-mode"
checked={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ wiki_mode: checked })}
onChange={(e) => updatePreferences({ wiki_mode: e.target.checked })}
/>
</div>
@ -113,7 +114,7 @@ export default function SettingsPage() {
id="show-ratings"
checked={preferences.show_ratings}
disabled={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ show_ratings: checked })}
onChange={(e) => updatePreferences({ show_ratings: e.target.checked })}
/>
</div>
@ -128,7 +129,7 @@ export default function SettingsPage() {
id="show-comments"
checked={preferences.show_comments}
disabled={preferences.wiki_mode}
onCheckedChange={(checked) => updatePreferences({ show_comments: checked })}
onChange={(e) => updatePreferences({ show_comments: e.target.checked })}
/>
</div>
</CardContent>

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink } from "lucide-react"
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink, Youtube } from "lucide-react"
import Link from "next/link"
import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating"
@ -13,6 +13,8 @@ import { notFound } from "next/navigation"
import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
import { EntityReviews } from "@/components/reviews/entity-reviews"
import { getApiUrl } from "@/lib/api-config"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
async function getShow(id: string) {
try {
@ -136,6 +138,20 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
</div>
)}
{show.youtube_link && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5 text-red-500" />
Video
</CardTitle>
</CardHeader>
<CardContent>
<YouTubeEmbed url={show.youtube_link} title={`${show.date?.split('T')[0]} - ${show.venue?.name}`} />
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6">
<Card>
@ -157,13 +173,30 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<div className="flex items-center gap-3">
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span>
<div className="font-medium flex items-center gap-2">
{perf.track_url ? (
<a href={perf.track_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:underline group/link">
<PlayCircle className="h-3.5 w-3.5 text-primary opacity-70 group-hover/link:opacity-100" />
<span>{perf.song?.title || "Unknown Song"}</span>
<Link
href={`/performances/${perf.slug || perf.id}`}
className="hover:text-primary hover:underline transition-colors"
>
{perf.song?.title || "Unknown Song"}
</Link>
{perf.track_url && (
<a
href={perf.track_url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
title="Listen"
>
<PlayCircle className="h-3.5 w-3.5" />
</a>
) : (
<span>{perf.song?.title || "Unknown Song"}</span>
)}
{perf.youtube_link && (
<span
className="text-red-500"
title="Video available"
>
<Youtube className="h-3.5 w-3.5" />
</span>
)}
{perf.segue && <span className="ml-1 text-muted-foreground">&gt;</span>}
</div>
@ -196,6 +229,13 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
compact={true}
/>
</SocialWrapper>
{/* Mark Caught (for chase songs) */}
<MarkCaughtButton
songId={perf.song?.id}
songTitle={perf.song?.title || "Song"}
showId={show.id}
/>
</div>
{perf.notes && (
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5">
@ -233,7 +273,9 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{show.venue.name}</span>
<Link href={`/venues/${show.venue.slug || show.venue.id}`} className="font-medium hover:underline hover:text-primary">
{show.venue.name}
</Link>
</div>
<p className="text-sm text-muted-foreground pl-6">
{show.venue.city}, {show.venue.state} {show.venue.country}

View file

@ -1,17 +1,18 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, Suspense } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { Calendar, MapPin } from "lucide-react"
import { Calendar, MapPin, Loader2, Youtube } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams } from "next/navigation"
interface Show {
id: number
slug?: string
date: string
youtube_link?: string
venue: {
id: number
name: string
@ -20,7 +21,7 @@ interface Show {
}
}
export default function ShowsPage() {
function ShowsContent() {
const searchParams = useSearchParams()
const year = searchParams.get("year")
@ -83,8 +84,13 @@ export default function ShowsPage() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{shows.map((show) => (
<Link key={show.id} href={`/shows/${show.id}`} className="block group">
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50">
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group">
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative">
{show.youtube_link && (
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">
<Youtube className="h-4 w-4" />
</div>
)}
<CardHeader>
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />
@ -111,3 +117,19 @@ export default function ShowsPage() {
</div>
)
}
function LoadingFallback() {
return (
<div className="container py-10 flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
export default function ShowsPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<ShowsContent />
</Suspense>
)
}

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, PlayCircle, History, Calendar } from "lucide-react"
import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge"
@ -12,6 +12,7 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper"
import { PerformanceList } from "@/components/songs/performance-list"
import { SongEvolutionChart } from "@/components/songs/song-evolution-chart"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getSong(id: string) {
try {
@ -24,6 +25,15 @@ async function getSong(id: string) {
}
}
// Get top rated performances for "Heady Version" leaderboard
function getHeadyVersions(performances: any[]) {
if (!performances || performances.length === 0) return []
return [...performances]
.filter(p => p.avg_rating && p.rating_count > 0)
.sort((a, b) => b.avg_rating - a.avg_rating)
.slice(0, 5)
}
export default async function SongDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const song = await getSong(id)
@ -32,6 +42,9 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
notFound()
}
const headyVersions = getHeadyVersions(song.performances || [])
const topPerformance = headyVersions[0]
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@ -100,6 +113,89 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
</Card>
</div>
{/* Heady Version Section */}
{headyVersions.length > 0 && (
<Card className="border-2 border-yellow-500/20 bg-gradient-to-br from-yellow-50/50 to-orange-50/50 dark:from-yellow-900/10 dark:to-orange-900/10">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-700 dark:text-yellow-400">
<Trophy className="h-6 w-6" />
Heady Version Leaderboard
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Top Performance with YouTube */}
{topPerformance && (
<div className="grid md:grid-cols-2 gap-4">
{topPerformance.youtube_link ? (
<YouTubeEmbed url={topPerformance.youtube_link} />
) : song.youtube_link ? (
<YouTubeEmbed url={song.youtube_link} />
) : (
<div className="aspect-video bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Youtube className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p className="text-sm">No video available</p>
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Badge className="bg-yellow-500 text-yellow-900">🏆 #1 Heady</Badge>
</div>
<p className="font-bold text-lg">
{topPerformance.show?.date ? new Date(topPerformance.show.date).toLocaleDateString() : "Unknown Date"}
</p>
<p className="text-muted-foreground">
{topPerformance.show?.venue?.name || "Unknown Venue"}
</p>
<div className="flex items-center gap-1 text-yellow-600">
<Star className="h-5 w-5 fill-current" />
<span className="font-bold text-xl">{topPerformance.avg_rating?.toFixed(1)}</span>
<span className="text-sm text-muted-foreground">({topPerformance.rating_count} ratings)</span>
</div>
</div>
</div>
)}
{/* Leaderboard List */}
<div className="space-y-2">
{headyVersions.map((perf, index) => (
<div
key={perf.id}
className={`flex items-center justify-between p-3 rounded-lg ${index === 0 ? 'bg-yellow-100/50 dark:bg-yellow-900/20' : 'bg-background/50'
}`}
>
<div className="flex items-center gap-3">
<span className="w-6 text-center font-bold">
{index === 0 ? "🥇" : index === 1 ? "🥈" : index === 2 ? "🥉" : `${index + 1}.`}
</span>
<div>
<p className="font-medium">
{perf.show?.date ? new Date(perf.show.date).toLocaleDateString() : "Unknown"}
</p>
<p className="text-sm text-muted-foreground">
{perf.show?.venue?.name || "Unknown Venue"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{perf.youtube_link && (
<a href={perf.youtube_link} target="_blank" rel="noopener noreferrer">
<Youtube className="h-4 w-4 text-red-500" />
</a>
)}
<div className="text-right">
<span className="font-bold">{perf.avg_rating?.toFixed(1)}</span>
<span className="text-xs text-muted-foreground ml-1">({perf.rating_count})</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
<SongEvolutionChart performances={song.performances || []} />
{/* Performance List Component (Handles Client Sorting) */}

View file

@ -9,6 +9,7 @@ import { Music } from "lucide-react"
interface Song {
id: number
title: string
slug?: string
original_artist?: string
}
@ -41,7 +42,7 @@ export default function SongsPage() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{songs.map((song) => (
<Link key={song.id} href={`/songs/${song.id}`}>
<Link key={song.id} href={`/songs/${song.slug || song.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">

View file

@ -1 +1,151 @@
export default function TermsPage() { return <div className="max-w-prose mx-auto"><h1 className="text-3xl font-bold mb-4">Terms of Service</h1><p>Welcome to Elmeg. By using this site, you agree to be excellent to each other.</p></div> }
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Terms of Service - Elmeg",
description: "Terms of Service for Elmeg, a community archive platform for live music fans.",
}
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto py-8">
<h1 className="text-4xl font-bold mb-2">Terms of Service</h1>
<p className="text-muted-foreground mb-8">Last updated: December 21, 2024</p>
<div className="prose prose-neutral dark:prose-invert max-w-none space-y-8">
<section>
<h2 className="text-2xl font-semibold mb-4">1. Acceptance of Terms</h2>
<p className="text-muted-foreground leading-relaxed">
By accessing or using Elmeg ("the Service"), you agree to be bound by these Terms of Service.
If you do not agree to these terms, please do not use the Service. We reserve the right to
update these terms at any time, and your continued use of the Service constitutes acceptance
of any changes.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">2. Description of Service</h2>
<p className="text-muted-foreground leading-relaxed">
Elmeg is a community-driven archive platform for live music enthusiasts. The Service allows
users to browse setlists, rate performances, participate in discussions, and contribute to
the archive. The Service is provided "as is" and we make no guarantees regarding availability,
accuracy, or completeness of content.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">3. User Accounts</h2>
<div className="text-muted-foreground leading-relaxed space-y-3">
<p>
To access certain features of the Service, you must create an account. You agree to:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Provide accurate and complete information during registration</li>
<li>Maintain the security of your account credentials</li>
<li>Notify us immediately of any unauthorized use of your account</li>
<li>Accept responsibility for all activities that occur under your account</li>
</ul>
<p>
You must be at least 13 years old to create an account and use the Service.
</p>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">4. User Conduct</h2>
<div className="text-muted-foreground leading-relaxed space-y-3">
<p>You agree not to:</p>
<ul className="list-disc pl-6 space-y-2">
<li>Post content that is defamatory, harassing, threatening, or discriminatory</li>
<li>Impersonate any person or entity</li>
<li>Upload malicious code or attempt to compromise the Service</li>
<li>Spam, advertise, or promote unrelated products or services</li>
<li>Circumvent any access controls or usage limits</li>
<li>Use automated tools to scrape or access the Service without permission</li>
<li>Violate any applicable laws or regulations</li>
</ul>
<p>
We reserve the right to suspend or terminate accounts that violate these guidelines.
</p>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">5. User-Generated Content</h2>
<div className="text-muted-foreground leading-relaxed space-y-3">
<p>
By submitting content to the Service (including reviews, comments, ratings, and
suggestions), you:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Grant us a non-exclusive, royalty-free license to use, display, and distribute your content</li>
<li>Represent that you have the right to submit such content</li>
<li>Acknowledge that your content may be viewed by other users</li>
</ul>
<p>
We do not claim ownership of your content but may moderate or remove content that
violates these terms.
</p>
</div>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">6. Intellectual Property</h2>
<p className="text-muted-foreground leading-relaxed">
The Service, including its design, code, and original content, is protected by copyright
and other intellectual property laws. Setlist data and performance information is
community-contributed and intended for personal, non-commercial use. All trademarks,
artist names, and related imagery remain the property of their respective owners.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">7. Disclaimer of Warranties</h2>
<p className="text-muted-foreground leading-relaxed">
THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, AND NON-INFRINGEMENT. WE DO NOT WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED,
ERROR-FREE, OR SECURE.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">8. Limitation of Liability</h2>
<p className="text-muted-foreground leading-relaxed">
TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE SHALL NOT BE LIABLE FOR ANY INDIRECT,
INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE
SERVICE. OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT YOU PAID TO USE THE SERVICE
(IF ANY) IN THE TWELVE MONTHS PRECEDING THE CLAIM.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">9. Account Termination</h2>
<p className="text-muted-foreground leading-relaxed">
You may delete your account at any time through your account settings. We may suspend
or terminate your account for violations of these terms or for any other reason at our
discretion. Upon termination, your right to use the Service ceases immediately.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">10. Changes to Terms</h2>
<p className="text-muted-foreground leading-relaxed">
We may modify these Terms of Service at any time. We will notify users of material
changes via email or through the Service. Your continued use after such modifications
constitutes acceptance of the updated terms.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold mb-4">11. Contact</h2>
<p className="text-muted-foreground leading-relaxed">
If you have questions about these Terms of Service, please contact us at{" "}
<a href="mailto:support@elmeg.xyz" className="text-primary hover:underline">
support@elmeg.xyz
</a>.
</p>
</section>
</div>
</div>
)
}

View file

@ -72,7 +72,9 @@ export default async function TourDetailPage({ params }: { params: Promise<{ id:
<CardContent>
{shows.length > 0 ? (
<div className="space-y-2">
{shows.map((show: any) => (
{[...shows]
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((show: any) => (
<Link key={show.id} href={`/shows/${show.id}`} className="block group">
<div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">

View file

@ -1,126 +1,252 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, MapPin, Calendar } from "lucide-react"
import { ArrowLeft, MapPin, Calendar, Music, Star } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { getApiUrl } from "@/lib/api-config"
import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating"
import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper"
async function getVenue(id: string) {
interface Venue {
id: number
name: string
city: string
state: string
country: string
capacity: number | null
notes: string | null
}
interface Show {
id: number
slug?: string
date: string
tour?: { name: string }
performances?: any[]
}
export default function VenueDetailPage() {
const params = useParams()
const id = params.id as string
const [venue, setVenue] = useState<Venue | null>(null)
const [shows, setShows] = useState<Show[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function fetchData() {
try {
const res = await fetch(`${getApiUrl()}/venues/${id}`, { cache: 'no-store' })
if (!res.ok) return null
return res.json()
} catch (e) {
console.error(e)
return null
// Fetch venue
const venueRes = await fetch(`${getApiUrl()}/venues/${id}`)
if (!venueRes.ok) {
if (venueRes.status === 404) {
setError("Venue not found")
} else {
setError("Failed to load venue")
}
return
}
const venueData = await venueRes.json()
setVenue(venueData)
// Fetch shows at this venue using numeric ID
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
if (showsRes.ok) {
const showsData = await showsRes.json()
// Sort by date descending
showsData.sort((a: Show, b: Show) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
setShows(showsData)
}
} catch (err) {
console.error("Error fetching venue:", err)
setError("Failed to load venue")
} finally {
setLoading(false)
}
}
fetchData()
}, [id])
if (loading) {
return (
<div className="container py-10">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-muted rounded w-64" />
<div className="h-4 bg-muted rounded w-48" />
<div className="h-64 bg-muted rounded" />
</div>
</div>
)
}
async function getVenueShows(id: string) {
try {
const res = await fetch(`${getApiUrl()}/shows/?venue_id=${id}`, { cache: 'no-store' })
if (!res.ok) return []
return res.json()
} catch (e) {
console.error(e)
return []
}
}
export default async function VenueDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const venue = await getVenue(id)
const shows = await getVenueShows(id)
if (!venue) {
notFound()
if (error || !venue) {
return (
<div className="container py-10">
<div className="text-center py-12">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h1 className="text-2xl font-bold mb-2">{error || "Venue not found"}</h1>
<Link href="/venues">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Venues
</Button>
</Link>
</div>
</div>
)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4 justify-between">
<div className="flex items-center gap-4">
<Link href="/archive">
<div className="container py-10 space-y-8">
{/* Header */}
<div className="flex items-start gap-4">
<Link href="/venues">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight">{venue.name}</h1>
<p className="text-muted-foreground flex items-center gap-2">
<p className="text-muted-foreground flex items-center gap-2 mt-1">
<MapPin className="h-4 w-4" />
{venue.city}, {venue.state} {venue.country}
{venue.city}{venue.state ? `, ${venue.state}` : ""}, {venue.country}
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">{shows.length}</div>
<div className="text-sm text-muted-foreground">
{shows.length === 1 ? "show" : "shows"}
</div>
</div>
<SocialWrapper type="ratings">
<EntityRating entityType="venue" entityId={venue.id} />
</SocialWrapper>
</div>
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6">
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
{/* Shows List */}
<Card>
<CardHeader>
<CardTitle>Shows at {venue.name}</CardTitle>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Shows at {venue.name}
</CardTitle>
</CardHeader>
<CardContent>
{shows.length > 0 ? (
<div className="space-y-2">
{shows.map((show: any) => (
<Link key={show.id} href={`/shows/${show.id}`} className="block group">
<div className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 transition-colors">
{shows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group">
<div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border">
<div className="flex items-center gap-3">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="font-medium group-hover:underline">
{new Date(show.date).toLocaleDateString()}
<span className="font-medium group-hover:text-primary transition-colors">
{new Date(show.date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric"
})}
</span>
</div>
<div className="flex items-center gap-4">
{show.tour && (
<span className="text-xs text-muted-foreground">{show.tour.name}</span>
<span className="text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
{show.tour.name}
</span>
)}
{show.performances && show.performances.length > 0 && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Music className="h-3 w-3" />
{show.performances.length}
</span>
)}
</div>
</div>
</Link>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">No shows found for this venue.</p>
<p className="text-muted-foreground text-sm text-center py-8">
No shows recorded at this venue yet.
</p>
)}
</CardContent>
</Card>
<SocialWrapper type="comments">
<CommentSection entityType="venue" entityId={venue.id} />
</SocialWrapper>
<SocialWrapper type="reviews">
<EntityReviews entityType="venue" entityId={venue.id} />
</SocialWrapper>
</div>
<div className="flex flex-col gap-6">
{/* Sidebar */}
<div className="space-y-6">
{/* Venue Details */}
<Card>
<CardHeader>
<CardTitle>Venue Details</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Location</span>
<span className="font-medium text-right">
{venue.city}{venue.state ? `, ${venue.state}` : ""}
<br />
<span className="text-muted-foreground">{venue.country}</span>
</span>
</div>
{venue.capacity && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Capacity</span>
<span className="font-medium">{venue.capacity.toLocaleString()}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Shows</span>
<span className="font-medium">{shows.length}</span>
</div>
{shows.length > 0 && (
<>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">First Show</span>
<span className="font-medium">
{new Date(shows[shows.length - 1].date).toLocaleDateString()}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Show</span>
<span className="font-medium">
{new Date(shows[0].date).toLocaleDateString()}
</span>
</div>
</>
)}
{venue.notes && (
<div className="pt-2 border-t mt-2">
<div className="pt-3 border-t mt-3">
<p className="text-sm text-muted-foreground italic">{venue.notes}</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Stats */}
{shows.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Quick Stats</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary">{shows.length}</div>
<div className="text-xs text-muted-foreground">Shows</div>
</div>
<div>
<div className="text-2xl font-bold">
{shows.reduce((acc, s) => acc + (s.performances?.length || 0), 0)}
</div>
<div className="text-xs text-muted-foreground">Performances</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>

View file

@ -1,65 +1,217 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useState, useMemo } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { MapPin } from "lucide-react"
import { MapPin, Search, Calendar, ArrowUpDown } from "lucide-react"
interface Venue {
id: number
name: string
slug?: string
city: string
state: string
country: string
show_count?: number
}
type SortOption = "name" | "city" | "shows"
export default function VenuesPage() {
const [venues, setVenues] = useState<Venue[]>([])
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [stateFilter, setStateFilter] = useState<string>("")
const [sortBy, setSortBy] = useState<SortOption>("name")
useEffect(() => {
fetch(`${getApiUrl()}/venues/?limit=100`)
.then(res => res.json())
.then(data => {
// Sort alphabetically
const sorted = data.sort((a: Venue, b: Venue) => a.name.localeCompare(b.name))
setVenues(sorted)
async function fetchVenues() {
try {
// Fetch venues
const venuesRes = await fetch(`${getApiUrl()}/venues/?limit=100`)
const venuesData: Venue[] = await venuesRes.json()
// Fetch show counts for each venue (batch approach)
const showsRes = await fetch(`${getApiUrl()}/shows/?limit=1000`)
const showsData = await showsRes.json()
// Count shows per venue
const showCounts: Record<number, number> = {}
showsData.forEach((show: any) => {
if (show.venue_id) {
showCounts[show.venue_id] = (showCounts[show.venue_id] || 0) + 1
}
})
.catch(console.error)
.finally(() => setLoading(false))
// Merge counts into venues
const venuesWithCounts = venuesData.map(v => ({
...v,
show_count: showCounts[v.id] || 0
}))
setVenues(venuesWithCounts)
} catch (error) {
console.error("Failed to fetch venues:", error)
} finally {
setLoading(false)
}
}
fetchVenues()
}, [])
if (loading) return <div className="container py-10">Loading venues...</div>
// Get unique states for filter dropdown
const uniqueStates = useMemo(() => {
const states = [...new Set(venues.map(v => v.state).filter(Boolean))]
return states.sort()
}, [venues])
// Filter and sort venues
const filteredVenues = useMemo(() => {
let result = venues
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase()
result = result.filter(v =>
v.name.toLowerCase().includes(query) ||
v.city.toLowerCase().includes(query)
)
}
// State filter
if (stateFilter) {
result = result.filter(v => v.state === stateFilter)
}
// Sort
switch (sortBy) {
case "name":
result = [...result].sort((a, b) => a.name.localeCompare(b.name))
break
case "city":
result = [...result].sort((a, b) => a.city.localeCompare(b.city))
break
case "shows":
result = [...result].sort((a, b) => (b.show_count || 0) - (a.show_count || 0))
break
}
return result
}, [venues, searchQuery, stateFilter, sortBy])
if (loading) {
return (
<div className="container py-10">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-48" />
<div className="h-12 bg-muted rounded" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(9)].map((_, i) => (
<div key={i} className="h-32 bg-muted rounded" />
))}
</div>
</div>
</div>
)
}
return (
<div className="container py-10 space-y-8">
<div className="container py-10 space-y-6">
{/* Header */}
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Venues</h1>
<p className="text-muted-foreground">
Explore the iconic venues where the magic happens.
{venues.length} venues where the magic happens
</p>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search venues or cities..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<select
className="h-10 px-3 rounded-md border bg-background text-sm"
value={stateFilter}
onChange={(e) => setStateFilter(e.target.value)}
>
<option value="">All States</option>
{uniqueStates.map(state => (
<option key={state} value={state}>{state}</option>
))}
</select>
<div className="flex gap-2">
<Button
variant={sortBy === "name" ? "default" : "outline"}
size="sm"
onClick={() => setSortBy("name")}
>
<ArrowUpDown className="h-3 w-3 mr-1" />
Name
</Button>
<Button
variant={sortBy === "shows" ? "default" : "outline"}
size="sm"
onClick={() => setSortBy("shows")}
>
<Calendar className="h-3 w-3 mr-1" />
Shows
</Button>
</div>
</div>
{/* Results count */}
{(searchQuery || stateFilter) && (
<p className="text-sm text-muted-foreground">
Showing {filteredVenues.length} of {venues.length} venues
</p>
)}
{/* Venue Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{venues.map((venue) => (
<Link key={venue.id} href={`/venues/${venue.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-green-500" />
{venue.name}
{filteredVenues.map((venue) => (
<Link key={venue.id} href={`/venues/${venue.slug || venue.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors">
<MapPin className="h-5 w-5 text-green-500 flex-shrink-0" />
<span className="truncate">{venue.name}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{venue.city}, {venue.state}
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{venue.city}{venue.state ? `, ${venue.state}` : ""}
</p>
{(venue.show_count || 0) > 0 && (
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded-full font-medium">
{venue.show_count} {venue.show_count === 1 ? "show" : "shows"}
</span>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{filteredVenues.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<MapPin className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No venues found matching your search.</p>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,107 @@
"use client"
import { useEffect, useState, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { CheckCircle, XCircle, Loader2 } from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
function VerifyEmailContent() {
const searchParams = useSearchParams()
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
const [message, setMessage] = useState("")
const verifyEmail = async (token: string) => {
try {
const res = await fetch(`${getApiUrl()}/auth/verify-email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token })
})
const data = await res.json()
if (res.ok) {
setStatus("success")
setMessage(data.message || "Email verified successfully!")
} else {
setStatus("error")
setMessage(data.detail || "Verification failed")
}
} catch (_) {
setStatus("error")
setMessage("An error occurred during verification")
}
}
useEffect(() => {
const token = searchParams.get("token")
if (!token) {
setStatus("error")
setMessage("No verification token provided")
return
}
verifyEmail(token)
}, [searchParams])
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
{status === "loading" && <Loader2 className="h-6 w-6 animate-spin" />}
{status === "success" && <CheckCircle className="h-6 w-6 text-green-500" />}
{status === "error" && <XCircle className="h-6 w-6 text-red-500" />}
Email Verification
</CardTitle>
<CardDescription>
{status === "loading" && "Verifying your email..."}
{status === "success" && "Your email has been verified!"}
{status === "error" && "Verification failed"}
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">{message}</p>
{status === "success" && (
<Button asChild>
<Link href="/login">Continue to Login</Link>
</Button>
)}
{status === "error" && (
<div className="space-y-2">
<Button variant="outline" asChild>
<Link href="/login">Go to Login</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function LoadingFallback() {
return (
<div className="container max-w-md mx-auto py-16 px-4">
<Card>
<CardContent className="py-16 flex flex-col items-center justify-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
)
}
export default function VerifyEmailPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<VerifyEmailContent />
</Suspense>
)
}

View file

@ -0,0 +1,211 @@
"use client"
import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Youtube, Calendar, MapPin, Music, Film } from "lucide-react"
import Link from "next/link"
interface PerformanceVideo {
type: "performance"
id: number
youtube_link: string
show_id: number
song_id: number
song_title: string
song_slug: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
}
interface ShowVideo {
type: "full_show"
id: number
youtube_link: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
}
interface VideoStats {
performance_videos: number
full_show_videos: number
total: number
}
export default function VideosPage() {
const [performances, setPerformances] = useState<PerformanceVideo[]>([])
const [shows, setShows] = useState<ShowVideo[]>([])
const [stats, setStats] = useState<VideoStats | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<"all" | "songs" | "shows">("all")
useEffect(() => {
Promise.all([
fetch(`${getApiUrl()}/videos/?limit=500`).then(r => r.json()),
fetch(`${getApiUrl()}/videos/stats`).then(r => r.json())
])
.then(([videoData, statsData]) => {
setPerformances(videoData.performances)
setShows(videoData.shows)
setStats(statsData)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const extractVideoId = (url: string) => {
const match = url.match(/[?&]v=([^&]+)/)
return match ? match[1] : null
}
if (loading) {
return (
<div className="container py-10 space-y-6">
<Skeleton className="h-10 w-48" />
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
)
}
const filteredPerformances = activeTab === "shows" ? [] : performances
const filteredShows = activeTab === "songs" ? [] : shows
// Combine and sort by date
const allVideos = [
...filteredPerformances.map(p => ({ ...p, sortDate: p.date })),
...filteredShows.map(s => ({ ...s, sortDate: s.date, song_title: "Full Show" }))
].sort((a, b) => new Date(b.sortDate).getTime() - new Date(a.sortDate).getTime())
return (
<div className="container py-10 space-y-8">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Youtube className="h-8 w-8 text-red-600" />
<h1 className="text-3xl font-bold tracking-tight">Videos</h1>
</div>
{stats && (
<p className="text-muted-foreground">
{stats.total} videos available {stats.full_show_videos} full shows {stats.performance_videos} song performances
</p>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-2">
<button
onClick={() => setActiveTab("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "all"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
All Videos
</button>
<button
onClick={() => setActiveTab("songs")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "songs"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
<Music className="h-4 w-4 inline mr-1" />
Songs ({stats?.performance_videos})
</button>
<button
onClick={() => setActiveTab("shows")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "shows"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
<Film className="h-4 w-4 inline mr-1" />
Full Shows ({stats?.full_show_videos})
</button>
</div>
{/* Video List */}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{allVideos.map((video, idx) => (
<div
key={`${video.type}-${video.id}-${idx}`}
className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors"
>
{/* YouTube Icon/Link */}
<a
href={video.youtube_link}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-red-600 hover:text-red-500 transition-colors"
title="Watch on YouTube"
>
<Youtube className="h-6 w-6" />
</a>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{video.type === "full_show" ? (
<Link
href={`/shows/${video.show_slug || video.id}`}
className="font-medium hover:underline text-primary"
>
Full Show
</Link>
) : (
<Link
href={`/songs/${(video as PerformanceVideo).song_slug || (video as PerformanceVideo).song_id}`}
className="font-medium hover:underline"
>
{(video as PerformanceVideo).song_title}
</Link>
)}
<Badge variant={video.type === "full_show" ? "default" : "secondary"} className="text-xs">
{video.type === "full_show" ? "Full Show" : "Song"}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(video.date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
})}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{video.venue_name}, {video.venue_city}
{video.venue_state && `, ${video.venue_state}`}
</span>
</div>
</div>
{/* Show Link */}
<Link
href={`/shows/${video.show_slug || (video.type === "full_show" ? video.id : (video as PerformanceVideo).show_id)}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
View Show
</Link>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -180,7 +180,7 @@ export default function WelcomePage() {
<Switch
id="wiki-mode"
checked={wikiMode}
onCheckedChange={setWikiMode}
onChange={(e) => setWikiMode(e.target.checked)}
/>
</div>

View file

@ -0,0 +1,116 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Target, Check, Loader2 } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
interface ChaseSong {
id: number
song_id: number
song_title: string
caught_at: string | null
caught_show_id: number | null
}
interface MarkCaughtButtonProps {
songId: number
songTitle: string
showId: number
className?: string
}
export function MarkCaughtButton({ songId, songTitle, showId, className }: MarkCaughtButtonProps) {
const { user, token } = useAuth()
const [chaseSong, setChaseSong] = useState<ChaseSong | null>(null)
const [loading, setLoading] = useState(false)
const [marking, setMarking] = useState(false)
useEffect(() => {
if (!user || !token) return
// Check if this song is in the user's chase list
setLoading(true)
fetch(`${getApiUrl()}/chase/songs`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : [])
.then((songs: ChaseSong[]) => {
const match = songs.find(s => s.song_id === songId)
setChaseSong(match || null)
})
.catch(() => setChaseSong(null))
.finally(() => setLoading(false))
}, [user, token, songId])
const handleMarkCaught = async () => {
if (!chaseSong || !token) return
setMarking(true)
try {
const res = await fetch(`${getApiUrl()}/chase/songs/${chaseSong.id}/caught?show_id=${showId}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
})
if (!res.ok) throw new Error("Failed to mark caught")
// Update local state
setChaseSong({ ...chaseSong, caught_at: new Date().toISOString(), caught_show_id: showId })
} catch (err) {
console.error(err)
alert("Failed to mark song as caught")
} finally {
setMarking(false)
}
}
// Not logged in or not chasing this song
if (!user || !chaseSong) return null
// Already caught at THIS show
if (chaseSong.caught_show_id === showId) {
return (
<span
className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium"
title={`You caught ${songTitle} at this show! 🎉`}
>
<Check className="h-3 w-3" />
Caught!
</span>
)
}
// Already caught at another show
if (chaseSong.caught_at) {
return (
<span
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
title={`You already caught ${songTitle} at another show`}
>
<Check className="h-3 w-3" />
Caught
</span>
)
}
// Chasing but not yet caught
return (
<Button
variant="ghost"
size="sm"
onClick={handleMarkCaught}
disabled={marking}
title={`You're chasing ${songTitle}! Mark it as caught at this show.`}
className={`h-6 px-2 text-xs gap-1 text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/50 ${className}`}
>
{marking ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Target className="h-3 w-3" />
)}
Mark Caught
</Button>
)
}

View file

@ -0,0 +1,147 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Flame, Star, Trophy, Zap, TrendingUp } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface LevelProgress {
current_xp: number
level: number
level_name: string
xp_for_next: number
xp_progress: number
progress_percent: number
streak_days: number
}
const TIER_COLORS = {
bronze: "bg-amber-700/20 text-amber-600 border-amber-600/30",
silver: "bg-slate-400/20 text-slate-300 border-slate-400/30",
gold: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
platinum: "bg-cyan-400/20 text-cyan-300 border-cyan-400/30",
diamond: "bg-purple-500/20 text-purple-300 border-purple-500/30",
}
export function LevelProgressCard() {
const [progress, setProgress] = useState<LevelProgress | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchProgress()
}, [])
const fetchProgress = async () => {
const token = localStorage.getItem("token")
if (!token) {
setLoading(false)
return
}
try {
const res = await fetch(`${getApiUrl()}/gamification/me`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setProgress(data)
}
} catch (err) {
console.error("Failed to fetch level progress", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading...</div>
}
if (!progress) {
return null
}
return (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-r from-primary/10 via-purple-500/10 to-pink-500/10">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-primary" />
Level Progress
</div>
{progress.streak_days > 0 && (
<Badge variant="outline" className="gap-1 bg-orange-500/10 text-orange-400 border-orange-500/30">
<Flame className="h-3 w-3" />
{progress.streak_days} day streak
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
{/* Level Badge */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center gap-4"
>
<div className="relative">
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-primary to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg">
{progress.level}
</div>
<div className="absolute -bottom-1 -right-1 bg-background rounded-full p-1">
<Star className="h-4 w-4 text-yellow-500" />
</div>
</div>
<div>
<h3 className="text-xl font-bold">{progress.level_name}</h3>
<p className="text-sm text-muted-foreground">
{progress.current_xp.toLocaleString()} XP total
</p>
</div>
</motion.div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Level {progress.level + 1}
</span>
<span className="font-mono">
{progress.xp_progress} / {progress.xp_for_next} XP
</span>
</div>
<Progress value={progress.progress_percent} className="h-3" />
<p className="text-xs text-muted-foreground text-center">
{Math.round(progress.xp_for_next - progress.xp_progress)} XP until next level
</p>
</div>
{/* XP Tips */}
<div className="pt-4 border-t">
<p className="text-xs text-muted-foreground mb-2 font-medium">Earn XP by:</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-2">
<Trophy className="h-3 w-3 text-primary" />
<span>Rating performances</span>
</div>
<div className="flex items-center gap-2">
<Zap className="h-3 w-3 text-yellow-500" />
<span>Writing reviews</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-3 w-3 text-purple-500" />
<span>Marking attendance</span>
</div>
<div className="flex items-center gap-2">
<Flame className="h-3 w-3 text-orange-500" />
<span>Daily streaks</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,149 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Trophy, Flame, Medal, Crown } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface LeaderboardEntry {
rank: number
username: string
xp: number
level: number
level_name: string
streak: number
}
const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return <Crown className="h-5 w-5 text-yellow-500" />
case 2:
return <Medal className="h-5 w-5 text-slate-400" />
case 3:
return <Medal className="h-5 w-5 text-amber-600" />
default:
return <span className="text-muted-foreground font-mono">#{rank}</span>
}
}
const getRankBg = (rank: number) => {
switch (rank) {
case 1:
return "bg-gradient-to-r from-yellow-500/20 to-amber-500/10 border-yellow-500/30"
case 2:
return "bg-gradient-to-r from-slate-400/20 to-slate-500/10 border-slate-400/30"
case 3:
return "bg-gradient-to-r from-amber-600/20 to-amber-700/10 border-amber-600/30"
default:
return "bg-muted/30"
}
}
export function XPLeaderboard() {
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchLeaderboard()
}, [])
const fetchLeaderboard = async () => {
try {
const res = await fetch(`${getApiUrl()}/gamification/leaderboard?limit=10`)
if (res.ok) {
const data = await res.json()
setLeaderboard(data)
}
} catch (err) {
console.error("Failed to fetch leaderboard", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading leaderboard...</div>
}
if (leaderboard.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
XP Leaderboard
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-4">
No rankings yet. Be the first!
</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader className="bg-gradient-to-r from-yellow-500/10 via-primary/5 to-transparent">
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
XP Leaderboard
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="space-y-2">
{leaderboard.map((entry, index) => (
<motion.div
key={entry.username}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className={`flex items-center gap-3 p-3 rounded-lg border ${getRankBg(entry.rank)}`}
>
<div className="w-8 flex justify-center">
{getRankIcon(entry.rank)}
</div>
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-primary/10 text-primary">
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{entry.username}</span>
<Badge variant="outline" className="text-xs">
Lv.{entry.level}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{entry.level_name}
</p>
</div>
<div className="text-right">
<div className="font-bold font-mono text-primary">
{entry.xp.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">XP</div>
</div>
{entry.streak > 0 && (
<div className="flex items-center gap-1 text-orange-500">
<Flame className="h-4 w-4" />
<span className="text-sm font-medium">{entry.streak}</span>
</div>
)}
</motion.div>
))}
</div>
</CardContent>
</Card>
)
}

View file

@ -4,6 +4,7 @@ import { Music, User, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { SearchDialog } from "@/components/ui/search-dialog"
import { NotificationBell } from "@/components/notifications/notification-bell"
import { ThemeToggle } from "@/components/theme-toggle"
import {
DropdownMenu,
DropdownMenuContent,
@ -45,13 +46,13 @@ export function Navbar() {
<Link href="/songs">
<DropdownMenuItem>Songs</DropdownMenuItem>
</Link>
<Link href="/performances">
<DropdownMenuItem>Top Performances</DropdownMenuItem>
</Link>
<Link href="/tours">
<DropdownMenuItem>Tours</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<Link href="/leaderboards">
<DropdownMenuItem>Leaderboards</DropdownMenuItem>
</Link>
{/* Leaderboards hidden until community activity grows */}
</DropdownMenuContent>
</DropdownMenu>
@ -64,6 +65,7 @@ export function Navbar() {
<div className="w-full flex-1 md:w-auto md:flex-none">
<SearchDialog />
</div>
<ThemeToggle />
<nav className="flex items-center gap-2">
{user ? (
<>

View file

@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuHeader,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator
@ -116,7 +115,7 @@ export function NotificationBell() {
{unreadCount > 0 && (
<Button
variant="ghost"
size="xs"
size="sm"
className="h-auto px-2 py-0.5 text-xs text-muted-foreground hover:text-primary"
onClick={handleMarkAllRead}
>

View file

@ -0,0 +1,209 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Sparkles, Music, Trophy, Star, Calendar, Target, Eye } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { motion } from "framer-motion"
interface ProfileStats {
shows_attended: number
unique_songs_seen: number
debuts_witnessed: number
heady_versions_attended: number
top_10_performances: number
total_ratings: number
total_reviews: number
chase_songs_count: number
chase_songs_caught: number
most_seen_song: string | null
most_seen_count: number
}
export function AttendanceSummary() {
const [stats, setStats] = useState<ProfileStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
const token = localStorage.getItem("token")
if (!token) {
setLoading(false)
return
}
try {
const res = await fetch(`${getApiUrl()}/chase/profile/stats`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setStats(data)
}
} catch (err) {
console.error("Failed to fetch profile stats", err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div className="text-muted-foreground">Loading stats...</div>
}
if (!stats || stats.shows_attended === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-yellow-500" />
Attendance Summary
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-4">
No shows marked as attended yet. Start adding shows to see your stats!
</p>
</CardContent>
</Card>
)
}
// Build the summary sentence
const highlights: string[] = []
if (stats.heady_versions_attended > 0) {
highlights.push(`${stats.heady_versions_attended} heady version${stats.heady_versions_attended !== 1 ? 's' : ''}`)
}
if (stats.top_10_performances > 0) {
highlights.push(`${stats.top_10_performances} top-rated performance${stats.top_10_performances !== 1 ? 's' : ''}`)
}
if (stats.debuts_witnessed > 0) {
highlights.push(`${stats.debuts_witnessed} debut${stats.debuts_witnessed !== 1 ? 's' : ''}`)
}
return (
<Card className="overflow-hidden">
<CardHeader className="bg-gradient-to-br from-primary/10 via-primary/5 to-transparent">
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-yellow-500" />
Your Attendance Story
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Main Summary */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-lg leading-relaxed"
>
<p>
You've attended <strong className="text-primary">{stats.shows_attended} shows</strong> and
seen <strong className="text-primary">{stats.unique_songs_seen} unique songs</strong>.
</p>
{highlights.length > 0 && (
<p className="mt-2 text-muted-foreground">
In attendance for {highlights.join(", ")}.
</p>
)}
{stats.most_seen_song && (
<p className="mt-2">
Your most-seen song is <strong className="text-primary">{stats.most_seen_song}</strong> ({stats.most_seen_count} times).
</p>
)}
</motion.div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="text-center p-4 rounded-lg bg-muted/50"
>
<Calendar className="h-6 w-6 mx-auto mb-2 text-primary" />
<div className="text-2xl font-bold">{stats.shows_attended}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Shows</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="text-center p-4 rounded-lg bg-muted/50"
>
<Music className="h-6 w-6 mx-auto mb-2 text-green-500" />
<div className="text-2xl font-bold">{stats.unique_songs_seen}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Songs Seen</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="text-center p-4 rounded-lg bg-yellow-500/10"
>
<Trophy className="h-6 w-6 mx-auto mb-2 text-yellow-500" />
<div className="text-2xl font-bold">{stats.heady_versions_attended}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Heady Versions</div>
</motion.div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 }}
className="text-center p-4 rounded-lg bg-purple-500/10"
>
<Star className="h-6 w-6 mx-auto mb-2 text-purple-500" />
<div className="text-2xl font-bold">{stats.debuts_witnessed}</div>
<div className="text-xs text-muted-foreground uppercase tracking-wider">Debuts</div>
</motion.div>
</div>
{/* Chase Songs Progress */}
{stats.chase_songs_count > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="p-4 rounded-lg border bg-muted/30"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 font-medium">
<Target className="h-4 w-4 text-primary" />
Chase Progress
</div>
<span className="text-sm font-mono">
{stats.chase_songs_caught}/{stats.chase_songs_count}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(stats.chase_songs_caught / stats.chase_songs_count) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-2">
{stats.chase_songs_count - stats.chase_songs_caught} songs left to catch!
</p>
</motion.div>
)}
{/* Activity Stats */}
<div className="flex items-center justify-center gap-8 pt-4 border-t text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Star className="h-4 w-4" />
<span>{stats.total_ratings} ratings</span>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4" />
<span>{stats.total_reviews} reviews</span>
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,259 @@
"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Target, CheckCircle, Trash2, Plus, Trophy, Music, Star } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
interface ChaseSong {
id: number
song_id: number
song_title: string
priority: number
notes: string | null
created_at: string
caught_at: string | null
caught_show_id: number | null
caught_show_date: string | null
}
interface ChaseSongsListProps {
userId?: number
}
export function ChaseSongsList({ userId }: ChaseSongsListProps) {
const [chaseSongs, setChaseSongs] = useState<ChaseSong[]>([])
const [loading, setLoading] = useState(true)
const [newSongQuery, setNewSongQuery] = useState("")
const [searchResults, setSearchResults] = useState<any[]>([])
const [showSearch, setShowSearch] = useState(false)
useEffect(() => {
fetchChaseSongs()
}, [])
const fetchChaseSongs = async () => {
const token = localStorage.getItem("token")
if (!token) {
setLoading(false)
return
}
try {
const res = await fetch(`${getApiUrl()}/chase/songs`, {
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
const data = await res.json()
setChaseSongs(data)
}
} catch (err) {
console.error("Failed to fetch chase songs", err)
} finally {
setLoading(false)
}
}
const searchSongs = async (query: string) => {
if (query.length < 2) {
setSearchResults([])
return
}
try {
const res = await fetch(`${getApiUrl()}/search/songs?q=${encodeURIComponent(query)}`)
if (res.ok) {
const data = await res.json()
setSearchResults(data.slice(0, 5))
}
} catch (err) {
console.error("Failed to search songs", err)
}
}
const addChaseSong = async (songId: number) => {
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/chase/songs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ song_id: songId, priority: 1 })
})
if (res.ok) {
fetchChaseSongs()
setNewSongQuery("")
setSearchResults([])
setShowSearch(false)
}
} catch (err) {
console.error("Failed to add chase song", err)
}
}
const removeChaseSong = async (chaseId: number) => {
const token = localStorage.getItem("token")
if (!token) return
try {
const res = await fetch(`${getApiUrl()}/chase/songs/${chaseId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
setChaseSongs(chaseSongs.filter(cs => cs.id !== chaseId))
}
} catch (err) {
console.error("Failed to remove chase song", err)
}
}
const activeChaseSongs = chaseSongs.filter(cs => !cs.caught_at)
const caughtChaseSongs = chaseSongs.filter(cs => cs.caught_at)
const getPriorityLabel = (priority: number) => {
switch (priority) {
case 1: return { label: "Must See", color: "bg-red-500/10 text-red-500 border-red-500/30" }
case 2: return { label: "Want", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/30" }
default: return { label: "Nice", color: "bg-muted text-muted-foreground" }
}
}
if (loading) {
return <div className="text-muted-foreground">Loading chase songs...</div>
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
Chase Songs
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setShowSearch(!showSearch)}
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Song
</Button>
</CardHeader>
<CardContent className="space-y-4">
{showSearch && (
<div className="space-y-2 p-4 bg-muted/50 rounded-lg">
<Input
placeholder="Search for a song to chase..."
value={newSongQuery}
onChange={(e) => {
setNewSongQuery(e.target.value)
searchSongs(e.target.value)
}}
/>
{searchResults.length > 0 && (
<div className="space-y-1">
{searchResults.map((song) => (
<div
key={song.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted cursor-pointer"
onClick={() => addChaseSong(song.id)}
>
<span>{song.title}</span>
<Plus className="h-4 w-4 text-primary" />
</div>
))}
</div>
)}
</div>
)}
{activeChaseSongs.length === 0 && caughtChaseSongs.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No chase songs yet. Add songs you want to catch!
</p>
) : (
<>
{/* Active Chase Songs */}
{activeChaseSongs.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Chasing ({activeChaseSongs.length})
</h4>
{activeChaseSongs.map((cs) => {
const priority = getPriorityLabel(cs.priority)
return (
<div
key={cs.id}
className="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Music className="h-4 w-4 text-muted-foreground" />
<Link
href={`/songs/${cs.song_id}`}
className="font-medium hover:text-primary transition-colors"
>
{cs.song_title}
</Link>
<Badge variant="outline" className={priority.color}>
{priority.label}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeChaseSong(cs.id)}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
{/* Caught Songs */}
{caughtChaseSongs.length > 0 && (
<div className="space-y-2 pt-4">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
Caught ({caughtChaseSongs.length})
</h4>
{caughtChaseSongs.map((cs) => (
<div
key={cs.id}
className="flex items-center justify-between p-3 rounded-lg border bg-green-500/5 border-green-500/20"
>
<div className="flex items-center gap-3">
<Trophy className="h-4 w-4 text-green-500" />
<Link
href={`/songs/${cs.song_id}`}
className="font-medium hover:text-primary transition-colors"
>
{cs.song_title}
</Link>
{cs.caught_show_date && (
<span className="text-sm text-muted-foreground">
{new Date(cs.caught_show_date).toLocaleDateString()}
</span>
)}
</div>
</div>
))}
</div>
)}
</>
)}
</CardContent>
</Card>
)
}

View file

@ -19,10 +19,18 @@ export type EntityType = "show" | "venue" | "song" | "performance" | "tour" | "y
interface EntityReviewsProps {
entityType: EntityType
entityId: number
entityName?: string // e.g., "Arcadia"
entityContext?: string // e.g., "Sat, Mar 14, 2015"
initialReviews?: Review[]
}
export function EntityReviews({ entityType, entityId, initialReviews = [] }: EntityReviewsProps) {
export function EntityReviews({
entityType,
entityId,
entityName,
entityContext,
initialReviews = []
}: EntityReviewsProps) {
const [reviews, setReviews] = useState<Review[]>(initialReviews)
// Fetch reviews on mount if not provided (or to refresh)
@ -34,8 +42,6 @@ export function EntityReviews({ entityType, entityId, initialReviews = [] }: Ent
const fetchReviews = async () => {
try {
const param = `${entityType}_id`
// Special case for 'year' which might just be 'year' param if we followed standard, but our API uses 'year'
const queryParam = entityType === 'year' ? 'year' : `${entityType}_id`
const res = await fetch(`${getApiUrl()}/reviews/?${queryParam}=${entityId}`)
@ -83,11 +89,26 @@ export function EntityReviews({ entityType, entityId, initialReviews = [] }: Ent
}
}
// Build title with context
const getTitle = () => {
if (entityName && entityContext) {
return `Write a Review for ${entityName}`
}
if (entityName) {
return `Write a Review for ${entityName}`
}
return "Write a Review"
}
return (
<div className="space-y-6 pt-6 border-t">
<h2 className="text-2xl font-bold">Reviews</h2>
<ReviewForm onSubmit={handleSubmit} />
<ReviewForm
onSubmit={handleSubmit}
title={getTitle()}
subtitle={entityContext}
/>
<div className="space-y-4">
{reviews.length === 0 ? (
@ -104,3 +125,4 @@ export function EntityReviews({ entityType, entityId, initialReviews = [] }: Ent
</div>
)
}

View file

@ -4,14 +4,16 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Input } from "@/components/ui/input"
import { StarRating } from "@/components/ui/star-rating"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { RatingInput } from "@/components/ui/rating-input"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
interface ReviewFormProps {
onSubmit: (data: { blurb: string; content: string; score: number }) => void
title?: string
subtitle?: string
}
export function ReviewForm({ onSubmit }: ReviewFormProps) {
export function ReviewForm({ onSubmit, title = "Write a Review", subtitle }: ReviewFormProps) {
const [blurb, setBlurb] = useState("")
const [content, setContent] = useState("")
const [score, setScore] = useState(0)
@ -29,13 +31,16 @@ export function ReviewForm({ onSubmit }: ReviewFormProps) {
return (
<Card>
<CardHeader>
<CardTitle>Write a Review</CardTitle>
<CardTitle>{title}</CardTitle>
{subtitle && (
<CardDescription>{subtitle}</CardDescription>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Rating</label>
<StarRating value={score} onChange={setScore} />
<RatingInput value={score} onChange={setScore} />
</div>
<div className="space-y-2">

View file

@ -1,7 +1,7 @@
"use client"
import { useState, useEffect } from "react"
import { StarRating } from "@/components/ui/star-rating"
import { RatingInput, RatingBadge } from "@/components/ui/rating-input"
import { getApiUrl } from "@/lib/api-config"
interface EntityRatingProps {
@ -14,16 +14,14 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
const [userRating, setUserRating] = useState(0)
const [averageRating, setAverageRating] = useState(0)
const [loading, setLoading] = useState(false)
const [hasRated, setHasRated] = useState(false)
useEffect(() => {
// Fetch average rating
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
.then(res => res.json())
.then(data => setAverageRating(data))
.catch(err => console.error("Failed to fetch avg rating", err))
// Fetch user rating (if logged in)
// TODO: Implement fetching user's existing rating
.then(res => res.ok ? res.json() : 0)
.then(data => setAverageRating(data || 0))
.catch(() => setAverageRating(0))
}, [entityType, entityId])
const handleRate = async (score: number) => {
@ -35,7 +33,7 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
setLoading(true)
try {
const body: any = { score }
const body: Record<string, unknown> = { score }
body[`${entityType}_id`] = entityId
const res = await fetch(`${getApiUrl()}/social/ratings`, {
@ -51,10 +49,11 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
const data = await res.json()
setUserRating(data.score)
setHasRated(true)
// Re-fetch average to keep it lively
// Re-fetch average
fetch(`${getApiUrl()}/social/ratings/average?${entityType}_id=${entityId}`)
.then(res => res.json())
.then(res => res.ok ? res.json() : averageRating)
.then(setAverageRating)
} catch (err) {
console.error(err)
@ -66,24 +65,41 @@ export function EntityRating({ entityType, entityId, compact = false }: EntityRa
if (compact) {
return (
<div className="flex items-center gap-1.5 opacity-80 hover:opacity-100 transition-opacity">
<StarRating value={userRating} onChange={handleRate} size="sm" />
<div className="flex items-center gap-2">
{averageRating > 0 && (
<span className="text-[10px] text-muted-foreground font-mono">
{averageRating.toFixed(1)}
</span>
<RatingBadge value={averageRating} />
)}
</div>
)
}
return (
<div className="flex items-center gap-2 border-l pl-4">
<div className="flex flex-col">
<span className="text-sm font-medium">Rating:</span>
<span className="text-xs text-muted-foreground">Avg: {averageRating.toFixed(1)}</span>
<div className="border rounded-lg p-4 bg-card">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium">Your Rating</span>
{averageRating > 0 && (
<span className="text-xs text-muted-foreground">
Community avg: <span className="font-medium">{averageRating.toFixed(1)}</span>
</span>
)}
</div>
<StarRating value={userRating} onChange={handleRate} />
<RatingInput
value={userRating}
onChange={handleRate}
showSlider={true}
/>
{loading && (
<p className="text-xs text-muted-foreground mt-2 animate-pulse">
Submitting...
</p>
)}
{hasRated && !loading && (
<p className="text-xs text-green-600 mt-2">
Rating saved!
</p>
)}
</div>
)
}

View file

@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
export interface Performance {
id: number
show_id: number
show_slug?: string
song_id: number
position: number
set_name: string | null
@ -86,7 +87,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
<div className="space-y-1 flex-1 min-w-0 pr-4">
<div className="flex items-baseline gap-2 flex-wrap">
<Link
href={`/shows/${perf.show_id}`}
href={`/shows/${perf.show_slug || perf.show_id}`}
className="font-medium hover:underline text-primary truncate"
>
{new Date(perf.show_date).toLocaleDateString(undefined, {

View file

@ -31,7 +31,7 @@ interface SongEvolutionChartProps {
title?: string
}
const CustomTooltip = ({ active, payload }: TooltipProps<number, string>) => {
const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: any[] }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (

View file

@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View file

@ -0,0 +1,39 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<Button variant="ghost" size="icon" className="h-9 w-9">
<Sun className="h-4 w-4" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
)
}

View file

@ -0,0 +1,62 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> { }
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
)
Avatar.displayName = "Avatar"
interface AvatarImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { }
const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
({ className, src, alt = "", ...props }, ref) => {
const [hasError, setHasError] = React.useState(false)
if (hasError || !src) {
return null
}
return (
<img
ref={ref}
src={src}
alt={alt}
onError={() => setHasError(true)}
className={cn("aspect-square h-full w-full object-cover", className)}
{...props}
/>
)
}
)
AvatarImage.displayName = "AvatarImage"
interface AvatarFallbackProps extends React.HTMLAttributes<HTMLDivElement> { }
const AvatarFallback = React.forwardRef<HTMLDivElement, AvatarFallbackProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground",
className
)}
{...props}
/>
)
)
AvatarFallback.displayName = "AvatarFallback"
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View file

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View file

@ -0,0 +1,192 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
interface RatingInputProps {
value: number
onChange?: (value: number) => void
readonly?: boolean
className?: string
size?: "sm" | "md" | "lg"
showSlider?: boolean
}
export function RatingInput({
value,
onChange,
readonly = false,
className,
size = "md",
showSlider = true
}: RatingInputProps) {
const [localValue, setLocalValue] = useState(value)
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = parseFloat(e.target.value)
setLocalValue(newValue)
onChange?.(newValue)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === "") {
setLocalValue(0)
return
}
const newValue = parseFloat(raw)
if (!isNaN(newValue)) {
const clamped = Math.min(10, Math.max(1, newValue))
const rounded = Math.round(clamped * 10) / 10
setLocalValue(rounded)
}
}
const handleInputBlur = () => {
setIsEditing(false)
if (localValue > 0) {
onChange?.(localValue)
}
}
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
inputRef.current?.blur()
}
}
// Visual star representation (readonly display)
const renderStars = () => {
const stars = []
const fullStars = Math.floor(localValue)
const partialFill = (localValue - fullStars) * 100
for (let i = 0; i < 10; i++) {
const isFull = i < fullStars
const isPartial = i === fullStars && partialFill > 0
stars.push(
<div key={i} className="relative">
<Star className={cn(
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
"fill-muted text-muted-foreground/50"
)} />
{(isFull || isPartial) && (
<div
className="absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${isFull ? 0 : 100 - partialFill}% 0 0)`
}}
>
<Star className={cn(
size === "sm" ? "h-3 w-3" : size === "lg" ? "h-5 w-5" : "h-4 w-4",
"fill-yellow-500 text-yellow-500"
)} />
</div>
)}
</div>
)
}
return stars
}
if (readonly) {
return (
<div className={cn("flex items-center gap-1", className)}>
<div className="flex gap-0.5">
{renderStars()}
</div>
<span className="ml-1.5 text-sm font-medium">
{localValue > 0 ? localValue.toFixed(1) : "—"}
</span>
</div>
)
}
return (
<div className={cn("flex flex-col gap-2", className)}>
{/* Stars Display + Numeric Input */}
<div className="flex items-center gap-3">
<div className="flex gap-0.5">
{renderStars()}
</div>
{/* Numeric Input */}
<div className="flex items-center gap-1">
<input
ref={inputRef}
type="number"
min="1"
max="10"
step="0.1"
value={isEditing ? localValue || "" : localValue.toFixed(1)}
onChange={handleInputChange}
onFocus={() => setIsEditing(true)}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
className={cn(
"w-14 h-8 px-2 text-center font-mono font-bold rounded-md",
"border bg-background text-foreground",
"focus:outline-none focus:ring-2 focus:ring-primary",
size === "lg" ? "text-lg" : "text-sm"
)}
/>
<span className="text-muted-foreground text-sm">/10</span>
</div>
</div>
{/* Slider */}
{showSlider && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<input
type="range"
min="1"
max="10"
step="0.1"
value={localValue || 1}
onChange={handleSliderChange}
className={cn(
"flex-1 h-2 rounded-full appearance-none cursor-pointer",
"bg-gradient-to-r from-red-500 via-yellow-500 to-green-500",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full",
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary",
"[&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:cursor-grab",
"[&::-webkit-slider-thumb]:active:cursor-grabbing"
)}
/>
<span className="text-xs text-muted-foreground w-4">10</span>
</div>
)}
</div>
)
}
// Compact version for inline use
export function RatingBadge({ value, className }: { value: number; className?: string }) {
const getColor = () => {
if (value >= 8) return "bg-green-500/10 text-green-600 border-green-500/20"
if (value >= 6) return "bg-yellow-500/10 text-yellow-600 border-yellow-500/20"
return "bg-red-500/10 text-red-600 border-red-500/20"
}
return (
<span className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-xs font-medium",
getColor(),
className
)}>
<Star className="h-3 w-3 fill-current" />
{value.toFixed(1)}
</span>
)
}

View file

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

Some files were not shown because too many files have changed in this diff Show more