Compare commits

..

13 commits

Author SHA1 Message Date
fullsizemalt
8d1ef4e915 Merge branch 'main' into production
Some checks failed
Deploy Elmeg / deploy (push) Has been cancelled
2025-12-21 13:00:14 -08:00
fullsizemalt
96aafce53f Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-21 12:58:46 -08:00
fullsizemalt
f7763199ff Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:50:16 -08:00
fullsizemalt
d520ec4c86 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:46:39 -08:00
fullsizemalt
1d0464c219 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:41:53 -08:00
fullsizemalt
bf2f52da81 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:33:30 -08:00
fullsizemalt
ac755ab7b0 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:23:26 -08:00
fullsizemalt
f91bc1b826 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 03:19:33 -08:00
fullsizemalt
aa1e630809 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 02:54:12 -08:00
fullsizemalt
e83d3de8c3 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 02:52:32 -08:00
fullsizemalt
e683398364 Merge branch 'main' into production
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2025-12-21 02:49:52 -08:00
fullsizemalt
5d27045819 feat(frontend): Integrate ReportDialog into CommentSection
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-21 02:47:17 -08:00
fullsizemalt
6bb0af937a feat(frontend): Implement threaded comment UI
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-21 02:44:26 -08:00
104 changed files with 1037 additions and 21577 deletions

Binary file not shown.

View file

@ -0,0 +1,66 @@
# 📊 Elmeg "Maximum Utility" Audit & Upgrade Plan
## 1. Executive Summary
The application has solid foundational data structures (Shows, Songs, Venues, Users) and initial social features (Ratings, Reviews). However, the **Presentation Layer** relies heavily on text lists and static tables. To achieve "Maximum Utility," we need to visualize this data to reveal trends, and add "Visual Delight" to encourage interaction.
## 2. Identified Gaps & Opportunities
### A. Data Visualization (Charts & Graphs)
*Current State:* Numeric averages and counts.
*Upgrade Potential:*
1. **Song Evolution Graph (Song Detail):** A scatter plot showing Rating vs. Date. visualizes if a song is getting better or worse over time ("The 2024 Peak").
2. **User Attendance Heatmap (Profile):** A GitHub-style commit calendar showing show attendance frequency over the year.
3. **Rating Distribution (Show Detail):** A bar chart showing the breakdown of ratings (How many 5s? How many 1s?) to see if a show is "divisive" or "universally loved".
4. **Venue Map (Venue Detail):** A visual map indicating location.
### B. Visual Delight (SVG Animations)
*Current State:* Standard browser interactions.
*Upgrade Potential:*
1. **"Heady" Rating Interaction:** When a user rates a show 5 stars, trigger a confetti or "fire" SVG animation.
2. **Page Transitions:** Smooth cross-fade or slide when navigating between tabs (Tabs are currently instant/harsh).
3. **Loading Skeletons:** Animated shimmers instead of "Loading..." text.
4. **Micro-interactions:** Animated heart icons, subtle zoom on card hover.
### C. Clarity & UX Enhancements
*Current State:* Functional but dense.
*Upgrade Potential:*
1. **"Sparklines" in Lists:** Small trend lines in the Leaderboard to show if a song is "trending up".
2. **Contextual Empty States:** Better illustrations when no data exists (e.g., an empty tour bus SVG for "No Shows").
## 3. Recommended Implementation Plan (Sprints)
### Sprint 1: The Visualizer (Charts)
* **Action:** Install `recharts` (lightweight, native React).
* **Deliverable:**
* Implement **Song Rating History Scatter Plot**.
* Implement **User Attendance Heatmap**.
### Sprint 2: The Aliveness (Animations)
* **Action:** Install `framer-motion` (industry standard for React animation).
* **Deliverable:**
* Add **Page Transitions** to Tab switching.
* Add **Micro-interactions** to Rating buttons.
* Add **Animated Skeletons** for loading states.
### Sprint 3: The Deep Dive (Advanced Stats)
* **Action:** Backend aggregation for complex stats.
* **Deliverable:**
* "Gap" charts (frequency of play over tours).
* "Rating Distribution" histograms.
## 4. Immediate Next Step Proposal
I recommend starting with **Sprint 1** to immediately boost the "Professional/Premium" feel of the data pages.
**Command to run:**
`npm install recharts framer-motion clsx tailwind-merge` (Ensure basics are there)

View file

@ -0,0 +1,98 @@
# 🚀 Elmeg Platform Expansion Spec
*Moving from "Utility" to "Community"*
## 1. Module: The "Social Loop" (Interactions & Notifications)
*Objective: Increase user retention by creating feedback loops.*
### A. "Likes" & Reactions
**Problem:** Users can only write text. No lightweight way to acknowledge others.
**Solution:** Implement a polymorphic `Reaction` system.
* **Backend:**
* Create `Reaction` model (`user_id`, `entity_type`, `entity_id`, `emoji`).
* API: `POST /react`, `DELETE /react`.
* **Frontend:**
* Add "Heart" / "Like" button to Reviews and Comments.
* Display count next to the button.
### B. Threaded Conversations
**Problem:** Comments are linear. Replies get lost.
**Solution:** infinite nesting (or shallow nesting) for comments.
* **Backend:** Add `parent_id` to `Comment`.
* **Frontend:** "Reply" button reveals nested input. indent child comments.
### C. The Notification Center
**Problem:** Users don't know when someone interacts with them.
**Solution:** A centralized inbox for alerts.
* **Triggers:**
* User B replies to User A -> Notify User A.
* User B likes User A's review -> Notify User A.
* **Backend:** `GET /notifications` (paginated), `POST /notifications/{id}/read`.
* **Frontend:**
* Bell icon in header with red dot badge.
* Dropdown or dedicated page listing alerts.
* Clicking clears "unread" state and navigates to content.
---
## 2. Module: The "Safe Space" (Moderation)
*Objective: Protect the community as it scales.*
### A. Reporting System
**Problem:** Bad actors can post spam/abuse.
**Solution:** Crowd-sourced flagging.
* **Frontend:** "Flag" icon on every user-generated content (UGC).
* Modal: Select reason (Spam, Harassment, Spoiler).
* **Backend:** `POST /reports`.
### B. Admin Dashboard
**Problem:** Reports go into a black hole (database only).
**Solution:** A UI for Moderators.
* **Route:** `/admin/reports` (Protected: Role >= Moderator).
* **Features:**
* List pending reports.
* "Resolve" (Delete Content + Warn User).
* "Dismiss" (False Alarm).
---
## 3. Module: The "Velvet Rope" (User Journey & Identity)
*Objective: Make the user feel special and secure.*
### A. Onboarding Flow
**Problem:** Registration dumps user on the homepage with no direction.
**Solution:** A "Welcome Wizard".
1. **Welcome:** "Thanks for joining the flock!"
2. **Setup:** Upload Avatar (or generate one).
3. **Taste:** "Pick your favorite 3 shows" (seeds recommendation engine later).
### B. Identity Verification
**Problem:** Anyone can sign up with fake emails.
**Solution:** Email Verification (optional for MVP, maybe "Verified User" badge).
---
## 4. Proposed Execution Order
1. **Phase 1: Notifications & Reactions** (High visibility, high engagement).
2. **Phase 2: Moderation** (Essential before public launch).
3. **Phase 3: Deep User Journey** (Polish).
**Action Item:**
Does this spec cover the requested scope? Shall we begin with **Phase 1 (Notifications)**?

View file

@ -7,5 +7,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# Run migrations then start server (shell form handles permissions) # Make startup script executable
CMD ["sh", "start.sh"] RUN chmod +x start.sh
# Run migrations then start server
CMD ["./start.sh"]

View file

@ -1,213 +0,0 @@
"""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 ###

View file

@ -1,146 +0,0 @@
"""
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)

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
from fastapi import FastAPI from 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, admin, chase, gamification, videos 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 fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -34,10 +34,6 @@ app.include_router(notifications.router)
app.include_router(feed.router) app.include_router(feed.router)
app.include_router(leaderboards.router) app.include_router(leaderboards.router)
app.include_router(stats.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("/") @app.get("/")
def read_root(): def read_root():

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,6 @@ from datetime import datetime
class Performance(SQLModel, table=True): class Performance(SQLModel, table=True):
"""Link table between Show and Song (Many-to-Many with extra data)""" """Link table between Show and Song (Many-to-Many with extra data)"""
id: Optional[int] = Field(default=None, primary_key=True) 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") show_id: int = Field(foreign_key="show.id")
song_id: int = Field(foreign_key="song.id") song_id: int = Field(foreign_key="song.id")
position: int = Field(description="Order in the setlist") position: int = Field(description="Order in the setlist")
@ -65,7 +64,6 @@ class Vertical(SQLModel, table=True):
class Venue(SQLModel, table=True): class Venue(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True) name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
city: str city: str
state: Optional[str] = Field(default=None) state: Optional[str] = Field(default=None)
country: str country: str
@ -77,7 +75,6 @@ class Venue(SQLModel, table=True):
class Tour(SQLModel, table=True): class Tour(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True) name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
start_date: Optional[datetime] = None start_date: Optional[datetime] = None
end_date: Optional[datetime] = None end_date: Optional[datetime] = None
notes: Optional[str] = Field(default=None) notes: Optional[str] = Field(default=None)
@ -93,7 +90,6 @@ class Artist(SQLModel, table=True):
class Show(SQLModel, table=True): class Show(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
date: datetime = Field(index=True) date: datetime = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
vertical_id: int = Field(foreign_key="vertical.id") vertical_id: int = Field(foreign_key="vertical.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id") venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id") tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
@ -113,7 +109,6 @@ class Show(SQLModel, table=True):
class Song(SQLModel, table=True): class Song(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(index=True) title: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
original_artist: Optional[str] = Field(default=None) original_artist: Optional[str] = Field(default=None)
vertical_id: int = Field(foreign_key="vertical.id") vertical_id: int = Field(foreign_key="vertical.id")
notes: Optional[str] = Field(default=None) notes: Optional[str] = Field(default=None)
@ -153,14 +148,12 @@ class Comment(SQLModel, table=True):
class Rating(SQLModel, table=True): class Rating(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
score: float = Field(ge=1.0, le=10.0, description="Rating from 1.0 to 10.0") score: int = Field(ge=1, le=10, description="Rating from 1 to 10")
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
show_id: Optional[int] = Field(default=None, foreign_key="show.id") show_id: Optional[int] = Field(default=None, foreign_key="show.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id") song_id: Optional[int] = Field(default=None, foreign_key="song.id")
performance_id: Optional[int] = Field(default=None, foreign_key="performance.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") user: "User" = Relationship(back_populates="ratings")
@ -174,29 +167,6 @@ class User(SQLModel, table=True):
bio: Optional[str] = Field(default=None) bio: Optional[str] = Field(default=None)
avatar: 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 # Multi-identity support: A user can have multiple Profiles
profiles: List["Profile"] = Relationship(back_populates="user") profiles: List["Profile"] = Relationship(back_populates="user")
comments: List["Comment"] = Relationship(back_populates="user") comments: List["Comment"] = Relationship(back_populates="user")
@ -226,9 +196,6 @@ class Badge(SQLModel, table=True):
description: str description: str
icon: str = Field(description="Lucide icon name or image URL") icon: str = Field(description="Lucide icon name or image URL")
slug: str = Field(unique=True, index=True) 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): class UserBadge(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
@ -244,7 +211,7 @@ class Review(SQLModel, table=True):
user_id: int = Field(foreign_key="user.id") user_id: int = Field(foreign_key="user.id")
blurb: str = Field(description="One-liner/pullquote") blurb: str = Field(description="One-liner/pullquote")
content: str = Field(description="Full review text") content: str = Field(description="Full review text")
score: float = Field(ge=1.0, le=10.0) score: int = Field(ge=1, le=10)
show_id: Optional[int] = Field(default=None, foreign_key="show.id") show_id: Optional[int] = Field(default=None, foreign_key="show.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id") venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id") song_id: Optional[int] = Field(default=None, foreign_key="song.id")
@ -326,18 +293,3 @@ class Reaction(SQLModel, table=True):
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship() user: User = Relationship()
class ChaseSong(SQLModel, table=True):
"""Songs a user wants to see live (hasn't seen performed yet or wants to see again)"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
song_id: int = Field(foreign_key="song.id", index=True)
priority: int = Field(default=1, description="1=high, 2=medium, 3=low")
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
caught_at: Optional[datetime] = Field(default=None, description="When they finally saw it")
caught_show_id: Optional[int] = Field(default=None, foreign_key="show.id")
user: User = Relationship()
song: "Song" = Relationship()

View file

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

View file

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

View file

@ -5,7 +5,6 @@ from database import get_session
from models import Attendance, User, Show from models import Attendance, User, Show
from schemas import AttendanceCreate, AttendanceRead from schemas import AttendanceCreate, AttendanceRead
from auth import get_current_user 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"]) router = APIRouter(prefix="/attendance", tags=["attendance"])
@ -33,12 +32,6 @@ def mark_attendance(
db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id) db_attendance = Attendance(**attendance.model_dump(), user_id=current_user.id)
session.add(db_attendance) 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.commit()
session.refresh(db_attendance) session.refresh(db_attendance)
return db_attendance return db_attendance

View file

@ -1,56 +1,24 @@
from datetime import timedelta, datetime from datetime import timedelta
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import Session, select from sqlmodel import Session, select
from pydantic import BaseModel, EmailStr
from database import get_session from database import get_session
from models import User, Profile from models import User, Profile
from schemas import UserCreate, Token, UserRead 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 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"]) 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) @router.post("/register", response_model=UserRead)
async def register( def register(user_in: UserCreate, session: Session = Depends(get_session)):
user_in: UserCreate,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session)
):
user = session.exec(select(User).where(User.email == user_in.email)).first() user = session.exec(select(User).where(User.email == user_in.email)).first()
if user: if user:
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(status_code=400, detail="Email already registered")
# Create User with verification token # Create User
hashed_password = get_password_hash(user_in.password) hashed_password = get_password_hash(user_in.password)
verification_token = generate_token() db_user = User(email=user_in.email, hashed_password=hashed_password)
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.add(db_user)
session.commit() session.commit()
session.refresh(db_user) session.refresh(db_user)
@ -60,112 +28,8 @@ async def register(
session.add(profile) session.add(profile)
session.commit() session.commit()
# Send verification email in background
background_tasks.add_task(send_verification_email, db_user.email, verification_token)
return db_user 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) @router.post("/token", response_model=Token)
def login_for_access_token( def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
@ -185,8 +49,6 @@ def login_for_access_token(
) )
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.get("/users/me", response_model=UserRead) @router.get("/users/me", response_model=UserRead)
def read_users_me(current_user: Annotated[User, Depends(get_current_user)]): def read_users_me(current_user: Annotated[User, Depends(get_current_user)]):
return current_user return current_user

View file

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

View file

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

View file

@ -1,10 +1,8 @@
from typing import List, Optional from typing import List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func from sqlmodel import Session, select
from pydantic import BaseModel
from database import get_session from database import get_session
from models import Report, User, PerformanceNickname, Profile, Rating, Review, Comment, Attendance from models import Report, User, PerformanceNickname
from schemas import ReportCreate, ReportRead, PerformanceNicknameRead from schemas import ReportCreate, ReportRead, PerformanceNicknameRead
from auth import get_current_user from auth import get_current_user
from dependencies import RoleChecker from dependencies import RoleChecker
@ -13,246 +11,6 @@ router = APIRouter(prefix="/moderation", tags=["moderation"])
allow_moderator = RoleChecker(["moderator", "admin"]) 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) @router.post("/reports", response_model=ReportRead)
def create_report( def create_report(
report: ReportCreate, report: ReportCreate,
@ -323,4 +81,3 @@ def moderate_report(
session.commit() session.commit()
session.refresh(report) session.refresh(report)
return report return report

View file

@ -1,44 +1,36 @@
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func from sqlmodel import Session, select
from database import get_session from database import get_session
from models import Performance, PerformanceNickname, Tag, EntityTag, Show, Venue, Rating from models import Performance, PerformanceNickname, Tag, EntityTag
from schemas import PerformanceDetailRead, PerformanceNicknameCreate, PerformanceNicknameRead, PerformanceReadWithShow from schemas import PerformanceDetailRead, PerformanceNicknameCreate, PerformanceNicknameRead
from auth import get_current_user from auth import get_current_user
router = APIRouter(prefix="/performances", tags=["performances"]) router = APIRouter(prefix="/performances", tags=["performances"])
@router.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead) @router.get("/{performance_id}", response_model=PerformanceDetailRead)
def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)): def read_performance(performance_id: int, session: Session = Depends(get_session)):
performance = None performance = session.get(Performance, performance_id)
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: if not performance:
raise HTTPException(status_code=404, detail="Performance not found") raise HTTPException(status_code=404, detail="Performance not found")
performance_id = performance.id # Use actual ID for lookups
# --- Calculate Stats & Navigation --- # --- Calculate Stats & Navigation ---
from sqlmodel import select, func, desc
from models import Show
# Get all performances of this song, ordered by date # Get all performances of this song, ordered by date
# Join Show and Venue for list display # We need to join Show to order by date
all_perfs_data = session.exec( all_perfs = session.exec(
select(Performance, Show, Venue) select(Performance, Show.date)
.join(Show, Performance.show_id == Show.id) .join(Show)
.outerjoin(Venue, Show.venue_id == Venue.id)
.where(Performance.song_id == performance.song_id) .where(Performance.song_id == performance.song_id)
.order_by(Show.date) .order_by(Show.date)
).all() ).all()
# Find current index # Find current index
# all_perfs is a list of tuples (Performance, date)
current_index = -1 current_index = -1
for i, (p, s, v) in enumerate(all_perfs_data): for i, (p, d) in enumerate(all_perfs):
if p.id == performance_id: if p.id == performance_id:
current_index = i current_index = i
break break
@ -49,11 +41,12 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
times_played = current_index + 1 # 1-based count times_played = current_index + 1 # 1-based count
if current_index > 0: if current_index > 0:
prev_id = all_perfs_data[current_index - 1][0].id prev_id = all_perfs[current_index - 1][0].id
# Calculate Gap # Calculate Gap
prev_date = all_perfs_data[current_index - 1][1].date # Gap is number of shows between prev performance and this one
current_date = all_perfs_data[current_index][1].date prev_date = all_perfs[current_index - 1][1]
current_date = all_perfs[current_index][1]
gap = session.exec( gap = session.exec(
select(func.count(Show.id)) select(func.count(Show.id))
@ -61,43 +54,8 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
.where(Show.date < current_date) .where(Show.date < current_date)
).one() ).one()
if current_index < len(all_perfs_data) - 1: if current_index < len(all_perfs) - 1:
next_id = all_perfs_data[current_index + 1][0].id next_id = all_perfs[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 # Construct response manually to include extra fields
# We need to ensure nested models (show, song) are validated correctly # We need to ensure nested models (show, song) are validated correctly
@ -109,7 +67,6 @@ def read_performance(performance_id_or_slug: str, session: Session = Depends(get
perf_dict['next_performance_id'] = next_id perf_dict['next_performance_id'] = next_id
perf_dict['gap'] = gap perf_dict['gap'] = gap
perf_dict['times_played'] = times_played perf_dict['times_played'] = times_played
perf_dict['other_performances'] = other_performances
return perf_dict return perf_dict

View file

@ -1,11 +1,10 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func from sqlmodel import Session, select
from database import get_session from database import get_session
from models import Review, User from models import Review, User
from schemas import ReviewCreate, ReviewRead from schemas import ReviewCreate, ReviewRead
from auth import get_current_user 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"]) router = APIRouter(prefix="/reviews", tags=["reviews"])
@ -18,21 +17,6 @@ def create_review(
db_review = Review.model_validate(review) db_review = Review.model_validate(review)
db_review.user_id = current_user.id db_review.user_id = current_user.id
session.add(db_review) 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.commit()
session.refresh(db_review) session.refresh(db_review)
return db_review return db_review

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,470 +0,0 @@
"""
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,
}

View file

@ -1,134 +0,0 @@
"""
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}"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,96 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "127.0.0.1:8020:8000"
volumes:
- ./backend:/app
- backend_data:/app/data
environment:
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
command: uvicorn main:app --host 0.0.0.0 --port 8000 --root-path /api --proxy-headers --forwarded-allow-ips '*'
depends_on:
- db
restart: unless-stopped
healthcheck:
test: [ "CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/docs').raise_for_status()" ]
interval: 30s
timeout: 10s
retries: 3
networks:
- elmeg
- ersen_traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.elmeg-backend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/api`)"
- "traefik.http.routers.elmeg-backend.entrypoints=websecure"
- "traefik.http.routers.elmeg-backend.tls.certresolver=letsencrypt"
- "traefik.http.routers.elmeg-backend.priority=100"
- "traefik.http.middlewares.elmeg-strip.stripprefix.prefixes=/api"
- "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=ersen_traefik-public"
# 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"
- "traefik.http.routers.elmeg-backend-docs.tls.certresolver=letsencrypt"
- "traefik.http.routers.elmeg-backend-docs.priority=100"
- "traefik.http.routers.elmeg-backend-docs.service=elmeg-backend-svc"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "127.0.0.1:3020:3000"
volumes:
- ./frontend:/app
- /app/node_modules
environment:
- NEXT_PUBLIC_API_URL=/api
- INTERNAL_API_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
networks:
- elmeg
- ersen_traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.elmeg-frontend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && !PathPrefix(`/api`, `/docs`, `/openapi.json`)"
- "traefik.http.routers.elmeg-frontend.entrypoints=websecure"
- "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=ersen_traefik-public"
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=elmeg
- POSTGRES_PASSWORD=elmeg_password
- POSTGRES_DB=elmeg
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U elmeg -d elmeg" ]
interval: 10s
timeout: 5s
retries: 5
networks:
- elmeg
volumes:
postgres_data:
backend_data:
networks:
elmeg:
ersen_traefik-public:
external: true

View file

@ -9,14 +9,9 @@ services:
- ./backend:/app - ./backend:/app
- backend_data:/app/data - backend_data:/app/data
environment: environment:
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg_db - DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production} - SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} command: ./start.sh
- 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: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
@ -38,7 +33,7 @@ services:
- "traefik.http.routers.elmeg-backend.middlewares=elmeg-strip" - "traefik.http.routers.elmeg-backend.middlewares=elmeg-strip"
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc" - "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000" - "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik-public"
# Direct routes for docs (no strip) # 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.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure" - "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
@ -71,7 +66,7 @@ services:
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt" - "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
- "traefik.http.routers.elmeg-frontend.priority=50" - "traefik.http.routers.elmeg-frontend.priority=50"
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000" - "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik-public"
db: db:
image: postgres:15-alpine image: postgres:15-alpine

View file

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

View file

@ -1,127 +0,0 @@
# 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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,177 +0,0 @@
# 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

View file

@ -1,147 +0,0 @@
# 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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,234 +0,0 @@
/**
* 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);

View file

@ -1,209 +0,0 @@
/**
* 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
);
}

View file

@ -1,176 +0,0 @@
/**
* 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;
*/

View file

@ -1,306 +0,0 @@
# 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 |

View file

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

View file

@ -1,410 +1,30 @@
"use client" "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 { 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"
interface PlatformStats { export default function AdminDashboard() {
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 ( return (
<div className="container py-8"> <div className="space-y-6">
<div className="animate-pulse space-y-4"> <h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<div className="h-8 bg-muted rounded w-48" /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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>
)
}
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> <Card>
<CardContent className="pt-6"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center gap-2"> <CardTitle className="text-sm font-medium">Pending Nicknames</CardTitle>
<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> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4"> <div className="text-2xl font-bold">--</div>
{stats?.total_shows || 0} shows in database
</p>
<Button variant="outline" size="sm">
Manage Shows
</Button>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-sm font-medium">Pending Reports</CardTitle>
<Music2 className="h-5 w-5" />
Songs
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground mb-4"> <div className="text-2xl font-bold">--</div>
{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> </CardContent>
</Card> </Card>
</div> </div>
</TabsContent> <p className="text-muted-foreground">Select a category from the sidebar to manage content.</p>
</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> </div>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -2,29 +2,11 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Check, X, ShieldAlert, Search, Ban, UserCheck, CheckCircle } from "lucide-react" import { Check, X, ShieldAlert, AlertTriangle } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Badge } from "@/components/ui/badge" 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 { interface PendingNickname {
id: number id: number
@ -45,49 +27,12 @@ interface PendingReport {
status: string 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() { export default function ModDashboardPage() {
const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([]) const [pendingNicknames, setPendingNicknames] = useState<PendingNickname[]>([])
const [pendingReports, setPendingReports] = useState<PendingReport[]>([]) const [pendingReports, setPendingReports] = useState<PendingReport[]>([])
const [stats, setStats] = useState<QueueStats | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState("") 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(() => { useEffect(() => {
fetchQueue() fetchQueue()
}, []) }, [])
@ -101,10 +46,9 @@ export default function ModDashboardPage() {
} }
try { try {
const [nicknamesRes, reportsRes, statsRes] = await Promise.all([ const [nicknamesRes, reportsRes] = await Promise.all([
fetch(`${getApiUrl()}/moderation/queue/nicknames`, { headers: { Authorization: `Bearer ${token}` } }), 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) { if (nicknamesRes.status === 403 || reportsRes.status === 403) {
@ -117,7 +61,6 @@ export default function ModDashboardPage() {
setPendingNicknames(await nicknamesRes.json()) setPendingNicknames(await nicknamesRes.json())
setPendingReports(await reportsRes.json()) setPendingReports(await reportsRes.json())
if (statsRes.ok) setStats(await statsRes.json())
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError("Failed to load moderation queue") setError("Failed to load moderation queue")
@ -126,86 +69,20 @@ 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 handleNicknameAction = async (id: number, action: "approve" | "reject") => {
const token = localStorage.getItem("token") const token = localStorage.getItem("token")
if (!token) return if (!token) return
try { try {
// Note: Updated backend uses PUT for nicknames
const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, { const res = await fetch(`${getApiUrl()}/moderation/nicknames/${id}/${action}`, {
method: "PUT", method: "PUT",
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
if (!res.ok) throw new Error(`Failed to ${action}`) if (!res.ok) throw new Error(`Failed to ${action}`)
// Remove from list
setPendingNicknames(prev => prev.filter(n => n.id !== id)) setPendingNicknames(prev => prev.filter(n => n.id !== id))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -218,12 +95,14 @@ export default function ModDashboardPage() {
if (!token) return if (!token) return
try { try {
// Note: Updated backend uses PUT for reports
const res = await fetch(`${getApiUrl()}/moderation/reports/${id}/${action}`, { const res = await fetch(`${getApiUrl()}/moderation/reports/${id}/${action}`, {
method: "PUT", method: "PUT",
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
if (!res.ok) throw new Error(`Failed to ${action}`) if (!res.ok) throw new Error(`Failed to ${action}`)
setPendingReports(prev => prev.filter(r => r.id !== id)) setPendingReports(prev => prev.filter(r => r.id !== id))
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -231,52 +110,6 @@ 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 (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> if (error) return <div className="p-8 text-center text-red-500 font-bold">{error}</div>
@ -287,51 +120,16 @@ export default function ModDashboardPage() {
Moderator Dashboard Moderator Dashboard
</h1> </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"> <Tabs defaultValue="reports">
<TabsList> <TabsList>
<TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger> <TabsTrigger value="reports">Reports ({pendingReports.length})</TabsTrigger>
<TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger> <TabsTrigger value="nicknames">Nicknames ({pendingNicknames.length})</TabsTrigger>
<TabsTrigger value="users">User Lookup</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="reports"> <TabsContent value="reports">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader>
<CardTitle>User Reports</CardTitle> <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> </CardHeader>
<CardContent> <CardContent>
{pendingReports.length === 0 ? ( {pendingReports.length === 0 ? (
@ -340,17 +138,6 @@ export default function ModDashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
{pendingReports.map(report => ( {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 key={report.id} className="flex flex-col md:flex-row gap-4 justify-between border p-4 rounded-lg bg-red-50/10 border-red-100 dark:border-red-900/20">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedReports.includes(report.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedReports([...selectedReports, report.id])
} else {
setSelectedReports(selectedReports.filter(id => id !== report.id))
}
}}
/>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Badge variant="destructive" className="uppercase text-[10px]"> <Badge variant="destructive" className="uppercase text-[10px]">
@ -359,13 +146,12 @@ export default function ModDashboardPage() {
<span className="font-semibold">{report.entity_type} #{report.entity_id}</span> <span className="font-semibold">{report.entity_type} #{report.entity_id}</span>
</div> </div>
{report.details && ( {report.details && (
<p className="text-sm italic text-muted-foreground mb-2">&quot;{report.details}&quot;</p> <p className="text-sm italic text-muted-foreground mb-2">"{report.details}"</p>
)} )}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Reported by User #{report.user_id} {new Date(report.created_at).toLocaleString()} Reported by User #{report.user_id} {new Date(report.created_at).toLocaleString()}
</p> </p>
</div> </div>
</div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Button <Button
size="sm" size="sm"
@ -373,7 +159,7 @@ export default function ModDashboardPage() {
className="bg-red-600 hover:bg-red-700" className="bg-red-600 hover:bg-red-700"
onClick={() => handleReportAction(report.id, "resolve")} onClick={() => handleReportAction(report.id, "resolve")}
> >
<Check className="h-4 w-4 mr-1" /> Resolve <Check className="h-4 w-4 mr-1" /> Resolve (Ban/Delete)
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -393,18 +179,8 @@ export default function ModDashboardPage() {
<TabsContent value="nicknames"> <TabsContent value="nicknames">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader>
<CardTitle>Pending Nicknames</CardTitle> <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> </CardHeader>
<CardContent> <CardContent>
{pendingNicknames.length === 0 ? ( {pendingNicknames.length === 0 ? (
@ -413,23 +189,14 @@ export default function ModDashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
{pendingNicknames.map((item) => ( {pendingNicknames.map((item) => (
<div key={item.id} className="flex items-center justify-between border p-4 rounded-lg"> <div key={item.id} className="flex items-center justify-between border p-4 rounded-lg">
<div className="flex items-center gap-3">
<Checkbox
checked={selectedNicknames.includes(item.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedNicknames([...selectedNicknames, item.id])
} else {
setSelectedNicknames(selectedNicknames.filter(id => id !== item.id))
}
}}
/>
<div> <div>
<p className="font-bold text-lg">&quot;{item.nickname}&quot;</p> <p className="font-bold text-lg">"{item.nickname}"</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Performance #{item.performance_id} User #{item.suggested_by} Performance #{item.performance_id} User #{item.suggested_by}
</p> </p>
</div> <p className="text-xs text-muted-foreground mt-1">
{new Date(item.created_at).toLocaleDateString()}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -456,139 +223,7 @@ export default function ModDashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </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> </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> </div>
) )
} }

View file

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

View file

@ -1,15 +1,12 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, ChevronRight, ChevronLeft, Music, Clock, Hash, Play, ExternalLink, Sparkles, Youtube } from "lucide-react" import { ArrowLeft, Calendar, MapPin, Music2 } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { CommentSection } from "@/components/social/comment-section" import { CommentSection } from "@/components/social/comment-section"
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper" 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) { async function getPerformance(id: string) {
try { try {
@ -30,350 +27,111 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
notFound() notFound()
} }
const showDate = new Date(performance.show.date)
const formattedDate = showDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
})
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Hero Banner - Distinct from Song page */} <div className="flex items-center gap-4">
<div className="relative -mx-4 -mt-4 px-4 pt-6 pb-8 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent border-b">
{/* Breadcrumbs */}
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-4">
<Link href={`/shows/${performance.show.id}`} className="hover:text-foreground transition-colors">
Show
</Link>
<ChevronRight className="h-4 w-4" />
<Link
href={`/songs/${performance.song.id}`}
className="hover:text-foreground transition-colors"
>
{performance.song.title}
</Link>
<ChevronRight className="h-4 w-4" />
<span className="text-foreground font-medium">This Performance</span>
</nav>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div className="flex items-start gap-4">
<Link href={`/shows/${performance.show.id}`}> <Link href={`/shows/${performance.show.id}`}>
<Button variant="outline" size="icon" className="mt-1"> <Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<div> <div>
{/* Context Badge */} <h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<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} {performance.song.title}
</Link> {performance.nicknames.length > 0 && (
</h1> <span className="text-xl text-muted-foreground font-normal">
"{performance.nicknames[0].nickname}"
{/* 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> </span>
))}
</div>
)} )}
</h1>
{/* Show Context - THE KEY DIFFERENTIATOR */} <div className="flex items-center gap-4 text-muted-foreground mt-1">
<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="hover:underline flex items-center 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" /> <Calendar className="h-4 w-4" />
{formattedDate} {new Date(performance.show.date).toLocaleDateString()}
</Link> </Link>
{performance.show.venue && ( {performance.show.venue && (
<Link <div className="flex items-center gap-1">
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" /> <MapPin className="h-4 w-4" />
{performance.show.venue.name}, {performance.show.venue.city} {performance.show.venue.name}
{performance.show.venue.state && `, ${performance.show.venue.state}`} </div>
</Link>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Rating Box */} <div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<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>
{/* Video Section - Show when performance has a video */}
{performance.youtube_link && (
<Card className="border-2 border-red-500/20 bg-gradient-to-br from-red-500/5 to-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5 text-red-500" />
Video
</CardTitle>
</CardHeader>
<CardContent>
<YouTubeEmbed
url={performance.youtube_link}
title={`${performance.song.title} - ${formattedDate}`}
/>
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
<div className="flex flex-col gap-6"> <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}`}
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>
) : (
<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}`}
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>
) : (
<div className="flex-1 p-3 rounded-md bg-muted/50 text-center text-sm text-muted-foreground">
Most Recent 🕐
</div>
)}
</div>
</CardContent>
</Card>
{/* Notes & Details */}
{(performance.notes || performance.segue || performance.track_url) && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>About This Performance</CardTitle> <CardTitle>Performance Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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>
<div className="flex items-center justify-between pt-4 border-t">
{performance.previous_performance_id ? (
<Link href={`/performances/${performance.previous_performance_id}`}>
<Button variant="outline" size="sm">
&larr; Previous Version
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
&larr; First Time Played
</Button>
)}
{performance.next_performance_id ? (
<Link href={`/performances/${performance.next_performance_id}`}>
<Button variant="outline" size="sm">
Next Version &rarr;
</Button>
</Link>
) : (
<Button variant="outline" size="sm" disabled>
Last Time Played &rarr;
</Button>
)}
</div>
{performance.notes && ( {performance.notes && (
<div> <div className="mt-4 pt-4 border-t">
<h3 className="font-medium text-sm mb-1 text-muted-foreground">Notes</h3> <h3 className="font-medium text-sm mb-1">Notes</h3>
<p className="text-foreground">{performance.notes}</p> <p className="text-sm text-muted-foreground">{performance.notes}</p>
</div> </div>
)} )}
{performance.segue && ( {performance.segue && (
<div className="flex items-center gap-2 p-2 rounded-md bg-primary/10 text-primary"> <div className="text-sm font-medium text-primary mt-2">
<Music className="h-4 w-4" /> Segue into next song &gt;
<span className="font-medium">Segues into next song </span>
</div> </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> </CardContent>
</Card> </Card>
)}
{/* Comments */}
<SocialWrapper type="comments"> <SocialWrapper type="comments">
<CommentSection entityType="performance" entityId={performance.id} /> <CommentSection entityType="performance" entityId={performance.id} />
</SocialWrapper> </SocialWrapper>
{/* Reviews */}
<SocialWrapper type="reviews"> <SocialWrapper type="reviews">
<EntityReviews <EntityReviews entityType="performance" entityId={performance.id} />
entityType="performance"
entityId={performance.id}
entityName={`${performance.song.title} - ${formattedDate}`}
/>
</SocialWrapper> </SocialWrapper>
</div> </div>
{/* Sidebar */} <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4"> {/* Could add "Other performances of this song" or "Other songs from this show" here */}
{/* 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> </div>
</div> </div>

View file

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

View file

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

View file

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

View file

@ -11,9 +11,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { UserAttendanceList } from "@/components/profile/user-attendance-list" import { UserAttendanceList } from "@/components/profile/user-attendance-list"
import { UserReviewsList } from "@/components/profile/user-reviews-list" import { UserReviewsList } from "@/components/profile/user-reviews-list"
import { UserGroupsList } from "@/components/profile/user-groups-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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { motion } from "framer-motion" import { motion } from "framer-motion"
@ -179,37 +176,10 @@ export default function ProfilePage() {
</TabsList> </TabsList>
<TabsContent value="overview" className="space-y-6"> <TabsContent value="overview" className="space-y-6">
{/* Level Progress */}
<motion.div <motion.div
initial={{ opacity: 0, x: -10 }} initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }} 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> <Card>
<CardHeader> <CardHeader>

View file

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

View file

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

View file

@ -14,7 +14,6 @@ import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialo
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { YouTubeEmbed } from "@/components/ui/youtube-embed" import { YouTubeEmbed } from "@/components/ui/youtube-embed"
import { MarkCaughtButton } from "@/components/chase/mark-caught-button"
async function getShow(id: string) { async function getShow(id: string) {
try { try {
@ -173,30 +172,13 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span> <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"> <div className="font-medium flex items-center gap-2">
<Link {perf.track_url ? (
href={`/performances/${perf.slug || perf.id}`} <a href={perf.track_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:underline group/link">
className="hover:text-primary hover:underline transition-colors" <PlayCircle className="h-3.5 w-3.5 text-primary opacity-70 group-hover/link:opacity-100" />
> <span>{perf.song?.title || "Unknown Song"}</span>
{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> </a>
)} ) : (
{perf.youtube_link && ( <span>{perf.song?.title || "Unknown Song"}</span>
<span
className="text-red-500"
title="Video available"
>
<Youtube className="h-3.5 w-3.5" />
</span>
)} )}
{perf.segue && <span className="ml-1 text-muted-foreground">&gt;</span>} {perf.segue && <span className="ml-1 text-muted-foreground">&gt;</span>}
</div> </div>
@ -229,13 +211,6 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
compact={true} compact={true}
/> />
</SocialWrapper> </SocialWrapper>
{/* Mark Caught (for chase songs) */}
<MarkCaughtButton
songId={perf.song?.id}
songTitle={perf.song?.title || "Song"}
showId={show.id}
/>
</div> </div>
{perf.notes && ( {perf.notes && (
<div className="text-xs text-muted-foreground ml-9 italic mt-0.5"> <div className="text-xs text-muted-foreground ml-9 italic mt-0.5">
@ -273,9 +248,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />
<Link href={`/venues/${show.venue.slug || show.venue.id}`} className="font-medium hover:underline hover:text-primary"> <span className="font-medium">{show.venue.name}</span>
{show.venue.name}
</Link>
</div> </div>
<p className="text-sm text-muted-foreground pl-6"> <p className="text-sm text-muted-foreground pl-6">
{show.venue.city}, {show.venue.state} {show.venue.country} {show.venue.city}, {show.venue.state} {show.venue.country}

View file

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

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, PlayCircle, History, Calendar, Trophy, Youtube, Star } from "lucide-react" import { ArrowLeft, PlayCircle, History, Calendar } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -12,7 +12,6 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper" import { SocialWrapper } from "@/components/social/social-wrapper"
import { PerformanceList } from "@/components/songs/performance-list" import { PerformanceList } from "@/components/songs/performance-list"
import { SongEvolutionChart } from "@/components/songs/song-evolution-chart" import { SongEvolutionChart } from "@/components/songs/song-evolution-chart"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getSong(id: string) { async function getSong(id: string) {
try { try {
@ -25,15 +24,6 @@ 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 }> }) { export default async function SongDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const song = await getSong(id) const song = await getSong(id)
@ -42,9 +32,6 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
notFound() notFound()
} }
const headyVersions = getHeadyVersions(song.performances || [])
const topPerformance = headyVersions[0]
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@ -113,89 +100,6 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
</Card> </Card>
</div> </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 || []} /> <SongEvolutionChart performances={song.performances || []} />
{/* Performance List Component (Handles Client Sorting) */} {/* Performance List Component (Handles Client Sorting) */}

View file

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

View file

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

View file

@ -72,9 +72,7 @@ export default async function TourDetailPage({ params }: { params: Promise<{ id:
<CardContent> <CardContent>
{shows.length > 0 ? ( {shows.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{[...shows] {shows.map((show: any) => (
.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"> <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 justify-between p-2 rounded-md hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View file

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

View file

@ -1,217 +1,65 @@
"use client" "use client"
import { useEffect, useState, useMemo } from "react" import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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 Link from "next/link"
import { MapPin, Search, Calendar, ArrowUpDown } from "lucide-react" import { MapPin } from "lucide-react"
interface Venue { interface Venue {
id: number id: number
name: string name: string
slug?: string
city: string city: string
state: string state: string
country: string country: string
show_count?: number
} }
type SortOption = "name" | "city" | "shows"
export default function VenuesPage() { export default function VenuesPage() {
const [venues, setVenues] = useState<Venue[]>([]) const [venues, setVenues] = useState<Venue[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const [stateFilter, setStateFilter] = useState<string>("")
const [sortBy, setSortBy] = useState<SortOption>("name")
useEffect(() => { useEffect(() => {
async function fetchVenues() { fetch(`${getApiUrl()}/venues/?limit=100`)
try { .then(res => res.json())
// Fetch venues .then(data => {
const venuesRes = await fetch(`${getApiUrl()}/venues/?limit=100`) // Sort alphabetically
const venuesData: Venue[] = await venuesRes.json() const sorted = data.sort((a: Venue, b: Venue) => a.name.localeCompare(b.name))
setVenues(sorted)
// Fetch show counts for each venue (batch approach)
const showsRes = await fetch(`${getApiUrl()}/shows/?limit=1000`)
const showsData = await showsRes.json()
// Count shows per venue
const showCounts: Record<number, number> = {}
showsData.forEach((show: any) => {
if (show.venue_id) {
showCounts[show.venue_id] = (showCounts[show.venue_id] || 0) + 1
}
}) })
.catch(console.error)
// Merge counts into venues .finally(() => setLoading(false))
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()
}, []) }, [])
// Get unique states for filter dropdown if (loading) return <div className="container py-10">Loading venues...</div>
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 ( return (
<div className="container py-10 space-y-6"> <div className="container py-10 space-y-8">
{/* Header */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Venues</h1> <h1 className="text-3xl font-bold tracking-tight">Venues</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{venues.length} venues where the magic happens Explore the iconic venues where the magic happens.
</p> </p>
</div> </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"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredVenues.map((venue) => ( {venues.map((venue) => (
<Link key={venue.id} href={`/venues/${venue.slug || venue.id}`}> <Link key={venue.id} href={`/venues/${venue.id}`}>
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group"> <Card className="h-full hover:bg-accent/50 transition-colors">
<CardHeader className="pb-2"> <CardHeader>
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors"> <CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5 text-green-500 flex-shrink-0" /> <MapPin className="h-5 w-5 text-green-500" />
<span className="truncate">{venue.name}</span> {venue.name}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between"> <p className="text-muted-foreground">
<p className="text-muted-foreground text-sm"> {venue.city}, {venue.state}
{venue.city}{venue.state ? `, ${venue.state}` : ""}
</p> </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> </CardContent>
</Card> </Card>
</Link> </Link>
))} ))}
</div> </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> </div>
) )
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,140 +0,0 @@
"use client"
import { Bell } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { cn } from "@/lib/utils"
interface Notification {
id: number
type: string
title: string
message: string
link?: string
is_read: boolean
created_at: string
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const [open, setOpen] = useState(false)
const fetchNotifications = () => {
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/notifications/`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
setNotifications(data)
setUnreadCount(data.filter((n: Notification) => !n.is_read).length)
})
.catch(console.error)
}
useEffect(() => {
fetchNotifications()
// Poll every 60 seconds
const interval = setInterval(fetchNotifications, 60000)
return () => clearInterval(interval)
}, [])
const markAsRead = (id: number) => {
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/notifications/${id}/read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
}).then(() => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n))
setUnreadCount(prev => Math.max(0, prev - 1))
})
}
const markAllRead = () => {
const token = localStorage.getItem("token")
if (!token) return
fetch(`${getApiUrl()}/notifications/mark-all-read`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` }
}).then(() => {
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })))
setUnreadCount(0)
})
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-red-600" />
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h4 className="font-semibold">Notifications</h4>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllRead} className="text-xs h-auto p-0 text-muted-foreground hover:text-foreground">
Mark all read
</Button>
)}
</div>
<div className="max-h-[300px] overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={cn(
"p-4 border-b last:border-0 hover:bg-muted/50 transition-colors cursor-pointer",
!notification.is_read && "bg-muted/20"
)}
onClick={() => {
if (!notification.is_read) markAsRead(notification.id)
}}
>
<Link href={notification.link || "#"} className="block">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-sm font-medium leading-none">
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-muted-foreground">
{new Date(notification.created_at).toLocaleDateString()}
</p>
</div>
{!notification.is_read && (
<span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0 mt-1" />
)}
</div>
</Link>
</div>
))
)}
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,121 @@
"use client"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Flag } from "lucide-react"
import { getApiUrl } from "@/lib/api-config"
import { Textarea } from "@/components/ui/textarea"
interface ReportDialogProps {
entityType: "comment" | "review" | "nickname"
entityId: number
trigger?: React.ReactNode
}
export function ReportDialog({ entityType, entityId, trigger }: ReportDialogProps) {
const [open, setOpen] = useState(false)
const [reason, setReason] = useState("spam")
const [details, setDetails] = useState("")
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
const token = localStorage.getItem("token")
if (!token) return
setLoading(true)
try {
const res = await fetch(`${getApiUrl()}/moderation/reports`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
entity_type: entityType,
entity_id: entityId,
reason: reason,
details: details // Schema might not have details yet, check backend
})
})
if (res.ok) {
setOpen(false)
alert("Report submitted. Thank you for helping keep the community safe.")
} else {
alert("Failed to submit report.")
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500">
<Flag className="h-3 w-3" />
<span className="sr-only">Report</span>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Report Content</DialogTitle>
<DialogDescription>
Help us understand what's wrong with this content.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<RadioGroup value={reason} onValueChange={setReason} className="gap-3">
<div className="flex items-center space-x-2">
<RadioGroupItem value="spam" id="spam" />
<Label htmlFor="spam">Spam or unwanted commercial content</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="harassment" id="harassment" />
<Label htmlFor="harassment">Harassment or hate speech</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="spoiler" id="spoiler" />
<Label htmlFor="spoiler">Spoiler or incorrect info</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="other" id="other" />
<Label htmlFor="other">Other</Label>
</div>
</RadioGroup>
{reason === "other" && (
<Textarea
placeholder="Please provide more details..."
value={details}
onChange={(e) => setDetails(e.target.value)}
/>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleSubmit} disabled={loading}>
{loading ? "Submitting..." : "Submit Report"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,15 +3,19 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { MessageCircle, CornerDownRight, Heart, Flag } from "lucide-react"
import { cn } from "@/lib/utils"
import { ReportDialog } from "@/components/moderation/report-dialog"
interface Comment { interface Comment {
id: number id: number
user_id: number user_id: number
content: string content: string
created_at: string created_at: string
parent_id: number | null
} }
interface CommentSectionProps { interface CommentSectionProps {
@ -30,7 +34,7 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
const fetchComments = async () => { const fetchComments = async () => {
try { try {
const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}`) const res = await fetch(`${getApiUrl()}/social/comments?${entityType}_id=${entityId}&limit=100`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setComments(data) setComments(data)
@ -40,9 +44,9 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
} }
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent, parentId: number | null = null, content: string = newComment) => {
e.preventDefault() e.preventDefault()
if (!newComment.trim()) return if (!content.trim()) return
const token = localStorage.getItem("token") const token = localStorage.getItem("token")
if (!token) { if (!token) {
@ -52,7 +56,10 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
setLoading(true) setLoading(true)
try { try {
const body: any = { content: newComment } const body: any = {
content: content,
parent_id: parentId
}
body[`${entityType}_id`] = entityId body[`${entityType}_id`] = entityId
const res = await fetch(`${getApiUrl()}/social/comments`, { const res = await fetch(`${getApiUrl()}/social/comments`, {
@ -77,41 +84,156 @@ export function CommentSection({ entityType, entityId }: CommentSectionProps) {
} }
} }
return ( // Tree builder
<div className="space-y-6"> const rootComments = comments.filter(c => c.parent_id === null)
<h3 className="text-xl font-semibold">Comments</h3> const getReplies = (parentId: number) => comments.filter(c => c.parent_id === parentId).sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
<form onSubmit={handleSubmit} className="space-y-4"> return (
<div className="space-y-8">
<h3 className="text-xl font-semibold flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Discussion
</h3>
{/* Root Post Form */}
<div className="flex gap-4">
<div className="flex-1">
<form onSubmit={(e) => handleSubmit(e)} className="space-y-4">
<Textarea <Textarea
placeholder="Join the discussion..." placeholder="Share your thoughts..."
value={newComment} value={newComment}
onChange={(e) => setNewComment(e.target.value)} onChange={(e) => setNewComment(e.target.value)}
required required
className="min-h-[100px]"
/> />
<div className="flex justify-end">
<Button type="submit" disabled={loading || !newComment.trim()}> <Button type="submit" disabled={loading || !newComment.trim()}>
{loading ? "Posting..." : "Post Comment"} {loading ? "Posting..." : "Post Comment"}
</Button> </Button>
</form>
<div className="space-y-4">
{comments.length === 0 ? (
<p className="text-muted-foreground">No comments yet.</p>
) : (
comments.map(comment => (
<Card key={comment.id}>
<CardContent className="pt-4">
<div className="flex justify-between items-start mb-2">
<span className="font-semibold text-sm">User #{comment.user_id}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
</span>
</div> </div>
<p className="text-sm whitespace-pre-wrap">{comment.content}</p> </form>
</CardContent> </div>
</Card> </div>
<div className="space-y-6">
{rootComments.length === 0 ? (
<p className="text-muted-foreground text-center py-8">No comments yet. Be the first!</p>
) : (
rootComments.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
getReplies={getReplies}
onReply={handleSubmit}
/>
)) ))
)} )}
</div> </div>
</div> </div>
) )
} }
function CommentItem({ comment, getReplies, onReply }: {
comment: Comment,
getReplies: (id: number) => Comment[],
onReply: (e: React.FormEvent, parentId: number, content: string) => Promise<void>
}) {
const [isReplying, setIsReplying] = useState(false)
const [replyContent, setReplyContent] = useState("")
const [submitting, setSubmitting] = useState(false)
const replies = getReplies(comment.id)
const handleReplySubmit = async (e: React.FormEvent) => {
setSubmitting(true)
await onReply(e, comment.id, replyContent)
setSubmitting(false)
setIsReplying(false)
setReplyContent("")
}
return (
<div className="group">
<div className="flex gap-3">
<div className="flex flex-col items-center">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
{comment.user_id}
</div>
{(replies.length > 0) && <div className="w-px h-full bg-border my-2" />}
</div>
<div className="flex-1 space-y-2 pb-6">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">User #{comment.user_id}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
</span>
</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.content}
</div>
<div className="flex items-center gap-4 pt-1">
<Button
variant="ghost"
size="sm"
className={cn("h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-primary", isReplying && "text-primary bg-primary/5")}
onClick={() => setIsReplying(!isReplying)}
>
<MessageCircle className="h-3 w-3" />
Reply
</Button>
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-red-500">
<Heart className="h-3 w-3" />
Like
</Button>
<ReportDialog
entityType="comment"
entityId={comment.id}
trigger={
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground gap-1 hover:text-red-500">
<Flag className="h-3 w-3" />
Report
</Button>
}
/>
</div>
{isReplying && (
<div className="mt-4 pl-4 border-l-2 border-primary/20">
<form onSubmit={handleReplySubmit} className="space-y-3">
<Textarea
placeholder={`Reply to User #${comment.user_id}...`}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
autoFocus
className="min-h-[80px]"
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setIsReplying(false)}>Cancel</Button>
<Button type="submit" size="sm" disabled={submitting || !replyContent.trim()}>
Reply
</Button>
</div>
</form>
</div>
)}
{/* Recursive Replies */}
{replies.length > 0 && (
<div className="mt-4 space-y-4">
{replies.map(reply => (
<CommentItem
key={reply.id}
comment={reply}
getReplies={getReplies}
onReply={onReply}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,160 +0,0 @@
"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,
}

View file

@ -1,125 +1,47 @@
"use client" import { useState } from "react"
import { useState, useRef } from "react"
import { Star } from "lucide-react" import { Star } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
interface RatingProps { interface RatingProps {
value: number value: number
onChange?: (value: number) => void onChange?: (value: number) => void
readonly?: boolean readonly?: boolean
className?: string className?: string
size?: "sm" | "md" | "lg" size?: "sm" | "md"
precision?: "full" | "half" | "decimal"
} }
export function StarRating({ export function StarRating({ value, onChange, readonly = false, className, size = "md" }: RatingProps) {
value,
onChange,
readonly = false,
className,
size = "md",
precision = "decimal"
}: RatingProps) {
const [hoverValue, setHoverValue] = useState<number | null>(null) const [hoverValue, setHoverValue] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const stars = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] const stars = Array.from({ length: 10 }, (_, i) => i + 1)
const starSize = { const starSize = size === "sm" ? "h-3 w-3" : "h-4 w-4"
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
ref={containerRef}
className={cn("flex gap-0.5 items-center", className)}
onMouseLeave={() => !readonly && setHoverValue(null)}
>
{stars.map((star, index) => {
const fillPercent = getStarFill(index)
return ( return (
<div className={cn("flex gap-0.5", className)}>
{stars.map((star) => (
<button <button
key={star} key={star}
type="button" type="button"
disabled={readonly} disabled={readonly}
className={cn( className={cn(
"p-0.5 transition-transform relative", "p-0.5 transition-colors",
readonly ? "cursor-default" : "cursor-pointer hover:scale-110" readonly ? "cursor-default" : "cursor-pointer hover:scale-110"
)} )}
onMouseMove={(e) => handleMouseMove(e, index)} onMouseEnter={() => !readonly && setHoverValue(star)}
onClick={handleClick} onMouseLeave={() => !readonly && setHoverValue(null)}
> onClick={() => !readonly && onChange?.(star)}
{/* 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 <Star
className={cn(starSize, "fill-primary text-primary")} className={cn(
/> starSize,
</div> (hoverValue !== null ? star <= hoverValue : star <= value)
</button> ? "fill-primary text-primary"
) : "fill-muted text-muted-foreground"
})}
{/* 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>
)} )}
/>
</button>
))}
</div> </div>
) )
} }

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