Compare commits
46 commits
8d1ef4e915
...
b0f919f9ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f919f9ff | ||
|
|
483d6dcb0d | ||
|
|
4a103511da | ||
|
|
171b8a38ca | ||
|
|
0ad89105b3 | ||
|
|
dc584af2f2 | ||
|
|
bd6832705f | ||
|
|
823c6e7dee | ||
|
|
98a7965c52 | ||
|
|
8620841932 | ||
|
|
060797a233 | ||
|
|
16bacc29df | ||
|
|
5e123463f7 | ||
|
|
532798db76 | ||
|
|
e3e074248e | ||
|
|
b73f993475 | ||
|
|
bc804a666b | ||
|
|
5ffb428bb8 | ||
|
|
66b5039337 | ||
|
|
a12f7fa8b0 | ||
|
|
3edbcdeb64 | ||
|
|
2e4e0b811d | ||
|
|
131bafa825 | ||
|
|
557d9e808e | ||
|
|
eebebbb637 | ||
|
|
b973b9e270 | ||
|
|
d443eabd69 | ||
|
|
835299fab5 | ||
|
|
ee311c0bc4 | ||
|
|
cd5b0698d3 | ||
|
|
67fbd4d152 | ||
|
|
415a092257 | ||
|
|
530f217445 | ||
|
|
da5b5e7c45 | ||
|
|
06dc8889b5 | ||
|
|
3987b64209 | ||
|
|
1242b5d2ed | ||
|
|
ae732074e2 | ||
|
|
d386dcbd65 | ||
|
|
cc694ed5bb | ||
|
|
bb1cba5e20 | ||
|
|
5a8764df05 | ||
|
|
ad2e6a107e | ||
|
|
c16fe559e0 | ||
|
|
9af0bc4b96 | ||
|
|
f1d8a14f75 |
98 changed files with 21403 additions and 500 deletions
BIN
._youtube.md
Normal file
BIN
._youtube.md
Normal file
Binary file not shown.
|
|
@ -7,8 +7,5 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
|
||||
COPY . .
|
||||
|
||||
# Make startup script executable
|
||||
RUN chmod +x start.sh
|
||||
|
||||
# Run migrations then start server
|
||||
CMD ["./start.sh"]
|
||||
# Run migrations then start server (shell form handles permissions)
|
||||
CMD ["sh", "start.sh"]
|
||||
|
|
|
|||
213
backend/alembic/versions/65c515b4722a_add_slugs.py
Normal file
213
backend/alembic/versions/65c515b4722a_add_slugs.py
Normal 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
146
backend/email_service.py
Normal 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
207
backend/fetch_youtube.py
Normal 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()
|
||||
|
|
@ -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
255
backend/import_youtube.py
Normal 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()
|
||||
|
|
@ -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,6 +34,10 @@ 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():
|
||||
|
|
|
|||
288
backend/migrations/99_fix_db_data.py
Normal file
288
backend/migrations/99_fix_db_data.py
Normal 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()
|
||||
34
backend/migrations/add_email_verification.py
Normal file
34
backend/migrations/add_email_verification.py
Normal 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()
|
||||
43
backend/migrations/add_rating_venue_tour.py
Normal file
43
backend/migrations/add_rating_venue_tour.py
Normal 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()
|
||||
225
backend/migrations/add_slugs.py
Normal file
225
backend/migrations/add_slugs.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -64,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
|
||||
|
|
@ -75,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)
|
||||
|
|
@ -90,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")
|
||||
|
|
@ -109,6 +113,7 @@ 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)
|
||||
|
|
@ -148,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")
|
||||
|
||||
|
|
@ -167,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")
|
||||
|
|
@ -196,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)
|
||||
|
|
@ -211,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")
|
||||
|
|
@ -293,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()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,3 +11,5 @@ argon2-cffi
|
|||
psycopg2-binary
|
||||
requests
|
||||
beautifulsoup4
|
||||
boto3
|
||||
email-validator
|
||||
|
|
|
|||
434
backend/routers/admin.py
Normal file
434
backend/routers/admin.py
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
327
backend/routers/chase.py
Normal 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
|
||||
)
|
||||
371
backend/routers/gamification.py
Normal file
371
backend/routers/gamification.py
Normal 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!"
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
124
backend/routers/videos.py
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
151
backend/services/email_service.py
Normal file
151
backend/services/email_service.py
Normal 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)
|
||||
470
backend/services/gamification.py
Normal file
470
backend/services/gamification.py
Normal 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
134
backend/slugify.py
Normal 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}"
|
||||
4962
backend/youtube_videos.json
Normal file
4962
backend/youtube_videos.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,9 +9,14 @@ services:
|
|||
- ./backend:/app
|
||||
- backend_data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
|
||||
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg_db
|
||||
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
|
||||
command: ./start.sh
|
||||
- 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
|
||||
|
|
@ -33,7 +38,7 @@ services:
|
|||
- "traefik.http.routers.elmeg-backend.middlewares=elmeg-strip"
|
||||
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
|
||||
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
- "traefik.docker.network=traefik"
|
||||
# Direct routes for docs (no strip)
|
||||
- "traefik.http.routers.elmeg-backend-docs.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
|
||||
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
|
||||
|
|
@ -66,7 +71,7 @@ services:
|
|||
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.elmeg-frontend.priority=50"
|
||||
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
- "traefik.docker.network=traefik"
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
|
|
|
|||
295
docs/AUDIT_AND_PLAN.md
Normal file
295
docs/AUDIT_AND_PLAN.md
Normal 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
|
||||
127
docs/AWS_SES_BROWSER_AGENT.md
Normal file
127
docs/AWS_SES_BROWSER_AGENT.md
Normal 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
141
docs/AWS_SES_SETUP.md
Normal 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 |
|
||||
62
docs/HANDOFF_2025_12_21.md
Normal file
62
docs/HANDOFF_2025_12_21.md
Normal 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.
|
||||
36
docs/HANDOFF_2025_12_22.md
Normal file
36
docs/HANDOFF_2025_12_22.md
Normal 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.
|
||||
323
docs/PLATFORM_ENHANCEMENT_SPEC.md
Normal file
323
docs/PLATFORM_ENHANCEMENT_SPEC.md
Normal 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
|
||||
177
docs/VIDEO_INTEGRATION_SPEC.md
Normal file
177
docs/VIDEO_INTEGRATION_SPEC.md
Normal 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
147
email/README.md
Normal 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
5183
email/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
email/package.json
Normal file
33
email/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
234
email/scripts/deploy-templates.ts
Normal file
234
email/scripts/deploy-templates.ts
Normal 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
209
email/src/email-service.ts
Normal 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
176
email/src/examples.ts
Normal 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
306
email/templates/README.md
Normal 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
26
email/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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() {
|
||||
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>
|
||||
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="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>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Select a category from the sidebar to manage content.</p>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
110
frontend/app/forgot-password/page.tsx
Normal file
110
frontend/app/forgot-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,17 +29,28 @@ 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")}>
|
||||
<AuthProvider>
|
||||
<PreferencesProvider>
|
||||
<Navbar />
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</PreferencesProvider>
|
||||
</AuthProvider>
|
||||
<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 />
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</PreferencesProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,19 +340,31 @@ 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>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="destructive" className="uppercase text-[10px]">
|
||||
{report.reason}
|
||||
</Badge>
|
||||
<span className="font-semibold">{report.entity_type} #{report.entity_id}</span>
|
||||
<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]">
|
||||
{report.reason}
|
||||
</Badge>
|
||||
<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-xs text-muted-foreground">
|
||||
Reported by User #{report.user_id} • {new Date(report.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{report.details && (
|
||||
<p className="text-sm italic text-muted-foreground mb-2">"{report.details}"</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reported by User #{report.user_id} • {new Date(report.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
|
|
@ -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>
|
||||
<p className="font-bold text-lg">"{item.nickname}"</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 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="text-sm text-muted-foreground">
|
||||
Performance #{item.performance_id} • User #{item.suggested_by}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<Link href={`/shows/${performance.show.id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
{/* 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}
|
||||
{performance.nicknames.length > 0 && (
|
||||
<span className="text-xl text-muted-foreground font-normal">
|
||||
"{performance.nicknames[0].nickname}"
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{new Date(performance.show.date).toLocaleDateString()}
|
||||
</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="outline" size="icon" className="mt-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
{performance.show.venue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{performance.show.venue.name}
|
||||
<div>
|
||||
{/* 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>
|
||||
|
||||
{/* 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" />
|
||||
{formattedDate}
|
||||
</Link>
|
||||
{performance.show.venue && (
|
||||
<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}, {performance.show.venue.city}
|
||||
{performance.show.venue.state && `, ${performance.show.venue.state}`}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rating Box */}
|
||||
<div className="md:text-right">
|
||||
<div className="text-xs uppercase font-medium text-muted-foreground mb-2">
|
||||
Rate This Version
|
||||
</div>
|
||||
<SocialWrapper type="ratings">
|
||||
<EntityRating entityType="performance" entityId={performance.id} />
|
||||
</SocialWrapper>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
{/* 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="flex items-center justify-between pt-4 border-t">
|
||||
<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">
|
||||
← 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>
|
||||
← 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 →
|
||||
<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 →
|
||||
</Button>
|
||||
<div className="flex-1 p-3 rounded-md bg-muted/50 text-center text-sm text-muted-foreground">
|
||||
Most Recent 🕐
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{performance.segue && (
|
||||
<div className="text-sm font-medium text-primary mt-2">
|
||||
Segue into next song >
|
||||
</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>
|
||||
|
|
|
|||
142
frontend/app/performances/page.tsx
Normal file
142
frontend/app/performances/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
171
frontend/app/reset-password/page.tsx
Normal file
171
frontend/app/reset-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialo
|
|||
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 {
|
||||
|
|
@ -172,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">></span>}
|
||||
</div>
|
||||
|
|
@ -211,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">
|
||||
|
|
@ -248,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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,23 +72,25 @@ export default async function TourDetailPage({ params }: { params: Promise<{ id:
|
|||
<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">
|
||||
<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>
|
||||
{[...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">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium group-hover:underline">
|
||||
{new Date(show.date).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{show.venue && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{show.venue.name}, {show.venue.city}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{show.venue && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{show.venue.name}, {show.venue.city}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No shows found for this tour.</p>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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
|
||||
}
|
||||
interface Venue {
|
||||
id: number
|
||||
name: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
capacity: number | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
interface Show {
|
||||
id: number
|
||||
slug?: string
|
||||
date: string
|
||||
tour?: { name: string }
|
||||
performances?: any[]
|
||||
}
|
||||
|
||||
export default async function VenueDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const venue = await getVenue(id)
|
||||
const shows = await getVenueShows(id)
|
||||
export default function VenueDetailPage() {
|
||||
const params = useParams()
|
||||
const id = params.id as string
|
||||
|
||||
if (!venue) {
|
||||
notFound()
|
||||
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 {
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{venue.name}</h1>
|
||||
<p className="text-muted-foreground flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{venue.city}, {venue.state} {venue.country}
|
||||
</p>
|
||||
<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 className="flex-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{venue.name}</h1>
|
||||
<p className="text-muted-foreground flex items-center gap-2 mt-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
{/* Shows List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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) => (
|
||||
<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: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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">No shows found for this venue.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// 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}
|
||||
</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
107
frontend/app/verify-email/page.tsx
Normal file
107
frontend/app/verify-email/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
211
frontend/app/videos/page.tsx
Normal file
211
frontend/app/videos/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -180,7 +180,7 @@ export default function WelcomePage() {
|
|||
<Switch
|
||||
id="wiki-mode"
|
||||
checked={wikiMode}
|
||||
onCheckedChange={setWikiMode}
|
||||
onChange={(e) => setWikiMode(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
116
frontend/components/chase/mark-caught-button.tsx
Normal file
116
frontend/components/chase/mark-caught-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
147
frontend/components/gamification/level-progress.tsx
Normal file
147
frontend/components/gamification/level-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
frontend/components/gamification/xp-leaderboard.tsx
Normal file
149
frontend/components/gamification/xp-leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,6 +46,9 @@ 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>
|
||||
|
|
@ -61,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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
209
frontend/components/profile/attendance-summary.tsx
Normal file
209
frontend/components/profile/attendance-summary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
259
frontend/components/profile/chase-songs-list.tsx
Normal file
259
frontend/components/profile/chase-songs-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
11
frontend/components/theme-provider.tsx
Normal file
11
frontend/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
39
frontend/components/theme-toggle.tsx
Normal file
39
frontend/components/theme-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
frontend/components/ui/avatar.tsx
Normal file
62
frontend/components/ui/avatar.tsx
Normal 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 }
|
||||
30
frontend/components/ui/checkbox.tsx
Normal file
30
frontend/components/ui/checkbox.tsx
Normal 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 }
|
||||
28
frontend/components/ui/progress.tsx
Normal file
28
frontend/components/ui/progress.tsx
Normal 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 }
|
||||
44
frontend/components/ui/radio-group.tsx
Normal file
44
frontend/components/ui/radio-group.tsx
Normal 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 }
|
||||
192
frontend/components/ui/rating-input.tsx
Normal file
192
frontend/components/ui/rating-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
frontend/components/ui/select.tsx
Normal file
160
frontend/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -1,47 +1,125 @@
|
|||
import { useState } from "react"
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { Star } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
interface RatingProps {
|
||||
value: number
|
||||
onChange?: (value: number) => void
|
||||
readonly?: boolean
|
||||
className?: string
|
||||
size?: "sm" | "md"
|
||||
size?: "sm" | "md" | "lg"
|
||||
precision?: "full" | "half" | "decimal"
|
||||
}
|
||||
|
||||
export function StarRating({ value, onChange, readonly = false, className, size = "md" }: RatingProps) {
|
||||
export function StarRating({
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
className,
|
||||
size = "md",
|
||||
precision = "decimal"
|
||||
}: RatingProps) {
|
||||
const [hoverValue, setHoverValue] = useState<number | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const stars = Array.from({ length: 10 }, (_, i) => i + 1)
|
||||
const starSize = size === "sm" ? "h-3 w-3" : "h-4 w-4"
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
const starSize = {
|
||||
sm: "h-3 w-3",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5"
|
||||
}[size]
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent, starIndex: number) => {
|
||||
if (readonly) return
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const width = rect.width
|
||||
const fraction = x / width
|
||||
|
||||
let newValue: number
|
||||
if (precision === "decimal") {
|
||||
// Allow 0.1 precision (e.g., 9.2)
|
||||
newValue = starIndex + Math.round(fraction * 10) / 10
|
||||
newValue = Math.max(starIndex, Math.min(starIndex + 1, newValue))
|
||||
} else if (precision === "half") {
|
||||
// Half-star precision
|
||||
newValue = fraction < 0.5 ? starIndex + 0.5 : starIndex + 1
|
||||
} else {
|
||||
// Full star only
|
||||
newValue = starIndex + 1
|
||||
}
|
||||
|
||||
setHoverValue(newValue)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (!readonly && hoverValue !== null && onChange) {
|
||||
// Round to 1 decimal place
|
||||
onChange(Math.round(hoverValue * 10) / 10)
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = hoverValue !== null ? hoverValue : value
|
||||
|
||||
// Calculate fill percentage for each star
|
||||
const getStarFill = (starIndex: number): number => {
|
||||
const starStart = starIndex
|
||||
const starEnd = starIndex + 1
|
||||
|
||||
if (displayValue >= starEnd) return 100
|
||||
if (displayValue <= starStart) return 0
|
||||
return (displayValue - starStart) * 100
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-0.5", className)}>
|
||||
{stars.map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
className={cn(
|
||||
"p-0.5 transition-colors",
|
||||
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
|
||||
)}
|
||||
onMouseEnter={() => !readonly && setHoverValue(star)}
|
||||
onMouseLeave={() => !readonly && setHoverValue(null)}
|
||||
onClick={() => !readonly && onChange?.(star)}
|
||||
>
|
||||
<Star
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("flex gap-0.5 items-center", className)}
|
||||
onMouseLeave={() => !readonly && setHoverValue(null)}
|
||||
>
|
||||
{stars.map((star, index) => {
|
||||
const fillPercent = getStarFill(index)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={readonly}
|
||||
className={cn(
|
||||
starSize,
|
||||
(hoverValue !== null ? star <= hoverValue : star <= value)
|
||||
? "fill-primary text-primary"
|
||||
: "fill-muted text-muted-foreground"
|
||||
"p-0.5 transition-transform relative",
|
||||
readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
onMouseMove={(e) => handleMouseMove(e, index)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background star (empty) */}
|
||||
<Star
|
||||
className={cn(starSize, "fill-muted text-muted-foreground")}
|
||||
/>
|
||||
{/* Foreground star (filled) - uses clip-path for partial fill */}
|
||||
<div
|
||||
className="absolute inset-0 p-0.5 overflow-hidden"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - fillPercent}% 0 0)`
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={cn(starSize, "fill-primary text-primary")}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Display numeric value */}
|
||||
{!readonly && (
|
||||
<span className="ml-2 text-sm font-mono text-muted-foreground min-w-[2.5rem]">
|
||||
{displayValue > 0 ? displayValue.toFixed(1) : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface User {
|
|||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
login: (token: string) => Promise<void>
|
||||
logout: () => void
|
||||
|
|
@ -20,6 +21,7 @@ interface AuthContextType {
|
|||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true,
|
||||
login: async () => { },
|
||||
logout: () => { },
|
||||
|
|
@ -27,17 +29,20 @@ const AuthContext = createContext<AuthContextType>({
|
|||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const token = localStorage.getItem("token")
|
||||
if (token) {
|
||||
const storedToken = localStorage.getItem("token")
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
try {
|
||||
await fetchUser(token)
|
||||
await fetchUser(storedToken)
|
||||
} catch (err) {
|
||||
console.error("Auth init failed", err)
|
||||
localStorage.removeItem("token")
|
||||
setToken(null)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
|
|
@ -59,18 +64,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}
|
||||
|
||||
const login = async (token: string) => {
|
||||
localStorage.setItem("token", token)
|
||||
await fetchUser(token)
|
||||
const login = async (newToken: string) => {
|
||||
localStorage.setItem("token", newToken)
|
||||
setToken(newToken)
|
||||
await fetchUser(newToken)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token")
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
|
|
|||
268
frontend/package-lock.json
generated
268
frontend/package-lock.json
generated
|
|
@ -8,11 +8,16 @@
|
|||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.5",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-switch": "^1.1.5",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -21,6 +26,7 @@
|
|||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "16.0.6",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"recharts": "^3.6.0",
|
||||
|
|
@ -2316,6 +2322,36 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
|
|
@ -2781,6 +2817,118 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz",
|
||||
"integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
|
||||
"integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
|
|
@ -2843,6 +2991,49 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
|
|
@ -2861,6 +3052,35 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
|
|
@ -2976,6 +3196,21 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
|
|
@ -3012,6 +3247,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
|
|
@ -9531,6 +9789,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
|
|
|
|||
|
|
@ -10,12 +10,15 @@
|
|||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.2.5",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-switch": "^1.1.5",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -25,6 +28,7 @@
|
|||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "16.0.6",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"recharts": "^3.6.0",
|
||||
|
|
|
|||
38
youtube.md
Normal file
38
youtube.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
GOOSE YOUTUBE VIDEO DATABASE
|
||||
Generated: December 21, 2025
|
||||
For elmeg.xyz Integration
|
||||
|
||||
INSTRUCTIONS:
|
||||
\- Use 'videoId' for YouTube embeds: https://youtube.com/embed/{videoId}
|
||||
\- Match by 'date' field (YYYY-MM-DD format) to link with setlists
|
||||
\- 'type' values: song, sequence, full\_show, documentary, visualizer, session
|
||||
\- Songs with → symbol are jam sequences
|
||||
|
||||
Download complete JSON: https://docs.google.com/document/d/1UsCSnwgVoAE\_6daf66xyGnwHrrwbi-sczcOZvynAxs4
|
||||
|
||||
\===============================================
|
||||
FIRST 100 VIDEOS (Batch 1 of 7\)
|
||||
\===============================================
|
||||
|
||||
\`\`\`json
|
||||
\[
|
||||
{"videoId":"PvdtMSifDqU","title":"Thatch","date":"2025-12-13","venue":"Providence, RI","type":"song"},
|
||||
{"videoId":"7vujKUzGtcg","title":"Give It Time","date":"2025-12-13","venue":"Providence, RI","type":"song"},
|
||||
{"videoId":"nCnYJaIxBzo","title":"Pigs (Three Different Ones)","date":"2025-12-12","venue":"Providence, RI","type":"song"},
|
||||
{"videoId":"Yeno3bJs4Ws","title":"Jed Stone → Master & Hound → Sugar Mountain","date":"2025-12-12","venue":"Providence, RI","type":"sequence"},
|
||||
{"videoId":"zQI6-LloYwI","title":"Dramophone → The Empress of Organos","date":"2025-12-13","venue":"Providence, RI","type":"sequence"},
|
||||
{"videoId":"F66jL0skeT4","title":"Arrow → Burn The Witch","date":"2025-12-12","venue":"Providence, RI","type":"sequence"},
|
||||
{"videoId":"JdWnhOIWh-I","title":"Show Upon Time","type":"documentary"},
|
||||
{"videoId":"wtTiAwA5Ha4","title":"Goose Live at Viva El Gonzo 2025 \- Night 3","event":"Viva El Gonzo 2025","type":"full\_show"},
|
||||
{"videoId":"USQNba0t-4A","title":"Goose Live at Viva El Gonzo 2025 \- Night 2","event":"Viva El Gonzo 2025","type":"full\_show"},
|
||||
{"videoId":"vCdiwBSGtpk","title":"Goose Live at Viva El Gonzo 2025 \- Night 1","event":"Viva El Gonzo 2025","type":"full\_show"},
|
||||
{"videoId":"1BbtYhhCMWs","title":"Jed Stone","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"yCPFRcIByqI","title":"My Mind Has Been Consumed By Media","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"vE1F77CZbXQ","title":"Hungersite","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"rek58BRByTw","title":"Factory Fiction","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"rhP0-gKD\_d8","title":"A Western Sun","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"jMhayG6WCIw","title":"Running Up That Hill","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"osPxSR5GmX8","title":"Animal","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"cyLYgo9r3xM","title":"Dripfield","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"nZE\_w8hDukI","title":"Don't Leave Me This Way","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
{"videoId":"W93zvUz4vyI","title":"Arcadia","date":"2025-06-28","venue":"Madison Square Garden, NY","type":"song"},
|
||||
Loading…
Add table
Reference in a new issue