Compare commits

...

26 commits

Author SHA1 Message Date
fullsizemalt
e94cb91010 fix(ops): add traefik network label to umami
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-24 09:58:41 -08:00
fullsizemalt
9eeba8571c docs: Update roadmap with Postal mail server completion
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 18:01:22 -08:00
fullsizemalt
5a60f3303a feat: Add SMTP env variables to docker-compose for Postal
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 17:21:36 -08:00
fullsizemalt
9c92eb7953 feat: Add SMTP support for self-hosted Postal mail server
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 17:19:22 -08:00
fullsizemalt
0af64f5862 docs: Update roadmap with session completions
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 16:01:03 -08:00
fullsizemalt
033c9f9bd0 feat: Enhanced footer with multi-column layout and bug tracker link
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 16:00:08 -08:00
fullsizemalt
08587f21f9 feat: Display Bandcamp/Nugs links on show page setlist
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 15:57:37 -08:00
fullsizemalt
1f29cdf290 feat: Bandcamp/Nugs links for shows and performances
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Add bandcamp_link, nugs_link to Performance model
- Admin endpoints: PATCH /admin/performances/{id}
- Bulk import: POST /admin/import/external-links
- Spec doc: docs/BANDCAMP_NUGS_SPEC.md
2025-12-23 15:56:21 -08:00
fullsizemalt
68453d6865 feat: Mailgun email integration with SES fallback
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Mailgun API as primary email provider
- AWS SES kept as fallback
- Improved email templates with modern styling
- Environment vars: MAILGUN_API_KEY, MAILGUN_DOMAIN
2025-12-23 15:49:33 -08:00
fullsizemalt
1f7f83a31a fix: Videos page links to show, hide test users from leaderboard
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Videos page now links song titles to show page (where video is displayed)
- Leaderboard hides tenwest/testuser until 12+ real users exist
2025-12-23 15:45:29 -08:00
fullsizemalt
cddd3e2389 fix: Silent handling of expired auth tokens (no console error)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 15:40:03 -08:00
fullsizemalt
14a509ddb5 feat: Add bug tracker MVP (decoupled, feature-flagged)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Backend: Ticket and TicketComment models (no FK to User)
- API: /tickets/* endpoints for submit, view, comment, upvote
- Admin: /tickets/admin/* for triage queue
- Frontend: /bugs pages (submit, my-tickets, known-issues, detail)
- Feature flag: ENABLE_BUG_TRACKER env var (default: true)

To disable: Set ENABLE_BUG_TRACKER=false
To remove: Delete models_tickets.py, routers/tickets.py, frontend/app/bugs/
2025-12-23 13:18:00 -08:00
fullsizemalt
2da46eaa16 feat: Privacy settings with functional toggles, sticky sidebar, roadmap doc
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 13:08:48 -08:00
fullsizemalt
824a70d303 fix: Update Switch component to properly handle onCheckedChange prop
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 13:00:06 -08:00
fullsizemalt
9e48dd78ff style: Update avatar colors to jewel tones (Sapphire, Ruby, Emerald, etc.)
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 11:56:23 -08:00
fullsizemalt
cc0d0255c0 fix: Add missing Separator component
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 11:39:28 -08:00
fullsizemalt
f989414323 feat: Redesign settings page with comprehensive sections, sidebar nav, and distinct avatar colors
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 11:38:18 -08:00
fullsizemalt
a4d63a9e2c feat: Add custom avatar system with color picker and text overlay
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 11:12:31 -08:00
fullsizemalt
c6ffc67fdd style: Apply consistent bento styling to video embeds on show pages
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 08:53:52 -08:00
fullsizemalt
1b11ad8b52 feat: Add mobile hamburger menu and Videos link to Browse dropdown
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 00:58:21 -08:00
fullsizemalt
735fd1a6ea style: Move video into bento grid left column for better layout flow
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 00:37:34 -08:00
fullsizemalt
d706a777a7 style: Limit video embed max-width to 3xl on large screens
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 00:35:09 -08:00
fullsizemalt
18cc7ea011 fix: Make DATABASE_URL configurable with default to elmeg
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-23 00:30:25 -08:00
fullsizemalt
b0f919f9ff fix: Correct Traefik network label to 'traefik'
Some checks failed
Deploy Elmeg / deploy (push) Has been cancelled
2025-12-23 00:28:48 -08:00
fullsizemalt
483d6dcb0d fix: Correct DATABASE_URL to use elmeg_db database name
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
2025-12-22 23:55:05 -08:00
fullsizemalt
4a103511da feat: Add video integration - display videos on performance pages and indicators
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
- Add YouTubeEmbed to performance detail page when youtube_link exists
- Add YouTube icon indicator on setlist items that have videos
- Add YouTube badge on show cards in archive when full show video exists
- Add youtube_link to ShowRead and PerformanceRead schemas
- Add VIDEO_INTEGRATION_SPEC.md documentation
2025-12-22 23:52:34 -08:00
32 changed files with 4088 additions and 350 deletions

View file

@ -1,8 +1,12 @@
from fastapi import FastAPI
import os
from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos
from fastapi.middleware.cors import CORSMiddleware
# Feature flags - set to False to disable features
ENABLE_BUG_TRACKER = os.getenv("ENABLE_BUG_TRACKER", "true").lower() == "true"
app = FastAPI()
app.add_middleware(
@ -39,6 +43,11 @@ app.include_router(chase.router)
app.include_router(gamification.router)
app.include_router(videos.router)
# Optional features - can be disabled via env vars
if ENABLE_BUG_TRACKER:
from routers import tickets
app.include_router(tickets.router)
@app.get("/")
def read_root():
return {"Hello": "World"}

View file

@ -15,6 +15,8 @@ class Performance(SQLModel, table=True):
notes: Optional[str] = Field(default=None)
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
bandcamp_link: Optional[str] = Field(default=None, description="Bandcamp track URL")
nugs_link: Optional[str] = Field(default=None, description="Nugs.net track URL")
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
show: "Show" = Relationship(back_populates="performances")
@ -173,6 +175,13 @@ class User(SQLModel, table=True):
role: str = Field(default="user") # user, moderator, admin
bio: Optional[str] = Field(default=None)
avatar: Optional[str] = Field(default=None)
avatar_bg_color: Optional[str] = Field(default="#3B82F6", description="Hex color for avatar background")
avatar_text: Optional[str] = Field(default=None, description="1-3 character text overlay on avatar")
# Privacy settings
profile_public: bool = Field(default=True, description="Allow others to view profile")
show_attendance_public: bool = Field(default=True, description="Show attended shows on profile")
appear_in_leaderboards: bool = Field(default=True, description="Appear in community leaderboards")
# Gamification
xp: int = Field(default=0, description="Experience points")

140
backend/models_tickets.py Normal file
View file

@ -0,0 +1,140 @@
"""
Bug Tracker Models - ISOLATED MODULE
No dependencies on main Elmeg models.
Can be removed by: deleting this file + routes file + removing router import from main.py
"""
from datetime import datetime
from typing import Optional, List
from sqlmodel import SQLModel, Field, Relationship
from enum import Enum
class TicketType(str, Enum):
BUG = "bug"
FEATURE = "feature"
QUESTION = "question"
OTHER = "other"
class TicketStatus(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
class TicketPriority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
CRITICAL = "critical"
class Ticket(SQLModel, table=True):
"""
Support ticket - fully decoupled from User model.
Stores reporter info as strings, not FKs.
"""
id: Optional[int] = Field(default=None, primary_key=True)
ticket_number: str = Field(unique=True, index=True) # ELM-001
type: TicketType = Field(default=TicketType.BUG)
status: TicketStatus = Field(default=TicketStatus.OPEN)
priority: TicketPriority = Field(default=TicketPriority.MEDIUM)
title: str = Field(max_length=200)
description: str = Field(default="")
# Reporter info - stored as strings, not FK
reporter_email: str = Field(index=True)
reporter_name: Optional[str] = None
reporter_user_id: Optional[int] = None # Reference only, not FK
# Assignment - stored as strings
assigned_to_email: Optional[str] = None
assigned_to_name: Optional[str] = None
is_public: bool = Field(default=False)
upvotes: int = Field(default=0)
# Environment info
browser: Optional[str] = None
os: Optional[str] = None
page_url: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
resolved_at: Optional[datetime] = None
# Relationships (within ticket system only)
comments: List["TicketComment"] = Relationship(back_populates="ticket")
class TicketComment(SQLModel, table=True):
"""Comment on a ticket - no FK to User"""
id: Optional[int] = Field(default=None, primary_key=True)
ticket_id: int = Field(foreign_key="ticket.id")
# Author info - stored as strings
author_email: str
author_name: str
author_user_id: Optional[int] = None # Reference only
content: str
is_internal: bool = Field(default=False) # Admin-only visibility
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationship
ticket: Optional[Ticket] = Relationship(back_populates="comments")
# ============ Schemas ============
class TicketCreate(SQLModel):
type: TicketType = TicketType.BUG
priority: TicketPriority = TicketPriority.MEDIUM
title: str
description: str = ""
reporter_email: Optional[str] = None
reporter_name: Optional[str] = None
browser: Optional[str] = None
os: Optional[str] = None
page_url: Optional[str] = None
class TicketUpdate(SQLModel):
status: Optional[TicketStatus] = None
priority: Optional[TicketPriority] = None
assigned_to_email: Optional[str] = None
assigned_to_name: Optional[str] = None
is_public: Optional[bool] = None
class TicketCommentCreate(SQLModel):
content: str
class TicketRead(SQLModel):
id: int
ticket_number: str
type: TicketType
status: TicketStatus
priority: TicketPriority
title: str
description: str
reporter_email: str
reporter_name: Optional[str]
is_public: bool
upvotes: int
created_at: datetime
updated_at: datetime
resolved_at: Optional[datetime]
class TicketCommentRead(SQLModel):
id: int
author_name: str
content: str
is_internal: bool
created_at: datetime

View file

@ -432,3 +432,114 @@ def delete_tour(
session.delete(tour)
session.commit()
return {"message": "Tour deleted", "tour_id": tour_id}
# ============ PERFORMANCES ============
from models import Performance
class PerformanceUpdate(BaseModel):
notes: Optional[str] = None
youtube_link: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
track_url: Optional[str] = None
@router.patch("/performances/{performance_id}")
def update_performance(
performance_id: int,
update: PerformanceUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update performance links and notes"""
performance = session.get(Performance, performance_id)
if not performance:
raise HTTPException(status_code=404, detail="Performance not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(performance, key, value)
session.add(performance)
session.commit()
session.refresh(performance)
return performance
@router.get("/performances/{performance_id}")
def get_performance(
performance_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get performance details for admin"""
performance = session.get(Performance, performance_id)
if not performance:
raise HTTPException(status_code=404, detail="Performance not found")
return {
"id": performance.id,
"slug": performance.slug,
"show_id": performance.show_id,
"song_id": performance.song_id,
"position": performance.position,
"set_name": performance.set_name,
"notes": performance.notes,
"youtube_link": performance.youtube_link,
"bandcamp_link": performance.bandcamp_link,
"nugs_link": performance.nugs_link,
"track_url": performance.track_url,
}
class BulkLinksImport(BaseModel):
links: List[dict] # {"show_id": 1, "platform": "nugs", "url": "..."} or {"performance_id": 1, ...}
@router.post("/import/external-links")
def bulk_import_links(
data: BulkLinksImport,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Bulk import external links for shows and performances"""
updated_shows = 0
updated_performances = 0
errors = []
for item in data.links:
platform = item.get("platform", "").lower()
url = item.get("url")
if not platform or not url:
errors.append({"item": item, "error": "Missing platform or url"})
continue
field_name = f"{platform}_link"
if "show_id" in item:
show = session.get(Show, item["show_id"])
if show and hasattr(show, field_name):
setattr(show, field_name, url)
session.add(show)
updated_shows += 1
else:
errors.append({"item": item, "error": "Show not found or invalid platform"})
elif "performance_id" in item:
perf = session.get(Performance, item["performance_id"])
if perf and hasattr(perf, field_name):
setattr(perf, field_name, url)
session.add(perf)
updated_performances += 1
else:
errors.append({"item": item, "error": "Performance not found or invalid platform"})
session.commit()
return {
"updated_shows": updated_shows,
"updated_performances": updated_performances,
"errors": errors
}

371
backend/routers/tickets.py Normal file
View file

@ -0,0 +1,371 @@
"""
Bug Tracker API Routes - ISOLATED MODULE
To remove this feature:
1. Delete this file
2. Delete models_tickets.py
3. Remove router import from main.py
4. Drop 'ticket' and 'ticketcomment' tables from database
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from database import get_session
# Import auth helpers but don't create hard dependency
try:
from auth import get_current_user, get_current_user_optional
from models import User
AUTH_AVAILABLE = True
except ImportError:
AUTH_AVAILABLE = False
User = None
def get_current_user():
return None
def get_current_user_optional():
return None
from models_tickets import (
Ticket, TicketComment, TicketType, TicketStatus, TicketPriority,
TicketCreate, TicketUpdate, TicketCommentCreate, TicketRead, TicketCommentRead
)
router = APIRouter(prefix="/tickets", tags=["tickets"])
def generate_ticket_number(session: Session) -> str:
"""Generate next ticket number like ELM-001"""
result = session.exec(select(func.count(Ticket.id))).one()
return f"ELM-{(result + 1):03d}"
# --- Public Endpoints ---
@router.post("/", response_model=TicketRead)
def create_ticket(
ticket: TicketCreate,
session: Session = Depends(get_session),
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
):
"""Create a new support ticket"""
# Determine reporter info
if current_user:
reporter_email = current_user.email
reporter_name = current_user.email.split("@")[0]
reporter_user_id = current_user.id
elif ticket.reporter_email:
reporter_email = ticket.reporter_email
reporter_name = ticket.reporter_name or ticket.reporter_email.split("@")[0]
reporter_user_id = None
else:
raise HTTPException(status_code=400, detail="Email required for anonymous submissions")
db_ticket = Ticket(
ticket_number=generate_ticket_number(session),
type=ticket.type,
priority=ticket.priority,
title=ticket.title,
description=ticket.description,
reporter_email=reporter_email,
reporter_name=reporter_name,
reporter_user_id=reporter_user_id,
browser=ticket.browser,
os=ticket.os,
page_url=ticket.page_url,
)
session.add(db_ticket)
session.commit()
session.refresh(db_ticket)
return db_ticket
@router.get("/known-issues", response_model=List[TicketRead])
def get_known_issues(
session: Session = Depends(get_session),
offset: int = 0,
limit: int = Query(default=50, le=100)
):
"""Get public/known issues"""
tickets = session.exec(
select(Ticket)
.where(Ticket.is_public == True)
.where(Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]))
.order_by(Ticket.upvotes.desc(), Ticket.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return tickets
@router.get("/my-tickets", response_model=List[TicketRead])
def get_my_tickets(
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
):
"""Get tickets submitted by current user"""
if not current_user:
raise HTTPException(status_code=401, detail="Login required")
tickets = session.exec(
select(Ticket)
.where(Ticket.reporter_user_id == current_user.id)
.order_by(Ticket.created_at.desc())
).all()
return tickets
@router.get("/{ticket_number}", response_model=TicketRead)
def get_ticket(
ticket_number: str,
session: Session = Depends(get_session),
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
):
"""Get ticket by number"""
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Check visibility
if not ticket.is_public:
if not current_user:
raise HTTPException(status_code=403, detail="Login required")
is_admin = getattr(current_user, 'is_superuser', False)
if ticket.reporter_user_id != current_user.id and not is_admin:
raise HTTPException(status_code=403, detail="Access denied")
return ticket
@router.get("/{ticket_number}/comments", response_model=List[TicketCommentRead])
def get_ticket_comments(
ticket_number: str,
session: Session = Depends(get_session),
current_user = Depends(get_current_user_optional) if AUTH_AVAILABLE else None
):
"""Get comments for a ticket"""
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Filter internal comments for non-admins
query = select(TicketComment).where(TicketComment.ticket_id == ticket.id)
is_admin = current_user and getattr(current_user, 'is_superuser', False)
if not is_admin:
query = query.where(TicketComment.is_internal == False)
comments = session.exec(query.order_by(TicketComment.created_at)).all()
return comments
@router.post("/{ticket_number}/comments", response_model=TicketCommentRead)
def add_comment(
ticket_number: str,
comment: TicketCommentCreate,
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
):
"""Add a comment to a ticket"""
if not current_user:
raise HTTPException(status_code=401, detail="Login required")
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
# Check access
is_admin = getattr(current_user, 'is_superuser', False)
if ticket.reporter_user_id != current_user.id and not is_admin:
raise HTTPException(status_code=403, detail="Access denied")
db_comment = TicketComment(
ticket_id=ticket.id,
author_user_id=current_user.id,
author_email=current_user.email,
author_name=current_user.email.split("@")[0],
content=comment.content,
is_internal=False
)
session.add(db_comment)
# Update ticket timestamp
ticket.updated_at = datetime.utcnow()
session.add(ticket)
session.commit()
session.refresh(db_comment)
return db_comment
@router.post("/{ticket_number}/upvote")
def upvote_ticket(
ticket_number: str,
session: Session = Depends(get_session)
):
"""Upvote a public ticket"""
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if not ticket.is_public:
raise HTTPException(status_code=403, detail="Not a public ticket")
ticket.upvotes += 1
session.add(ticket)
session.commit()
return {"upvotes": ticket.upvotes}
# --- Admin Endpoints ---
@router.get("/admin/all", response_model=List[TicketRead])
def admin_get_all_tickets(
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None,
status: Optional[TicketStatus] = None,
type: Optional[TicketType] = None,
priority: Optional[TicketPriority] = None,
offset: int = 0,
limit: int = Query(default=50, le=100)
):
"""Admin: Get all tickets with filters"""
if not current_user or not getattr(current_user, 'is_superuser', False):
raise HTTPException(status_code=403, detail="Admin access required")
query = select(Ticket)
if status:
query = query.where(Ticket.status == status)
if type:
query = query.where(Ticket.type == type)
if priority:
query = query.where(Ticket.priority == priority)
tickets = session.exec(
query.order_by(Ticket.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return tickets
@router.patch("/admin/{ticket_number}", response_model=TicketRead)
def admin_update_ticket(
ticket_number: str,
update: TicketUpdate,
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
):
"""Admin: Update ticket status, priority, assignment"""
if not current_user or not getattr(current_user, 'is_superuser', False):
raise HTTPException(status_code=403, detail="Admin access required")
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
if update.status is not None:
ticket.status = update.status
if update.status == TicketStatus.RESOLVED:
ticket.resolved_at = datetime.utcnow()
if update.priority is not None:
ticket.priority = update.priority
if update.assigned_to_email is not None:
ticket.assigned_to_email = update.assigned_to_email
ticket.assigned_to_name = update.assigned_to_name
if update.is_public is not None:
ticket.is_public = update.is_public
ticket.updated_at = datetime.utcnow()
session.add(ticket)
session.commit()
session.refresh(ticket)
return ticket
@router.post("/admin/{ticket_number}/internal-note", response_model=TicketCommentRead)
def admin_add_internal_note(
ticket_number: str,
comment: TicketCommentCreate,
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
):
"""Admin: Add internal note (not visible to user)"""
if not current_user or not getattr(current_user, 'is_superuser', False):
raise HTTPException(status_code=403, detail="Admin access required")
ticket = session.exec(
select(Ticket).where(Ticket.ticket_number == ticket_number)
).first()
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
db_comment = TicketComment(
ticket_id=ticket.id,
author_user_id=current_user.id,
author_email=current_user.email,
author_name=current_user.email.split("@")[0],
content=comment.content,
is_internal=True
)
session.add(db_comment)
session.commit()
session.refresh(db_comment)
return db_comment
@router.get("/admin/stats")
def admin_get_stats(
session: Session = Depends(get_session),
current_user = Depends(get_current_user) if AUTH_AVAILABLE else None
):
"""Admin: Get ticket statistics"""
if not current_user or not getattr(current_user, 'is_superuser', False):
raise HTTPException(status_code=403, detail="Admin access required")
total = session.exec(select(func.count(Ticket.id))).one()
open_count = session.exec(
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN)
).one()
in_progress = session.exec(
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.IN_PROGRESS)
).one()
resolved = session.exec(
select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.RESOLVED)
).one()
return {
"total": total,
"open": open_count,
"in_progress": in_progress,
"resolved": resolved
}

View file

@ -14,14 +14,31 @@ class UserProfileUpdate(BaseModel):
avatar: Optional[str] = None
username: Optional[str] = None
display_name: Optional[str] = None
avatar_bg_color: Optional[str] = None
avatar_text: Optional[str] = None
@router.get("/{user_id}", response_model=UserRead)
def get_user_public(user_id: int, session: Session = Depends(get_session)):
"""Get public user profile"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
# Preset avatar colors - Jewel Tones (Primary Set)
AVATAR_COLORS = [
"#0F4C81", # Sapphire
"#9B111E", # Ruby
"#50C878", # Emerald
"#9966CC", # Amethyst
"#0D98BA", # Topaz
"#E0115F", # Rose Quartz
"#082567", # Lapis
"#FF7518", # Carnelian
"#006B3C", # Jade
"#1C1C1C", # Onyx
"#E6E200", # Citrine
"#702963", # Garnet
]
class AvatarUpdate(BaseModel):
bg_color: Optional[str] = None
text: Optional[str] = None # 1-3 alphanumeric chars
# Note: Dynamic routes like /{user_id} are placed at the END of this file
# to avoid conflicts with static routes like /me and /avatar
@router.patch("/me", response_model=UserRead)
def update_my_profile(
@ -34,6 +51,15 @@ def update_my_profile(
current_user.bio = update.bio
if update.avatar is not None:
current_user.avatar = update.avatar
if update.avatar_bg_color is not None:
# Validate it's a valid hex color or preset
if update.avatar_bg_color in AVATAR_COLORS or (update.avatar_bg_color.startswith('#') and len(update.avatar_bg_color) == 7):
current_user.avatar_bg_color = update.avatar_bg_color
if update.avatar_text is not None:
# Validate 1-3 alphanumeric characters
import re
if len(update.avatar_text) <= 3 and re.match(r'^[A-Za-z0-9]*$', update.avatar_text):
current_user.avatar_text = update.avatar_text if update.avatar_text else None
if update.username or update.display_name:
# Find or create primary profile
@ -74,6 +100,71 @@ def update_my_profile(
session.refresh(current_user)
return current_user
@router.patch("/me/avatar")
def update_avatar(
update: AvatarUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update avatar customization"""
import re
if update.bg_color is not None:
if update.bg_color in AVATAR_COLORS or (update.bg_color.startswith('#') and len(update.bg_color) == 7):
current_user.avatar_bg_color = update.bg_color
else:
raise HTTPException(status_code=400, detail="Invalid color. Use a preset color or valid hex.")
if update.text is not None:
if len(update.text) > 3:
raise HTTPException(status_code=400, detail="Avatar text must be 3 characters or less")
if update.text and not re.match(r'^[A-Za-z0-9]*$', update.text):
raise HTTPException(status_code=400, detail="Avatar text must be alphanumeric")
current_user.avatar_text = update.text.upper() if update.text else None
session.add(current_user)
session.commit()
session.refresh(current_user)
return {
"avatar_bg_color": current_user.avatar_bg_color,
"avatar_text": current_user.avatar_text
}
@router.get("/avatar/colors")
def get_avatar_colors():
"""Get available avatar preset colors"""
return {"colors": AVATAR_COLORS}
class PrivacyUpdate(BaseModel):
profile_public: Optional[bool] = None
show_attendance_public: Optional[bool] = None
appear_in_leaderboards: Optional[bool] = None
@router.patch("/me/privacy")
def update_privacy(
update: PrivacyUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update privacy settings"""
if update.profile_public is not None:
current_user.profile_public = update.profile_public
if update.show_attendance_public is not None:
current_user.show_attendance_public = update.show_attendance_public
if update.appear_in_leaderboards is not None:
current_user.appear_in_leaderboards = update.appear_in_leaderboards
session.add(current_user)
session.commit()
session.refresh(current_user)
return {
"profile_public": current_user.profile_public,
"show_attendance_public": current_user.show_attendance_public,
"appear_in_leaderboards": current_user.appear_in_leaderboards
}
@router.patch("/me/preferences", response_model=UserPreferencesUpdate)
def update_preferences(
prefs: UserPreferencesUpdate,
@ -178,3 +269,13 @@ def get_user_groups(
.limit(limit)
).all()
return groups
# --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) ---
@router.get("/{user_id}", response_model=UserRead)
def get_user_public(user_id: int, session: Session = Depends(get_session)):
"""Get public user profile"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user

View file

@ -12,6 +12,11 @@ class UserRead(SQLModel):
email: str
is_active: bool
is_superuser: bool
avatar_bg_color: Optional[str] = "#3B82F6"
avatar_text: Optional[str] = None
profile_public: bool = True
show_attendance_public: bool = True
appear_in_leaderboards: bool = True
class Token(SQLModel):
access_token: str
@ -91,6 +96,7 @@ class PerformanceRead(PerformanceBase):
slug: Optional[str] = None
song: Optional["SongRead"] = None
nicknames: List["PerformanceNicknameRead"] = []
youtube_link: Optional[str] = None
class PerformanceReadWithShow(PerformanceRead):
show_date: datetime
@ -151,6 +157,7 @@ class ShowRead(ShowBase):
tour: Optional["TourRead"] = None
tags: List["TagRead"] = []
performances: List["PerformanceRead"] = []
youtube_link: Optional[str] = None
class ShowUpdate(SQLModel):
date: Optional[datetime] = None

View file

@ -1,69 +1,171 @@
"""
Email Service - Postal SMTP (primary), Mailgun, or AWS SES fallback
"""
import os
import boto3
import httpx
import secrets
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from botocore.exceptions import ClientError
from typing import Optional
# Optional: Keep boto3 for legacy/fallback
try:
import boto3
from botocore.exceptions import ClientError
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
class EmailService:
def __init__(self):
self.region_name = os.getenv("AWS_SES_REGION", "us-east-1")
# Postal SMTP settings (primary - self-hosted)
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT", "25"))
self.smtp_username = os.getenv("SMTP_USERNAME")
self.smtp_password = os.getenv("SMTP_PASSWORD")
self.smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
# Mailgun settings (alternative)
self.mailgun_api_key = os.getenv("MAILGUN_API_KEY")
self.mailgun_domain = os.getenv("MAILGUN_DOMAIN")
self.mailgun_api_base = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3")
# AWS SES settings (fallback)
self.aws_region = 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")
# Common settings
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(
# Determine which provider to use (priority: SMTP -> Mailgun -> SES -> Dummy)
if self.smtp_host and self.smtp_username and self.smtp_password:
self.provider = "smtp"
print(f"Email service: Using SMTP ({self.smtp_host}:{self.smtp_port})")
elif self.mailgun_api_key and self.mailgun_domain:
self.provider = "mailgun"
print(f"Email service: Using Mailgun ({self.mailgun_domain})")
elif BOTO3_AVAILABLE and self.aws_access_key_id and self.aws_secret_access_key:
self.provider = "ses"
self.ses_client = boto3.client(
"ses",
region_name=self.region_name,
region_name=self.aws_region,
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
)
print("Email service: Using AWS SES")
else:
self.client = None
print("WARNING: AWS credentials not found. Email service running in dummy mode.")
self.provider = "dummy"
print("WARNING: No email credentials found. 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
"""Send an email using configured provider"""
if self.provider == "smtp":
return self._send_smtp(to_email, subject, html_content, text_content)
elif self.provider == "mailgun":
return self._send_mailgun(to_email, subject, html_content, text_content)
elif self.provider == "ses":
return self._send_ses(to_email, subject, html_content, text_content)
else:
return self._send_dummy(to_email, subject, text_content)
def _send_smtp(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send email via SMTP (Postal or any SMTP server)"""
try:
response = self.client.send_email(
# Create message
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"Elmeg <{self.email_from}>"
msg["To"] = to_email
# Attach text and HTML parts
msg.attach(MIMEText(text_content, "plain"))
msg.attach(MIMEText(html_content, "html"))
# Connect and send
if self.smtp_use_tls:
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
server.starttls()
else:
server = smtplib.SMTP(self.smtp_host, self.smtp_port)
server.login(self.smtp_username, self.smtp_password)
server.sendmail(self.email_from, to_email, msg.as_string())
server.quit()
print(f"Email sent via SMTP to {to_email}")
return True
except Exception as e:
print(f"Error sending email via SMTP: {e}")
return False
def _send_mailgun(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send email via Mailgun API"""
try:
with httpx.Client() as client:
response = client.post(
f"{self.mailgun_api_base}/{self.mailgun_domain}/messages",
auth=("api", self.mailgun_api_key),
data={
"from": f"Elmeg <{self.email_from}>",
"to": to_email,
"subject": subject,
"text": text_content,
"html": html_content,
},
timeout=30.0,
)
if response.status_code == 200:
print(f"Email sent via Mailgun to {to_email}")
return response.json()
else:
print(f"Mailgun error: {response.status_code} - {response.text}")
return False
except Exception as e:
print(f"Error sending email via Mailgun: {e}")
return False
def _send_ses(self, to_email: str, subject: str, html_content: str, text_content: str):
"""Send email via AWS SES (fallback)"""
try:
response = self.ses_client.send_email(
Source=self.email_from,
Destination={
"ToAddresses": [to_email],
},
Destination={"ToAddresses": [to_email]},
Message={
"Subject": {
"Data": subject,
"Charset": "UTF-8",
},
"Subject": {"Data": subject, "Charset": "UTF-8"},
"Body": {
"Html": {
"Data": html_content,
"Charset": "UTF-8",
},
"Text": {
"Data": text_content,
"Charset": "UTF-8",
},
"Html": {"Data": html_content, "Charset": "UTF-8"},
"Text": {"Data": text_content, "Charset": "UTF-8"},
},
},
)
print(f"Email sent via SES to {to_email}")
return response
except ClientError as e:
print(f"Error sending email: {e.response['Error']['Message']}")
print(f"SES error: {e.response['Error']['Message']}")
return False
def _send_dummy(self, to_email: str, subject: str, text_content: str):
"""Dummy mode - print to console"""
print(f"\n{'='*50}")
print(f"DUMMY EMAIL to: {to_email}")
print(f"Subject: {subject}")
print("-" * 50)
print(text_content)
print(f"{'='*50}\n")
return True
# Global instance
email_service = EmailService()
# --- Helper Functions (used by auth router) ---
# --- Helper Functions ---
def generate_token() -> str:
"""Generate a secure random token"""
@ -85,29 +187,29 @@ def send_verification_email(to_email: str, token: str):
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>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #2563eb; margin-top: 0;">Welcome to Elmeg! 🎵</h2>
<p>Thanks for joining the community. Please verify your email address to get started.</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{verify_url}" style="background: linear-gradient(135deg, #2563eb, #4f46e5); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: 600; display: inline-block;">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>
<p style="font-size: 12px; color: #888; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">{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>
<p style="font-size: 12px; color: #999; margin-bottom: 0;">If you didn't create an account, you can safely ignore this email.</p>
</div>
</body>
</html>
"""
text_content = f"""
Welcome to Elmeg!
Welcome to Elmeg!
Please verify your email address by visiting this link:
{verify_url}
Please verify your email address by visiting this link:
{verify_url}
If you didn't create an account, safely ignore this email.
If you didn't create an account, safely ignore this email.
"""
return email_service.send_email(to_email, subject, html_content, text_content)
@ -120,32 +222,32 @@ def send_password_reset_email(to_email: str, token: str):
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>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; background-color: #f5f5f5; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; padding: 40px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<h2 style="color: #2563eb; margin-top: 0;">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 style="margin: 30px 0; text-align: center;">
<a href="{reset_url}" style="background: linear-gradient(135deg, #2563eb, #4f46e5); color: white; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: 600; display: inline-block;">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>
<p style="font-size: 12px; color: #888; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">{reset_url}</p>
<p style="font-size: 14px; color: #e74c3c; font-weight: 500;"> 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>
<p style="font-size: 12px; color: #999; margin-bottom: 0;">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
Reset your Elmeg password
Click the link below to choose a new password:
{reset_url}
Click the link below to choose a new password:
{reset_url}
This link expires in 1 hour.
This link expires in 1 hour.
If you didn't request a password reset, safely ignore this email.
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

@ -222,11 +222,27 @@ def check_and_award_badges(session: Session, user: User) -> List[Badge]:
def get_leaderboard(session: Session, limit: int = 10) -> List[dict]:
"""Get top users by XP"""
users = session.exec(
select(User)
# Test accounts to hide until we have real users
TEST_USER_EMAILS = ["tenwest", "testuser"]
MIN_USERS_TO_SHOW_TEST = 12
# Count total real users
total_users = session.exec(
select(func.count(User.id))
.where(User.is_active == True)
.order_by(User.xp.desc())
.limit(limit)
).one() or 0
# Build query
query = select(User).where(User.is_active == True)
# If we don't have enough real users, hide test accounts
if total_users < MIN_USERS_TO_SHOW_TEST:
for test_email in TEST_USER_EMAILS:
query = query.where(~User.email.ilike(f"{test_email}@%"))
query = query.where(~User.email.ilike(f"%{test_email}%"))
users = session.exec(
query.order_by(User.xp.desc()).limit(limit)
).all()
return [

View file

@ -9,12 +9,20 @@ services:
- ./backend:/app
- backend_data:/app/data
environment:
- DATABASE_URL=postgresql://elmeg:elmeg_password@db:5432/elmeg
- DATABASE_URL=${DATABASE_URL:-postgresql://elmeg:elmeg_password@db:5432/elmeg}
- SECRET_KEY=${SECRET_KEY:-demo-secret-change-in-production}
# Postal SMTP (primary)
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-25}
- SMTP_USERNAME=${SMTP_USERNAME}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_USE_TLS=${SMTP_USE_TLS:-true}
# AWS SES (fallback)
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_SES_REGION=${AWS_SES_REGION}
- EMAIL_FROM=${EMAIL_FROM}
# Common
- EMAIL_FROM=${EMAIL_FROM:-noreply@elmeg.xyz}
- FRONTEND_URL=${FRONTEND_URL:-https://elmeg.xyz}
command: sh start.sh
depends_on:
@ -28,6 +36,7 @@ services:
networks:
- elmeg
- traefik-public
- postal-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.elmeg-backend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/api`)"
@ -38,7 +47,7 @@ services:
- "traefik.http.routers.elmeg-backend.middlewares=elmeg-strip"
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
- "traefik.docker.network=traefik-public"
- "traefik.docker.network=traefik"
# Direct routes for docs (no strip)
- "traefik.http.routers.elmeg-backend-docs.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
@ -71,7 +80,7 @@ services:
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
- "traefik.http.routers.elmeg-frontend.priority=50"
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
- "traefik.docker.network=traefik"
db:
image: postgres:15-alpine
@ -90,9 +99,63 @@ services:
networks:
- elmeg
db-backup:
image: prodrigestivill/postgres-backup-local:15-alpine
restart: unless-stopped
volumes:
- ./backups:/backups
- postgres_data:/var/lib/postgresql/data:ro
environment:
- POSTGRES_HOST=db
- POSTGRES_DB=elmeg
- POSTGRES_USER=elmeg
- POSTGRES_PASSWORD=elmeg_password
- SCHEDULE=@daily
- BACKUP_KEEP_DAYS=7
- BACKUP_KEEP_WEEKS=4
- BACKUP_KEEP_MONTHS=6
- HEALTHCHECK_PORT=80
depends_on:
- db
networks:
- elmeg
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://umami:umami_password@umami-db:5432/umami
- APP_SECRET=${UMAMI_SECRET:-highly-secret-key-change-this}
- TRACKER_SCRIPT_NAME=stats
depends_on:
- umami-db
networks:
- elmeg
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.elmeg-umami.rule=Host(`stats.elmeg.xyz`) || Host(`stats.elmeg.runfoo.run`)"
- "traefik.http.routers.elmeg-umami.entrypoints=websecure"
- "traefik.http.routers.elmeg-umami.tls.certresolver=letsencrypt"
- "traefik.http.services.elmeg-umami.loadbalancer.server.port=3000"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
umami-db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=umami
- POSTGRES_PASSWORD=umami_password
- POSTGRES_DB=umami
volumes:
- umami_data:/var/lib/postgresql/data
restart: unless-stopped
networks:
- elmeg
volumes:
postgres_data:
backend_data:
umami_data:
networks:
@ -100,3 +163,6 @@ networks:
traefik-public:
name: ${TRAEFIK_NETWORK:-traefik}
external: true
postal-internal:
name: postal_postal-internal
external: true

217
docs/BANDCAMP_NUGS_SPEC.md Normal file
View file

@ -0,0 +1,217 @@
# Bandcamp & Nugs Integration Spec
**Date:** 2023-12-23
**Purpose:** Link shows and performances to official audio sources
---
## Overview
Add support for linking to official audio releases on:
- **Bandcamp** - Official studio/live releases, digital purchases
- **Nugs.net** - Live show streams/downloads, SBD recordings
---
## Database Changes
### Option A: Simple Link Fields (MVP)
Add to existing models:
```python
# Show model
class Show(SQLModel, table=True):
# ... existing fields ...
nugs_link: Optional[str] = None # Full Nugs.net URL
bandcamp_link: Optional[str] = None # Full Bandcamp URL
# Performance model
class Performance(SQLModel, table=True):
# ... existing fields ...
nugs_link: Optional[str] = None # Link to specific track on Nugs
bandcamp_link: Optional[str] = None # Link to specific track on Bandcamp
```
### Option B: Structured Link Table (Future)
For more flexibility:
```python
class ExternalLink(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
# Polymorphic reference
entity_type: str # "show" | "performance" | "song"
entity_id: int
# Link info
platform: str # "nugs" | "bandcamp" | "youtube" | "archive" | "spotify"
url: str
label: Optional[str] = None # Custom label like "SBD Recording"
is_official: bool = True
created_at: datetime
created_by: Optional[int] # User who added it
```
---
## API Endpoints
### Update Show (Admin)
```
PATCH /admin/shows/{id}
{
"nugs_link": "https://nugs.net/...",
"bandcamp_link": "https://bandcamp.com/..."
}
```
### Update Performance (Admin)
```
PATCH /admin/performances/{id}
{
"nugs_link": "https://nugs.net/...",
"bandcamp_link": "https://bandcamp.com/..."
}
```
### Bulk Import Links
```
POST /admin/import/external-links
{
"links": [
{"show_id": 123, "platform": "nugs", "url": "..."},
{"performance_id": 456, "platform": "bandcamp", "url": "..."}
]
}
```
---
## Frontend Display
### Show Page
```
┌────────────────────────────────────────────┐
│ 📅 December 13, 2025 @ The Anthem │
│ │
│ [▶ Watch on YouTube] [🎧 Nugs] [🎵 Bandcamp]│
│ │
│ Set 1: │
│ 1. Song Title [🎧] [🎵] ← per-track │
│ 2. Another Song │
└────────────────────────────────────────────┘
```
### Icon/Button Design
```tsx
const PLATFORM_ICONS = {
nugs: { icon: Headphones, label: "Nugs.net", color: "#ff6b00" },
bandcamp: { icon: Music, label: "Bandcamp", color: "#629aa9" },
youtube: { icon: Youtube, label: "YouTube", color: "#ff0000" },
}
```
---
## Data Sources
### Nugs.net
- Shows listed at: <https://nugs.net/artist/>...
- Direct track links available
- May have SBD vs AUD quality indicators
### Bandcamp
- Live releases often on artist's Bandcamp
- Track-level linking possible
- May include "pay what you want" vs fixed price
---
## Import Workflow
### Manual Entry (Admin UI)
1. Admin navigates to show/performance
2. Clicks "Add External Links"
3. Pastes URL, selects platform
4. Saves
### Bulk CSV Import
```csv
show_date,platform,url
2024-12-13,nugs,https://nugs.net/live/...
2024-12-13,bandcamp,https://bandcamp.com/album/...
```
### API Scraping (Future)
- Could auto-detect new releases on Nugs
- Match by date/venue
- Requires API access or web scraping
---
## Implementation Phases
### Phase 1: MVP (Database + Admin)
- [ ] Add `nugs_link`, `bandcamp_link` to Show model
- [ ] Add `nugs_link`, `bandcamp_link` to Performance model
- [ ] Run migration
- [ ] Add admin endpoints to update links
### Phase 2: Frontend Display
- [ ] Show links on Show page (next to YouTube)
- [ ] Show links on Performance rows
- [ ] Add platform icons/colors
### Phase 3: Import Tools
- [ ] CSV import endpoint
- [ ] Admin bulk edit UI
### Phase 4: User Features (Optional)
- [ ] Users can suggest links (moderated)
- [ ] "I own this" tracking for collectors
---
## Estimated Effort
| Phase | Time |
|-------|------|
| Phase 1 | 30 min |
| Phase 2 | 1 hour |
| Phase 3 | 1 hour |
| Phase 4 | 2+ hours |
---
## Questions to Resolve
1. **Should users be able to add links?** Or admin-only?
2. **Verify link quality?** Some Nugs links are AUD, some SBD
3. **Other platforms?** Spotify, Apple Music, Archive.org, Relisten?
4. **Affiliate links?** If monetizing, need affiliate program setup
---
## Next Steps
1. Run database migration
2. Add admin API endpoints
3. Update Show page UI with link buttons

248
docs/BUGS_TRACKER_SPEC.md Normal file
View file

@ -0,0 +1,248 @@
# Elmeg Bug Tracker Spec
**Domain:** bugs.elmeg.xyz
**Style:** Zendesk-inspired support portal
**Date:** 2023-12-23
---
## Overview
A lightweight, self-hosted bug/feedback tracker with a clean, user-first interface. Users can submit issues, track status, and browse known issues. Admins can triage, respond, and resolve.
---
## User-Facing Features
### 1. Submit a Report
- **Type:** Bug, Feature Request, Question, Other
- **Title:** Short description (required)
- **Description:** Rich text with markdown support
- **Attachments:** Screenshot upload (optional)
- **Priority:** (auto or user-selectable) Low, Medium, High, Critical
- **Email:** For anonymous users (logged-in users auto-fill)
- **Environment:** Auto-capture: Browser, OS, page URL
### 2. My Tickets
- View submitted tickets
- Track status: Open → In Progress → Resolved → Closed
- Add comments/updates to existing tickets
- Receive email notifications on updates
### 3. Knowledge Base / Known Issues
- Browse open bugs (public visibility toggle per ticket)
- Search functionality
- "Me too" upvote button
- FAQ section
### 4. Anonymous vs Authenticated
- Logged-in Elmeg users: auto-linked, no email required
- Anonymous: requires email for updates
---
## Admin Features
### Dashboard
- Ticket queue (filterable by status, type, priority, date)
- Unassigned tickets highlight
- Metrics: open count, avg response time, resolution rate
### Ticket Management
- Assign to self or team member
- Internal notes (not visible to user)
- Public reply
- Change status, priority, type
- Merge duplicate tickets
- Mark as known issue (public)
### Canned Responses
- Pre-written templates for common issues
- Variable substitution: `{user_name}`, `{ticket_id}`
---
## Technical Architecture
### Stack
- **Frontend:** Next.js (standalone app, shares Elmeg styling)
- **Backend:** FastAPI (can share auth with main Elmeg backend)
- **Database:** PostgreSQL (new schema in same db or separate)
- **Storage:** S3/local for attachments
### Models
```
Ticket
├── id (uuid)
├── ticket_number (auto-increment display ID: ELM-001)
├── type (bug | feature | question | other)
├── status (open | in_progress | resolved | closed)
├── priority (low | medium | high | critical)
├── title
├── description
├── reporter_email
├── reporter_user_id (nullable, FK to elmeg user)
├── assigned_to (nullable, FK to admin user)
├── is_public (for known issues)
├── upvotes
├── environment (JSON: browser, os, url)
├── created_at
├── updated_at
├── resolved_at
TicketComment
├── id
├── ticket_id (FK)
├── author_id (FK)
├── author_email (for anonymous)
├── content
├── is_internal (admin-only visibility)
├── created_at
TicketAttachment
├── id
├── ticket_id (FK)
├── filename
├── url
├── created_at
CannedResponse
├── id
├── title
├── content
├── created_by
```
### API Endpoints
**Public:**
- `POST /tickets` - Create ticket
- `GET /tickets/{ticket_number}` - View ticket (if public or owned)
- `POST /tickets/{id}/comments` - Add comment
- `POST /tickets/{id}/upvote` - Upvote known issue
- `GET /known-issues` - List public tickets
**Authenticated (Elmeg user):**
- `GET /my-tickets` - User's tickets
**Admin:**
- `GET /admin/tickets` - All tickets (filtered)
- `PATCH /admin/tickets/{id}` - Update status, assign, etc.
- `POST /admin/tickets/{id}/internal-note` - Add internal note
- `GET /admin/canned-responses`
- `POST /admin/canned-responses`
---
## UI Pages
| Route | Purpose |
|-------|---------|
| `/` | Landing: "How can we help?" + Submit form |
| `/submit` | Full bug report form |
| `/my-tickets` | User's submitted tickets |
| `/ticket/[id]` | Ticket detail + comments |
| `/known-issues` | Public bug list with search |
| `/admin` | Admin dashboard |
| `/admin/ticket/[id]` | Admin ticket view |
---
## Email Notifications
| Event | Recipient |
|-------|-----------|
| Ticket created | User (confirmation) |
| Admin replies | User |
| Status changed | User |
| User comment | Assigned admin |
Uses existing AWS SES integration.
---
## Deployment
### Docker Compose (separate service)
```yaml
services:
bugs-frontend:
build: ./bugs-frontend
labels:
- "traefik.http.routers.bugs.rule=Host(`bugs.elmeg.xyz`)"
bugs-backend:
build: ./bugs-backend
environment:
- DATABASE_URL=postgresql://...
- ELMEG_API_URL=https://elmeg.xyz/api
```
### DNS
Add A record or CNAME for `bugs.elmeg.xyz` → production server
### Auth Integration
- Share JWT validation with main Elmeg backend
- Or standalone with optional Elmeg SSO
---
## MVP Scope (Phase 1)
- [ ] Submit ticket form
- [ ] My tickets list
- [ ] Ticket detail with comments
- [ ] Admin ticket queue
- [ ] Email on ticket creation
- [ ] Basic styling matching Elmeg
## Phase 2
- [ ] Known issues page
- [ ] Upvoting
- [ ] Canned responses
- [ ] Internal notes
- [ ] Attachment uploads
- [ ] Search
## Phase 3
- [ ] Metrics dashboard
- [ ] Merge tickets
- [ ] Tags/labels
- [ ] Webhook integrations (Discord, Slack)
---
## Estimated Effort
| Phase | Effort |
|-------|--------|
| MVP | 2-3 days |
| Phase 2 | 2 days |
| Phase 3 | 2 days |
---
## Next Steps
1. Create `/srv/containers/elmeg-bugs` directory structure
2. Initialize Next.js + FastAPI project
3. Set up Traefik routing for `bugs.elmeg.xyz`
4. Implement MVP models + endpoints
5. Deploy

233
docs/MAILGUN_SETUP.md Normal file
View file

@ -0,0 +1,233 @@
# Mailgun Integration Spec
**Date:** 2023-12-23
**Domain:** elmeg.xyz
**Purpose:** Transactional email (signup, login, password reset, notifications)
---
## Overview
Elmeg will use **Mailgun** as the transactional email provider instead of AWS SES. Traffic is low-volume and strictly transactional; no marketing or bulk sending.
---
## Account & Domain Setup
### 1. Mailgun Account
- Create/use shared Mailgun org account owned by Antigravity (not personal)
- Add maintainers with least-privilege roles
### 2. Domain Configuration
- **Sending Domain:** `mail.elmeg.xyz` (subdomain recommended)
- Select appropriate region (US or EU)
### 3. DNS Records (Cloudflare)
Add the following records as provided by Mailgun:
```
# SPF
TXT mail.elmeg.xyz "v=spf1 include:mailgun.org ~all"
# DKIM (2 records typically)
TXT smtp._domainkey.mail.elmeg.xyz "k=rsa; p=..."
TXT mailo._domainkey.mail.elmeg.xyz "k=rsa; p=..."
# DMARC
TXT _dmarc.mail.elmeg.xyz "v=DMARC1; p=none; rua=mailto:dmarc@elmeg.xyz"
# MX (for receiving bounces)
MX mail.elmeg.xyz mxa.mailgun.org 10
MX mail.elmeg.xyz mxb.mailgun.org 20
# Tracking (optional)
CNAME email.mail.elmeg.xyz mailgun.org
```
Wait for Mailgun to show domain as **"Verified"** before switching production.
---
## Sending Model & Limits
| Aspect | Configuration |
|--------|---------------|
| Plan | Free tier (~100 emails/day), scales to pay-per-use |
| Budget | Well under $10/month for current volume |
| Sending Identity | `no-reply@mail.elmeg.xyz` for notifications |
| Reply Address | `support@elmeg.xyz` for human replies |
| Email Types | Account creation, login/OTP, password reset, security alerts |
| **Not Allowed** | Newsletters, promos, bulk imports |
---
## Application Integration
### Environment Variables
```env
# Mailgun API Credentials
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
MAILGUN_DOMAIN=mail.elmeg.xyz
MAILGUN_API_BASE=https://api.mailgun.net/v3 # or eu.api.mailgun.net for EU
# Legacy (keep for transition)
EMAIL_FROM=no-reply@mail.elmeg.xyz
# Optional SMTP (if not using API)
MAILGUN_SMTP_HOST=smtp.mailgun.org
MAILGUN_SMTP_PORT=587
MAILGUN_SMTP_LOGIN=postmaster@mail.elmeg.xyz
MAILGUN_SMTP_PASSWORD=xxxxxxxx
```
### Recommended: Use HTTP API
Prefer Mailgun's HTTP API over SMTP for:
- Better error reporting
- Delivery metrics
- Simpler debugging
### Code Changes Required
Update `backend/services/email.py` (or equivalent) to:
```python
import httpx
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY")
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN")
MAILGUN_API_BASE = os.getenv("MAILGUN_API_BASE", "https://api.mailgun.net/v3")
async def send_email(to: str, subject: str, text: str, html: str = None):
"""Send email via Mailgun API"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{MAILGUN_API_BASE}/{MAILGUN_DOMAIN}/messages",
auth=("api", MAILGUN_API_KEY),
data={
"from": f"Elmeg <no-reply@{MAILGUN_DOMAIN}>",
"to": to,
"subject": subject,
"text": text,
"html": html,
}
)
response.raise_for_status()
return response.json()
```
---
## Email Templates
### Registration/Verification
```
Subject: Verify your Elmeg account
From: Elmeg <no-reply@mail.elmeg.xyz>
Hi {username},
Welcome to Elmeg! Please verify your email address:
{verification_link}
This link expires in 24 hours.
If you didn't create this account, please ignore this email.
— The Elmeg Team
```
### Password Reset
```
Subject: Reset your Elmeg password
From: Elmeg <no-reply@mail.elmeg.xyz>
Hi {username},
You requested a password reset. Click below to set a new password:
{reset_link}
This link expires in 1 hour.
If you didn't request this, you can safely ignore this email.
— The Elmeg Team
```
---
## Deliverability & Monitoring
| Setting | Value |
|---------|-------|
| Open/Click Tracking | Off (for transactional, reduces complexity) |
| Bounce Handling | Monitor in Mailgun dashboard |
| Target Metrics | <1% bounce rate, <0.1% complaints |
### Monitoring Checklist
- [ ] Check Mailgun dashboard weekly for delivery rates
- [ ] Review any bounces/complaints immediately
- [ ] Ramp up gradually if sending volume increases
---
## Migration Steps
### Phase 1: Setup (Today)
1. [ ] Create/access Mailgun account
2. [ ] Add `mail.elmeg.xyz` domain
3. [ ] Configure DNS in Cloudflare
4. [ ] Wait for domain verification
### Phase 2: Integration
5. [ ] Add `httpx` to requirements.txt
2. [ ] Update email service to use Mailgun API
3. [ ] Add environment variables to production `.env`
4. [ ] Test with a single email before full migration
### Phase 3: Switchover
9. [ ] Remove AWS SES credentials from production
2. [ ] Update documentation
3. [ ] Monitor deliverability for first week
---
## Security & Access
| Role | Access |
|------|--------|
| Org Owner | Full Mailgun access (Antigravity) |
| Maintainers | Send access, dashboard view |
| DNS | Cloudflare managed by Antigravity |
| Credentials | Environment variables only, never in Git |
---
## Future Considerations
- **Marketing Email:** If needed, use separate provider (Sendgrid, Postmark) to keep Mailgun clean
- **High Volume:** If >100/day, upgrade to paid tier
- **Webhooks:** Mailgun can send delivery/bounce webhooks for advanced tracking
---
## Cost Estimate
| Tier | Volume | Cost |
|------|--------|------|
| Free | ~100/day | $0 |
| Flex | Pay per 1,000 | ~$0.80/1,000 |
| Estimated Monthly | ~500 emails | <$1 |

View file

@ -1,72 +1,171 @@
# Future Roadmap & Implementation Plan
# Elmeg Platform Roadmap
## 1. Cross-Vertical "Fandom Federation" (Future Feature)
**Concept**: Enable cross-pollination between different band/fandom instances (Verticals).
**Use Case**: A user mentions `@Phish` in the `Goose` instance, or a guest artist like "Trey Anastasio" links to his stats in the Phish vertical.
**Implementation Strategy**:
* **Federated Identity**: A single `User` account works across all verticals (already partially supported by our schema).
* **Universal Resolver**: A service that resolves links like `elmeg://phish/shows/123` or `@phish:user_123`.
* **Shared Artist Database**: A global table of Artists that links to specific performances across all verticals.
**Last Updated:** 2023-12-23
---
## 2. Immediate Implementation Plan (V1.1 Polish)
## Current Status Summary
We will tackle the following gaps to round out the V1 experience:
### ✅ Email Service - COMPLETE
### Phase A: Personalization & "Wiki Mode"
**Status: POSTAL SELF-HOSTED (PRODUCTION READY)**
**Goal**: Allow users to customize their experience, specifically enabling the "pure archive" feel.
| Component | Status |
|-----------|--------|
| Postal Mail Server | ✅ Running on tangible-aacorn |
| SMTP Host | `smtp.elmeg.xyz:25` |
| Web Dashboard | <https://postal.elmeg.xyz> |
| SPF Record | ✅ Configured |
| DKIM Record | ✅ Configured |
| DMARC Record | ✅ Configured |
| Return Path | ✅ Configured |
1. **Settings Page**: Create `/settings` route.
2. **Preferences UI**: Toggles for:
* `Wiki Mode` (Hides comments, ratings, social noise).
* `Show Ratings` (Toggle visibility of 1-10 scores).
* `Show Comments` (Toggle visibility of discussion sections).
3. **Frontend Logic**: Wrap social components in a context provider that respects these flags.
**SMTP Credentials:**
### Phase B: Moderation Dashboard
- Username: `elmeg/main`
- Password: (in production .env)
**Goal**: Empower admins to maintain data quality and community standards.
### Templates Available
1. **Admin Route**: Create `/admin` (protected by `is_superuser` or `role=admin`).
2. **Nickname Queue**: List `pending` nicknames with Approve/Reject actions.
3. **Report Queue**: List reported content with Dismiss/Delete actions.
4. **User Management**: Basic list of users with Ban/Promote options.
| Template | Status |
|----------|--------|
| Email Verification | ✅ Ready |
| Password Reset | ✅ Ready |
### Phase C: Activity Feed (The "Pulse")
---
**Goal**: Make the platform feel alive and aid discovery.
## Recent Completions (Dec 23, 2023)
1. **Global Feed**: Aggregated stream of:
* New Reviews
1. **Global Feed**: Aggregated stream of:
* New Reviews
* New Show Attendance
* New Groups created
* Rare stats/milestones (e.g., "User X attended their 100th show")
2. **Home Page Widget**: Replace static content on Home with this dynamic feed.
| Feature | Status |
|---------|--------|
| Privacy Settings (3 toggles) | ✅ Complete |
| Sticky Settings Sidebar | ✅ Complete |
| Bug Tracker MVP | ✅ Deployed |
| Auth Console Error Fix | ✅ Fixed |
| Videos Page Link Fix | ✅ Fixed |
| Hide Test Users | ✅ Implemented |
| Bandcamp/Nugs Links | ✅ Complete |
| Enhanced Footer | ✅ Deployed |
| **Postal Mail Server** | ✅ Built & Deployed |
| **Email DNS Records** | ✅ SPF/DKIM/DMARC/RP |
| **SMTP Integration** | ✅ Backend configured |
### Phase D: Visualizations & Deep Stats
---
**Goal**: Provide the "crunchy" data fans love.
## Postal Mail Server Details
1. **Gap Chart**: A visual bar chart on Song Pages showing the gap between performances.
2. **Heatmaps**: "Shows by Year" or "Shows by State" maps on Artist/Band pages.
3. **Graph View**: (Mind Map precursor) Simple node-link diagram of related songs/shows.
### Infrastructure
### Phase E: Glossary (Wiki-Style Knowledge Base)
| Component | Details |
|-----------|---------|
| Location | tangible-aacorn (Hetzner ARM64) |
| Build | Custom ARM64 from source |
| Database | MariaDB 11 |
| Queue | RabbitMQ 3.13 |
| Routing | Traefik with Let's Encrypt |
**Goal**: Build a community-curated glossary of fandom terms.
### DNS Records (Cloudflare)
1. **Glossary Entry Model**: Term, definition, example, category, status.
2. **Edit History**: Track suggested edits with approval workflow.
3. **Public Pages**: `/glossary` index and `/glossary/[term]` detail pages.
4. **Moderation**: Admin queue for approving/rejecting entries and edits.
5. **Integration**: Include in global search, auto-link in comments.
| Type | Name | Value |
|------|------|-------|
| A | postal | 159.69.219.254 (DNS only) |
| A | smtp | 159.69.219.254 (DNS only) |
| MX | @ | smtp.elmeg.xyz (Priority 10) |
| TXT | @ | v=spf1 mx a ip4:159.69.219.254 ~all |
| TXT | postal-VkYvkc._domainkey | v=DKIM1; t=s; h=sha256; p=... |
| TXT | _dmarc | v=DMARC1; p=none; rua=mailto:admin@elmeg.xyz |
| CNAME | psrp | smtp.elmeg.xyz |
## 3. Execution Order
### Admin Access
4. **Phase D (Stats)**: "Nice to have" polish.
- **URL:** <https://postal.elmeg.xyz>
- **Login:** <admin@elmeg.xyz>
- **Organization:** Elmeg
- **Mail Server:** main
---
## Settings Page Status
### Phase 1: Quick Wins ✅ COMPLETE
| Feature | Status |
|---------|--------|
| Privacy: Public Profile | ✅ Done |
| Privacy: Show Attendance | ✅ Done |
| Privacy: Leaderboards | ✅ Done |
| Theme Persistence | ✅ Works client-side |
### Phase 2: Notifications (Deferred)
| Feature | Dependency | Status |
|---------|------------|--------|
| Comment Replies | Notification system | Ready to implement |
| New Show Added | Import trigger hook | Needs backend work |
| Chase Song Played | Post-import check | Needs backend work |
| Weekly Digest | Email templates + cron | Future |
---
## External Links System
### ✅ Phase 1: Database + Admin - COMPLETE
### ✅ Phase 2: Frontend Display - COMPLETE
### Phase 3: Import Tools (Future)
---
## Bug Tracker
**Status: ✅ DEPLOYED at `/bugs`**
---
## Avatar System Roadmap
### ✅ Phase 1: Jewel Tones (Complete)
### Phase 2-4: (Future)
---
## Pending Tasks
### High Priority
- [ ] Test email verification flow end-to-end
- [ ] Test password reset flow end-to-end
### Medium Priority
- [ ] Analytics provider decision
- [ ] Notification preferences backend
- [ ] Avatar unlock system
### Low Priority
- [ ] bugs.elmeg.xyz subdomain
- [ ] Data export (GDPR)
- [ ] Account deletion
---
## Implementation Priority
### Immediate (Testing)
1. [ ] Register test account to trigger verification email
2. [ ] Test password reset flow
3. [ ] Monitor email deliverability in Postal dashboard
### This Week
- [ ] Answer analytics question
- [ ] Theme persistence to user preferences
### Next Sprint
- [ ] Notification preferences backend
- [ ] Avatar unlock system

View file

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

View file

@ -0,0 +1,143 @@
"use client"
/**
* Known Issues Page - Public bugs/feature requests
*/
import { useEffect, useState } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { ArrowLeft, ThumbsUp, Bug, Lightbulb, Loader2 } from "lucide-react"
interface Ticket {
id: number
ticket_number: string
type: string
status: string
priority: string
title: string
description: string
upvotes: number
created_at: string
}
const TYPE_ICONS: Record<string, typeof Bug> = {
bug: Bug,
feature: Lightbulb,
}
export default function KnownIssuesPage() {
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchKnownIssues()
}, [])
const fetchKnownIssues = async () => {
try {
const res = await fetch(`${getApiUrl()}/tickets/known-issues`)
if (res.ok) {
setTickets(await res.json())
}
} catch (e) {
console.error("Failed to fetch known issues", e)
} finally {
setLoading(false)
}
}
const handleUpvote = async (ticketNumber: string, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await fetch(`${getApiUrl()}/tickets/${ticketNumber}/upvote`, {
method: "POST",
})
// Optimistic update
setTickets(tickets.map(t =>
t.ticket_number === ticketNumber
? { ...t, upvotes: t.upvotes + 1 }
: t
))
} catch (e) {
console.error("Failed to upvote", e)
}
}
return (
<div className="container max-w-4xl py-8">
<div className="flex items-center gap-4 mb-8">
<Link href="/bugs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">Known Issues</h1>
<p className="text-muted-foreground">Active bugs and feature requests</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tickets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No known issues at this time.</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{tickets.map((ticket) => {
const Icon = TYPE_ICONS[ticket.type] || Bug
return (
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-muted-foreground">
{ticket.ticket_number}
</span>
<Badge variant={ticket.status === "in_progress" ? "secondary" : "default"}>
{ticket.status === "in_progress" ? "In Progress" : "Open"}
</Badge>
</div>
<h3 className="font-semibold">{ticket.title}</h3>
{ticket.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{ticket.description}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
className="gap-2 shrink-0"
onClick={(e) => handleUpvote(ticket.ticket_number, e)}
>
<ThumbsUp className="h-4 w-4" />
{ticket.upvotes}
</Button>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,146 @@
"use client"
/**
* My Tickets Page - View user's submitted tickets
*/
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { ArrowLeft, Clock, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
interface Ticket {
id: number
ticket_number: string
type: string
status: string
priority: string
title: string
created_at: string
updated_at: string
}
const STATUS_STYLES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
open: { label: "Open", variant: "default" },
in_progress: { label: "In Progress", variant: "secondary" },
resolved: { label: "Resolved", variant: "outline" },
closed: { label: "Closed", variant: "outline" },
}
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500",
medium: "bg-yellow-500",
high: "bg-orange-500",
critical: "bg-red-500",
}
export default function MyTicketsPage() {
const { user } = useAuth()
const [tickets, setTickets] = useState<Ticket[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
if (user) {
fetchTickets()
}
}, [user])
const fetchTickets = async () => {
try {
const token = localStorage.getItem("token")
const res = await fetch(`${getApiUrl()}/tickets/my-tickets`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
setTickets(data)
}
} catch (e) {
console.error("Failed to fetch tickets", e)
} finally {
setLoading(false)
}
}
if (!user) {
return (
<div className="container max-w-2xl py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Please Log In</h1>
<p className="text-muted-foreground mb-6">
You need to be logged in to view your tickets.
</p>
<Link href="/login">
<Button>Log In</Button>
</Link>
</div>
)
}
return (
<div className="container max-w-4xl py-8">
<div className="flex items-center gap-4 mb-8">
<Link href="/bugs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">My Tickets</h1>
<p className="text-muted-foreground">Track your submitted issues</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tickets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-4">You haven't submitted any tickets yet.</p>
<Link href="/bugs">
<Button>Submit a Ticket</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{tickets.map((ticket) => (
<Link key={ticket.id} href={`/bugs/ticket/${ticket.ticket_number}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-mono text-sm text-muted-foreground">
{ticket.ticket_number}
</span>
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
{STATUS_STYLES[ticket.status]?.label || ticket.status}
</Badge>
</div>
<h3 className="font-semibold truncate">{ticket.title}</h3>
<p className="text-sm text-muted-foreground">
{new Date(ticket.created_at).toLocaleDateString()}
</p>
</div>
<div className="flex items-center">
{ticket.status === "open" && <Clock className="h-4 w-4 text-muted-foreground" />}
{ticket.status === "resolved" && <CheckCircle className="h-4 w-4 text-green-500" />}
{ticket.status === "in_progress" && <AlertCircle className="h-4 w-4 text-yellow-500" />}
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)
}

299
frontend/app/bugs/page.tsx Normal file
View file

@ -0,0 +1,299 @@
"use client"
/**
* Bug Tracker - Main Page
* Submit bugs, feature requests, and questions
*/
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import {
Bug,
Lightbulb,
HelpCircle,
MessageSquare,
CheckCircle,
ArrowRight,
ExternalLink
} from "lucide-react"
import Link from "next/link"
import { getApiUrl } from "@/lib/api-config"
import { useAuth } from "@/contexts/auth-context"
const TICKET_TYPES = [
{ value: "bug", label: "Bug Report", icon: Bug, description: "Something isn't working" },
{ value: "feature", label: "Feature Request", icon: Lightbulb, description: "Suggest an improvement" },
{ value: "question", label: "Question", icon: HelpCircle, description: "Ask for help" },
{ value: "other", label: "Other", icon: MessageSquare, description: "General feedback" },
]
const PRIORITIES = [
{ value: "low", label: "Low", color: "bg-gray-500" },
{ value: "medium", label: "Medium", color: "bg-yellow-500" },
{ value: "high", label: "High", color: "bg-orange-500" },
{ value: "critical", label: "Critical", color: "bg-red-500" },
]
export default function BugsPage() {
const { user } = useAuth()
const [step, setStep] = useState<"type" | "form" | "success">("type")
const [selectedType, setSelectedType] = useState<string>("bug")
const [submitting, setSubmitting] = useState(false)
const [ticketNumber, setTicketNumber] = useState<string>("")
const [error, setError] = useState("")
const [formData, setFormData] = useState({
title: "",
description: "",
priority: "medium",
reporter_email: "",
reporter_name: "",
})
const handleTypeSelect = (type: string) => {
setSelectedType(type)
setStep("form")
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError("")
try {
const token = localStorage.getItem("token")
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const res = await fetch(`${getApiUrl()}/tickets/`, {
method: "POST",
headers,
body: JSON.stringify({
type: selectedType,
priority: formData.priority,
title: formData.title,
description: formData.description,
reporter_email: user?.email || formData.reporter_email,
reporter_name: formData.reporter_name,
page_url: window.location.href,
browser: navigator.userAgent,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || "Failed to submit")
}
const ticket = await res.json()
setTicketNumber(ticket.ticket_number)
setStep("success")
} catch (e: any) {
setError(e.message || "Failed to submit ticket")
} finally {
setSubmitting(false)
}
}
// Success screen
if (step === "success") {
return (
<div className="container max-w-2xl py-16">
<Card className="text-center">
<CardContent className="pt-12 pb-8 space-y-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 dark:bg-green-900">
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h1 className="text-2xl font-bold">Ticket Submitted!</h1>
<p className="text-muted-foreground mt-2">
Your ticket number is <span className="font-mono font-bold">{ticketNumber}</span>
</p>
</div>
<p className="text-sm text-muted-foreground">
We'll review your submission and get back to you soon.
</p>
<div className="flex gap-4 justify-center pt-4">
<Link href="/bugs/my-tickets">
<Button variant="outline">View My Tickets</Button>
</Link>
<Button onClick={() => { setStep("type"); setFormData({ title: "", description: "", priority: "medium", reporter_email: "", reporter_name: "" }) }}>
Submit Another
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container max-w-4xl py-8">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4">How can we help?</h1>
<p className="text-muted-foreground text-lg">
Report bugs, request features, or ask questions
</p>
</div>
{step === "type" && (
<>
{/* Type Selection */}
<div className="grid md:grid-cols-2 gap-4 mb-8">
{TICKET_TYPES.map((type) => {
const Icon = type.icon
return (
<button
key={type.value}
onClick={() => handleTypeSelect(type.value)}
className="text-left p-6 rounded-xl border-2 border-border hover:border-primary transition-colors bg-card hover:bg-accent group"
>
<div className="flex items-start gap-4">
<div className="p-3 rounded-lg bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
<Icon className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-lg">{type.label}</h3>
<p className="text-muted-foreground text-sm">{type.description}</p>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground group-hover:text-primary transition-colors" />
</div>
</button>
)
})}
</div>
{/* Quick Links */}
<div className="flex flex-wrap gap-4 justify-center">
<Link href="/bugs/known-issues">
<Button variant="outline" className="gap-2">
<ExternalLink className="h-4 w-4" />
Known Issues
</Button>
</Link>
{user && (
<Link href="/bugs/my-tickets">
<Button variant="outline" className="gap-2">
My Tickets
</Button>
</Link>
)}
</div>
</>
)}
{step === "form" && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setStep("type")}>
Back
</Button>
</div>
<CardTitle className="flex items-center gap-2">
{TICKET_TYPES.find(t => t.value === selectedType)?.icon && (
(() => {
const Icon = TICKET_TYPES.find(t => t.value === selectedType)!.icon
return <Icon className="h-5 w-5" />
})()
)}
{TICKET_TYPES.find(t => t.value === selectedType)?.label}
</CardTitle>
<CardDescription>
Fill out the details below
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
placeholder="Brief summary of the issue"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
maxLength={200}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Provide more details. What happened? What did you expect?"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={5}
/>
</div>
{/* Priority */}
<div className="space-y-2">
<Label>Priority</Label>
<div className="flex gap-2">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setFormData({ ...formData, priority: p.value })}
className={`px-4 py-2 rounded-lg border-2 transition-colors ${formData.priority === p.value
? "border-primary bg-primary/10"
: "border-border hover:border-muted-foreground"
}`}
>
<span className={`inline-block w-2 h-2 rounded-full ${p.color} mr-2`} />
{p.label}
</button>
))}
</div>
</div>
{/* Email (for anonymous) */}
{!user && (
<div className="space-y-2">
<Label htmlFor="email">Your Email *</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={formData.reporter_email}
onChange={(e) => setFormData({ ...formData, reporter_email: e.target.value })}
required
/>
<p className="text-xs text-muted-foreground">
We'll use this to send you updates
</p>
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex gap-4 pt-4">
<Button type="button" variant="outline" onClick={() => setStep("type")}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !formData.title}>
{submitting ? "Submitting..." : "Submit Ticket"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
)
}

View file

@ -0,0 +1,241 @@
"use client"
/**
* Ticket Detail Page
*/
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
import Link from "next/link"
import { ArrowLeft, Send, Loader2, Clock, User } from "lucide-react"
interface Ticket {
id: number
ticket_number: string
type: string
status: string
priority: string
title: string
description: string
reporter_email: string
reporter_name: string
is_public: boolean
upvotes: number
created_at: string
updated_at: string
resolved_at: string | null
}
interface Comment {
id: number
author_name: string
content: string
is_internal: boolean
created_at: string
}
const STATUS_STYLES: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
open: { label: "Open", variant: "default" },
in_progress: { label: "In Progress", variant: "secondary" },
resolved: { label: "Resolved", variant: "outline" },
closed: { label: "Closed", variant: "outline" },
}
const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500",
medium: "bg-yellow-500",
high: "bg-orange-500",
critical: "bg-red-500",
}
export default function TicketDetailPage() {
const params = useParams()
const ticketNumber = params.id as string
const { user } = useAuth()
const [ticket, setTicket] = useState<Ticket | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [newComment, setNewComment] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
if (ticketNumber) {
fetchTicket()
fetchComments()
}
}, [ticketNumber])
const fetchTicket = async () => {
try {
const token = localStorage.getItem("token")
const headers: Record<string, string> = {}
if (token) headers["Authorization"] = `Bearer ${token}`
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}`, { headers })
if (res.ok) {
setTicket(await res.json())
} else if (res.status === 404) {
setError("Ticket not found")
} else if (res.status === 403) {
setError("You don't have access to this ticket")
}
} catch (e) {
setError("Failed to load ticket")
} finally {
setLoading(false)
}
}
const fetchComments = async () => {
try {
const token = localStorage.getItem("token")
const headers: Record<string, string> = {}
if (token) headers["Authorization"] = `Bearer ${token}`
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, { headers })
if (res.ok) {
setComments(await res.json())
}
} catch (e) {
console.error("Failed to load comments", e)
}
}
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim()) return
setSubmitting(true)
try {
const token = localStorage.getItem("token")
const res = await fetch(`${getApiUrl()}/tickets/${ticketNumber}/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content: newComment }),
})
if (res.ok) {
const comment = await res.json()
setComments([...comments, comment])
setNewComment("")
}
} catch (e) {
console.error("Failed to add comment", e)
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<div className="container max-w-3xl py-16 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (error || !ticket) {
return (
<div className="container max-w-3xl py-16 text-center">
<h1 className="text-2xl font-bold mb-4">{error || "Ticket not found"}</h1>
<Link href="/bugs">
<Button>Back to Bug Tracker</Button>
</Link>
</div>
)
}
return (
<div className="container max-w-3xl py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Link href="/bugs/my-tickets">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-muted-foreground">{ticket.ticket_number}</span>
<span className={`inline-block w-2 h-2 rounded-full ${PRIORITY_COLORS[ticket.priority]}`} />
<Badge variant={STATUS_STYLES[ticket.status]?.variant || "default"}>
{STATUS_STYLES[ticket.status]?.label || ticket.status}
</Badge>
</div>
</div>
</div>
{/* Ticket Content */}
<Card className="mb-6">
<CardHeader>
<CardTitle>{ticket.title}</CardTitle>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{ticket.reporter_name || "Anonymous"}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(ticket.created_at).toLocaleString()}
</span>
</div>
</CardHeader>
{ticket.description && (
<CardContent>
<p className="whitespace-pre-wrap">{ticket.description}</p>
</CardContent>
)}
</Card>
{/* Comments */}
<div className="space-y-4 mb-6">
<h3 className="font-semibold">Comments ({comments.length})</h3>
{comments.length === 0 ? (
<p className="text-sm text-muted-foreground">No comments yet</p>
) : (
comments.map((comment) => (
<Card key={comment.id}>
<CardContent className="py-4">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{comment.author_name}</span>
<span className="text-sm text-muted-foreground">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p className="whitespace-pre-wrap">{comment.content}</p>
</CardContent>
</Card>
))
)}
</div>
{/* Add Comment Form */}
{user && (
<form onSubmit={handleSubmitComment} className="space-y-4">
<Textarea
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={3}
/>
<Button type="submit" disabled={submitting || !newComment.trim()} className="gap-2">
<Send className="h-4 w-4" />
{submitting ? "Sending..." : "Send Comment"}
</Button>
</form>
)}
</div>
)
}

View file

@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, ChevronRight, ChevronLeft, Music, Clock, Hash, Play, ExternalLink, Sparkles } from "lucide-react"
import { ArrowLeft, Calendar, MapPin, ChevronRight, ChevronLeft, Music, Clock, Hash, Play, ExternalLink, Sparkles, Youtube } from "lucide-react"
import Link from "next/link"
import { notFound } from "next/navigation"
import { getApiUrl } from "@/lib/api-config"
@ -9,6 +9,7 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
import { SocialWrapper } from "@/components/social/social-wrapper"
import { EntityRating } from "@/components/social/entity-rating"
import { Badge } from "@/components/ui/badge"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getPerformance(id: string) {
try {
@ -143,6 +144,24 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
<div className="flex flex-col gap-6">
{/* 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 className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Youtube className="h-4 w-4 text-red-500" />
Video
</CardTitle>
</CardHeader>
<CardContent>
<YouTubeEmbed
url={performance.youtube_link}
title={`${performance.song.title} - ${formattedDate}`}
/>
</CardContent>
</Card>
)}
{/* Version Navigation - Prominent */}
<Card className="border-2">
<CardHeader className="pb-2">

View file

@ -14,7 +14,7 @@ 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 { UserAvatar } from "@/components/ui/user-avatar"
import { motion } from "framer-motion"
// Types
@ -23,6 +23,8 @@ interface UserProfile {
email: string
username: string
avatar: string | null
avatar_bg_color: string | null
avatar_text: string | null
bio: string | null
created_at: string
}
@ -125,10 +127,13 @@ export default function ProfilePage() {
</div>
<div className="flex flex-col md:flex-row gap-8 items-start pt-8">
<Avatar className="h-32 w-32 border-4 border-background shadow-lg">
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} />
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback>
</Avatar>
<UserAvatar
bgColor={user.avatar_bg_color || "#3B82F6"}
text={user.avatar_text || undefined}
username={displayName}
size="xl"
className="border-4 border-background"
/>
<div className="space-y-4 flex-1">
<div>
<div className="flex items-center gap-3">

View file

@ -7,27 +7,82 @@ import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator"
import { usePreferences } from "@/contexts/preferences-context"
import { useAuth } from "@/contexts/auth-context"
import { getApiUrl } from "@/lib/api-config"
import { UserAvatar } from "@/components/ui/user-avatar"
import {
User,
Palette,
Bell,
Eye,
Shield,
Sparkles,
Check,
ArrowLeft
} from "lucide-react"
import Link from "next/link"
// Avatar color palette - Jewel Tones (Primary Set)
const PRESET_COLORS = [
{ value: "#0F4C81", name: "Sapphire" },
{ value: "#9B111E", name: "Ruby" },
{ value: "#50C878", name: "Emerald" },
{ value: "#9966CC", name: "Amethyst" },
{ value: "#0D98BA", name: "Topaz" },
{ value: "#E0115F", name: "Rose Quartz" },
{ value: "#082567", name: "Lapis" },
{ value: "#FF7518", name: "Carnelian" },
{ value: "#006B3C", name: "Jade" },
{ value: "#1C1C1C", name: "Onyx" },
{ value: "#E6E200", name: "Citrine" },
{ value: "#702963", name: "Garnet" },
]
export default function SettingsPage() {
const { preferences, updatePreferences, loading } = usePreferences()
const { user } = useAuth()
const { user, refreshUser } = useAuth()
// Profile state
const [bio, setBio] = useState("")
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [username, setUsername] = useState("")
const [profileSaving, setProfileSaving] = useState(false)
const [profileSaved, setProfileSaved] = useState(false)
// Avatar state
const [avatarBgColor, setAvatarBgColor] = useState("#0F4C81")
const [avatarText, setAvatarText] = useState("")
const [avatarSaving, setAvatarSaving] = useState(false)
const [avatarSaved, setAvatarSaved] = useState(false)
const [avatarError, setAvatarError] = useState("")
// Privacy state
const [privacySettings, setPrivacySettings] = useState({
profile_public: true,
show_attendance_public: true,
appear_in_leaderboards: true
})
useEffect(() => {
// Bio might be in extended user response - check dynamically
if (user && 'bio' in user && typeof (user as Record<string, unknown>).bio === 'string') {
setBio((user as Record<string, unknown>).bio as string)
if (user) {
const extUser = user as any
setBio(extUser.bio || "")
setUsername(extUser.email?.split('@')[0] || "")
setAvatarBgColor(extUser.avatar_bg_color || "#0F4C81")
setAvatarText(extUser.avatar_text || "")
setPrivacySettings({
profile_public: extUser.profile_public ?? true,
show_attendance_public: extUser.show_attendance_public ?? true,
appear_in_leaderboards: extUser.appear_in_leaderboards ?? true
})
}
}, [user])
const handleSaveProfile = async () => {
setSaving(true)
setSaved(false)
setProfileSaving(true)
setProfileSaved(false)
const token = localStorage.getItem("token")
try {
await fetch(`${getApiUrl()}/users/me`, {
@ -36,104 +91,614 @@ export default function SettingsPage() {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({ bio })
body: JSON.stringify({ bio, username })
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
setProfileSaved(true)
setTimeout(() => setProfileSaved(false), 2000)
} catch (e) {
console.error(e)
} finally {
setSaving(false)
setProfileSaving(false)
}
}
const handleAvatarTextChange = (value: string) => {
const cleaned = value.replace(/[^A-Za-z0-9]/g, '').slice(0, 3).toUpperCase()
setAvatarText(cleaned)
setAvatarError("")
}
const handleSaveAvatar = async () => {
setAvatarSaving(true)
setAvatarSaved(false)
setAvatarError("")
try {
const token = localStorage.getItem("token")
const res = await fetch(`${getApiUrl()}/users/me/avatar`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
bg_color: avatarBgColor,
text: avatarText || null,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || "Failed to save")
}
setAvatarSaved(true)
refreshUser?.()
setTimeout(() => setAvatarSaved(false), 2000)
} catch (e: any) {
setAvatarError(e.message || "Failed to save avatar")
} finally {
setAvatarSaving(false)
}
}
const handlePrivacyChange = async (key: string, value: boolean) => {
// Optimistic update
setPrivacySettings(prev => ({ ...prev, [key]: value }))
try {
const token = localStorage.getItem("token")
await fetch(`${getApiUrl()}/users/me/privacy`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ [key]: value }),
})
} catch (e) {
// Revert on error
setPrivacySettings(prev => ({ ...prev, [key]: !value }))
console.error("Failed to update privacy setting:", e)
}
}
if (loading) {
return <div>Loading settings...</div>
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="animate-pulse text-muted-foreground">Loading settings...</div>
</div>
)
}
if (!user) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-20 min-h-[50vh]">
<h1 className="text-2xl font-bold">Please Log In</h1>
<p className="text-muted-foreground">You need to be logged in to access settings.</p>
<Link href="/login">
<Button>Log In</Button>
</Link>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="container max-w-6xl py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link href="/profile">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage your account and preferences</p>
</div>
</div>
{/* Profile Section */}
<Card>
{/* Desktop: Side-by-side layout, Mobile: Tabs */}
<div className="hidden lg:grid lg:grid-cols-[280px_1fr] lg:gap-8">
{/* Sidebar Navigation - Sticky */}
<nav className="space-y-1 sticky top-24 self-start">
<SidebarLink icon={User} label="Profile" href="#profile" active />
<SidebarLink icon={Palette} label="Appearance" href="#appearance" />
<SidebarLink icon={Eye} label="Display" href="#display" />
<SidebarLink icon={Bell} label="Notifications" href="#notifications" />
<SidebarLink icon={Shield} label="Privacy" href="#privacy" />
</nav>
{/* Settings Content */}
<div className="space-y-8">
<ProfileSection
bio={bio}
setBio={setBio}
username={username}
setUsername={setUsername}
saving={profileSaving}
saved={profileSaved}
onSave={handleSaveProfile}
/>
<Separator />
<AppearanceSection
avatarBgColor={avatarBgColor}
setAvatarBgColor={setAvatarBgColor}
avatarText={avatarText}
handleAvatarTextChange={handleAvatarTextChange}
username={username}
saving={avatarSaving}
saved={avatarSaved}
error={avatarError}
onSave={handleSaveAvatar}
/>
<Separator />
<DisplaySection
preferences={preferences}
updatePreferences={updatePreferences}
/>
<Separator />
<NotificationsSection />
<Separator />
<PrivacySection settings={privacySettings} onChange={handlePrivacyChange} />
</div>
</div>
{/* Mobile: Tabs Layout */}
<div className="lg:hidden">
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-full grid-cols-5 mb-6">
<TabsTrigger value="profile" className="text-xs">
<User className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="appearance" className="text-xs">
<Palette className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="display" className="text-xs">
<Eye className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="notifications" className="text-xs">
<Bell className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="privacy" className="text-xs">
<Shield className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<ProfileSection
bio={bio}
setBio={setBio}
username={username}
setUsername={setUsername}
saving={profileSaving}
saved={profileSaved}
onSave={handleSaveProfile}
/>
</TabsContent>
<TabsContent value="appearance">
<AppearanceSection
avatarBgColor={avatarBgColor}
setAvatarBgColor={setAvatarBgColor}
avatarText={avatarText}
handleAvatarTextChange={handleAvatarTextChange}
username={username}
saving={avatarSaving}
saved={avatarSaved}
error={avatarError}
onSave={handleSaveAvatar}
/>
</TabsContent>
<TabsContent value="display">
<DisplaySection
preferences={preferences}
updatePreferences={updatePreferences}
/>
</TabsContent>
<TabsContent value="notifications">
<NotificationsSection />
</TabsContent>
<TabsContent value="privacy">
<PrivacySection settings={privacySettings} onChange={handlePrivacyChange} />
</TabsContent>
</Tabs>
</div>
</div>
)
}
// Sidebar Link Component
function SidebarLink({ icon: Icon, label, href, active }: {
icon: any,
label: string,
href: string,
active?: boolean
}) {
return (
<a
href={href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${active
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
<Icon className="h-4 w-4" />
{label}
</a>
)
}
// Profile Section
function ProfileSection({ bio, setBio, username, setUsername, saving, saved, onSave }: any) {
return (
<Card id="profile">
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Profile
</CardTitle>
<CardDescription>
Tell other fans about yourself.
Your public profile information visible to other users
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
placeholder="Your display name"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
This will be shown on your profile and comments
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
disabled
value="(cannot be changed)"
className="bg-muted"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
placeholder="Been following the band since 2019..."
placeholder="Tell other fans about yourself... When did you start following the band?"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
rows={4}
/>
<p className="text-xs text-muted-foreground">
Markdown is supported. Max 500 characters.
</p>
</div>
<Button onClick={handleSaveProfile} disabled={saving}>
{saving ? "Saving..." : saved ? "Saved ✓" : "Save Profile"}
<div className="flex justify-end">
<Button onClick={onSave} disabled={saving}>
{saving ? "Saving..." : saved ? "Saved ✓" : "Save Changes"}
</Button>
</div>
</CardContent>
</Card>
)
}
{/* Preferences Section */}
<Card>
// Appearance Section
function AppearanceSection({
avatarBgColor,
setAvatarBgColor,
avatarText,
handleAvatarTextChange,
username,
saving,
saved,
error,
onSave
}: any) {
return (
<Card id="appearance">
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Appearance
</CardTitle>
<CardDescription>
Customize your browsing experience.
Customize how you appear across the site
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<Label htmlFor="wiki-mode">Wiki Mode</Label>
<p className="text-sm text-muted-foreground">
Hide all social features (comments, ratings, reviews) for a pure archive experience.
</p>
</div>
<Switch
id="wiki-mode"
checked={preferences.wiki_mode}
onChange={(e) => updatePreferences({ wiki_mode: e.target.checked })}
{/* Avatar Preview */}
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="flex flex-col items-center gap-2">
<UserAvatar
bgColor={avatarBgColor}
text={avatarText}
username={username}
size="xl"
/>
<span className="text-xs text-muted-foreground">Preview</span>
</div>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<Label htmlFor="show-ratings">Show Ratings</Label>
<p className="text-sm text-muted-foreground">
Display 1-10 ratings on shows and songs.
<div className="flex-1 space-y-4">
<div className="space-y-2">
<Label>Avatar Text (1-3 characters)</Label>
<Input
placeholder="e.g. ABC or 42"
value={avatarText}
onChange={(e) => handleAvatarTextChange(e.target.value)}
maxLength={3}
className="max-w-[200px] text-center text-lg font-bold uppercase"
/>
<p className="text-xs text-muted-foreground">
Leave empty to show first letter of username
</p>
</div>
<Switch
id="show-ratings"
checked={preferences.show_ratings}
disabled={preferences.wiki_mode}
onChange={(e) => updatePreferences({ show_ratings: e.target.checked })}
/>
</div>
</div>
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<Label htmlFor="show-comments">Show Comments</Label>
<p className="text-sm text-muted-foreground">
Display comment sections on pages.
</p>
{/* Color Grid */}
<div className="space-y-3">
<Label>Background Color</Label>
<div className="grid grid-cols-6 gap-3">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setAvatarBgColor(color.value)}
className="relative aspect-square rounded-xl transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring"
style={{ backgroundColor: color.value }}
title={color.name}
>
{avatarBgColor === color.value && (
<Check className="absolute inset-0 m-auto h-5 w-5 text-white drop-shadow-lg" />
)}
</button>
))}
</div>
<Switch
id="show-comments"
checked={preferences.show_comments}
disabled={preferences.wiki_mode}
onChange={(e) => updatePreferences({ show_comments: e.target.checked })}
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex justify-end">
<Button onClick={onSave} disabled={saving}>
{saving ? "Saving..." : saved ? "Saved ✓" : "Save Avatar"}
</Button>
</div>
</CardContent>
</Card>
)
}
// Display Section
function DisplaySection({ preferences, updatePreferences }: any) {
return (
<Card id="display">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Display Preferences
</CardTitle>
<CardDescription>
Control what you see while browsing
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SettingRow
label="Wiki Mode"
description="Hide all social features (comments, ratings, reviews) for a pure archive experience"
checked={preferences.wiki_mode}
onChange={(checked: boolean) => updatePreferences({ wiki_mode: checked })}
/>
<Separator />
<SettingRow
label="Show Ratings"
description="Display community ratings on shows, songs, and performances"
checked={preferences.show_ratings}
onChange={(checked: boolean) => updatePreferences({ show_ratings: checked })}
disabled={preferences.wiki_mode}
/>
<Separator />
<SettingRow
label="Show Comments"
description="Display comment sections on pages"
checked={preferences.show_comments}
onChange={(checked: boolean) => updatePreferences({ show_comments: checked })}
disabled={preferences.wiki_mode}
/>
<Separator />
<SettingRow
label="Show Heady Badges"
description="Display special badges for top-rated performances"
checked={true}
onChange={() => { }}
comingSoon
/>
</CardContent>
</Card>
)
}
// Notifications Section
function NotificationsSection() {
return (
<Card id="notifications">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>
Choose what updates you receive
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SettingRow
label="Comment Replies"
description="Get notified when someone replies to your comment"
checked={true}
onChange={() => { }}
comingSoon
/>
<Separator />
<SettingRow
label="New Show Added"
description="Get notified when a new show is added to the archive"
checked={true}
onChange={() => { }}
comingSoon
/>
<Separator />
<SettingRow
label="Chase Song Played"
description="Get notified when a song on your chase list gets played"
checked={true}
onChange={() => { }}
comingSoon
/>
<Separator />
<SettingRow
label="Weekly Digest"
description="Receive a weekly email with site highlights"
checked={false}
onChange={() => { }}
comingSoon
/>
</CardContent>
</Card>
)
}
// Privacy Section
function PrivacySection({ settings, onChange }: {
settings: { profile_public: boolean; show_attendance_public: boolean; appear_in_leaderboards: boolean };
onChange: (key: string, value: boolean) => void;
}) {
return (
<Card id="privacy">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Privacy
</CardTitle>
<CardDescription>
Control your privacy and data
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SettingRow
label="Public Profile"
description="Allow other users to view your profile, attendance history, and reviews"
checked={settings.profile_public}
onChange={(checked) => onChange("profile_public", checked)}
/>
<Separator />
<SettingRow
label="Show Attendance"
description="Display which shows you've attended on your public profile"
checked={settings.show_attendance_public}
onChange={(checked) => onChange("show_attendance_public", checked)}
/>
<Separator />
<SettingRow
label="Appear in Leaderboards"
description="Allow your name to appear on community leaderboards"
checked={settings.appear_in_leaderboards}
onChange={(checked) => onChange("appear_in_leaderboards", checked)}
/>
<Separator />
<div className="pt-4">
<h4 className="text-sm font-medium mb-2">Danger Zone</h4>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" className="w-fit" disabled>
Export My Data
</Button>
<Button variant="destructive" size="sm" className="w-fit" disabled>
Delete Account
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Account deletion is permanent and cannot be undone.
</p>
</div>
</CardContent>
</Card>
)
}
// Reusable Setting Row
function SettingRow({
label,
description,
checked,
onChange,
disabled,
comingSoon
}: {
label: string
description: string
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
comingSoon?: boolean
}) {
return (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5 flex-1">
<div className="flex items-center gap-2">
<Label className="text-base font-medium">{label}</Label>
{comingSoon && (
<span className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground">
Coming soon
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={disabled || comingSoon}
/>
</div>
)
}

View file

@ -138,12 +138,15 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
</div>
)}
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6">
{/* Full Show Video */}
{show.youtube_link && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5 text-red-500" />
Video
<Card className="border-2 border-red-500/20 bg-gradient-to-br from-red-500/5 to-transparent">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Youtube className="h-4 w-4 text-red-500" />
Full Show Video
</CardTitle>
</CardHeader>
<CardContent>
@ -152,8 +155,6 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Setlist</CardTitle>
@ -190,6 +191,36 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
<PlayCircle className="h-3.5 w-3.5" />
</a>
)}
{perf.youtube_link && (
<span
className="text-red-500"
title="Video available"
>
<Youtube className="h-3.5 w-3.5" />
</span>
)}
{perf.bandcamp_link && (
<a
href={perf.bandcamp_link}
target="_blank"
rel="noopener noreferrer"
className="text-[#629aa9] hover:text-[#4a7a89]"
title="Listen on Bandcamp"
>
<Disc className="h-3.5 w-3.5" />
</a>
)}
{perf.nugs_link && (
<a
href={perf.nugs_link}
target="_blank"
rel="noopener noreferrer"
className="text-[#ff6b00] hover:text-[#cc5500]"
title="Listen on Nugs.net"
>
<PlayCircle className="h-3.5 w-3.5" />
</a>
)}
{perf.segue && <span className="ml-1 text-muted-foreground">&gt;</span>}
</div>

View file

@ -4,7 +4,7 @@ import { useEffect, useState, Suspense } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import Link from "next/link"
import { Calendar, MapPin, Loader2 } from "lucide-react"
import { Calendar, MapPin, Loader2, Youtube } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { useSearchParams } from "next/navigation"
@ -12,6 +12,7 @@ interface Show {
id: number
slug?: string
date: string
youtube_link?: string
venue: {
id: number
name: string
@ -84,7 +85,12 @@ function ShowsContent() {
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{shows.map((show) => (
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group">
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50">
<Card className="h-full transition-all duration-300 hover:scale-[1.02] hover:shadow-lg group-hover:border-primary/50 relative">
{show.youtube_link && (
<div className="absolute top-2 right-2 bg-red-500/10 text-red-500 p-1.5 rounded-full" title="Full show video available">
<Youtube className="h-4 w-4" />
</div>
)}
<CardHeader>
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />

View file

@ -167,7 +167,7 @@ export default function VideosPage() {
</Link>
) : (
<Link
href={`/songs/${(video as PerformanceVideo).song_slug || (video as PerformanceVideo).song_id}`}
href={`/shows/${(video as PerformanceVideo).show_slug || (video as PerformanceVideo).show_id}`}
className="font-medium hover:underline"
>
{(video as PerformanceVideo).song_title}

View file

@ -2,17 +2,57 @@ import Link from "next/link"
export function Footer() {
return (
<footer className="border-t py-6 md:py-8 mt-12 bg-muted/30">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex flex-col gap-1 items-center md:items-start text-center md:text-left">
<span className="font-bold">Elmeg</span>
<span className="text-sm text-muted-foreground">The community archive.</span>
<p className="text-xs text-muted-foreground mt-2">© {new Date().getFullYear()} Elmeg. All rights reserved.</p>
<footer className="border-t py-8 md:py-10 mt-12 bg-muted/30">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
{/* Brand */}
<div className="col-span-2 md:col-span-1">
<span className="text-xl font-bold">Elmeg</span>
<p className="text-sm text-muted-foreground mt-2">
The community archive for fans, by fans.
</p>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<Link href="/about" className="hover:underline hover:text-foreground">About</Link>
<Link href="/terms" className="hover:underline hover:text-foreground">Terms</Link>
<Link href="/privacy" className="hover:underline hover:text-foreground">Privacy</Link>
{/* Explore */}
<div>
<h4 className="font-semibold text-sm mb-3">Explore</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/archive" className="hover:underline hover:text-foreground">Shows</Link></li>
<li><Link href="/songs" className="hover:underline hover:text-foreground">Songs</Link></li>
<li><Link href="/venues" className="hover:underline hover:text-foreground">Venues</Link></li>
<li><Link href="/videos" className="hover:underline hover:text-foreground">Videos</Link></li>
</ul>
</div>
{/* Community */}
<div>
<h4 className="font-semibold text-sm mb-3">Community</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/leaderboards" className="hover:underline hover:text-foreground">Leaderboards</Link></li>
<li><Link href="/groups" className="hover:underline hover:text-foreground">Groups</Link></li>
<li><Link href="/bugs" className="hover:underline hover:text-foreground">Report a Bug</Link></li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="font-semibold text-sm mb-3">Legal</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/about" className="hover:underline hover:text-foreground">About</Link></li>
<li><Link href="/terms" className="hover:underline hover:text-foreground">Terms of Service</Link></li>
<li><Link href="/privacy" className="hover:underline hover:text-foreground">Privacy Policy</Link></li>
</ul>
</div>
</div>
{/* Bottom bar */}
<div className="border-t pt-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">
© {new Date().getFullYear()} Elmeg. All rights reserved.
</p>
<p className="text-xs text-muted-foreground">
Made with for the community
</p>
</div>
</div>
</footer>

View file

@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { Music, User, ChevronDown } from "lucide-react"
import { Music, User, ChevronDown, Menu, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { SearchDialog } from "@/components/ui/search-dialog"
import { NotificationBell } from "@/components/notifications/notification-bell"
@ -14,19 +15,30 @@ import {
} from "@/components/ui/dropdown-menu"
import { useAuth } from "@/contexts/auth-context"
const browseLinks = [
{ href: "/shows", label: "Shows" },
{ href: "/venues", label: "Venues" },
{ href: "/songs", label: "Songs" },
{ href: "/performances", label: "Top Performances" },
{ href: "/tours", label: "Tours" },
{ href: "/videos", label: "Videos" },
]
export function Navbar() {
const { user, logout } = useAuth()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-14 items-center">
<div className="mr-4 hidden md:flex">
<Link href="/" className="mr-6 flex items-center space-x-2">
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-14 items-center justify-between">
{/* Logo - always visible */}
<Link href="/" className="flex items-center space-x-2">
<Music className="h-6 w-6" />
<span className="hidden font-bold sm:inline-block">
Elmeg
</span>
<span className="font-bold">Elmeg</span>
</Link>
<nav className="flex items-center space-x-6 text-sm font-medium">
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
<Link href="/archive" className="transition-colors hover:text-foreground/80 text-foreground/60">
Archive
</Link>
@ -37,22 +49,11 @@ export function Navbar() {
<ChevronDown className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<Link href="/shows">
<DropdownMenuItem>Shows</DropdownMenuItem>
{browseLinks.map((link) => (
<Link key={link.href} href={link.href}>
<DropdownMenuItem>{link.label}</DropdownMenuItem>
</Link>
<Link href="/venues">
<DropdownMenuItem>Venues</DropdownMenuItem>
</Link>
<Link href="/songs">
<DropdownMenuItem>Songs</DropdownMenuItem>
</Link>
<Link href="/performances">
<DropdownMenuItem>Top Performances</DropdownMenuItem>
</Link>
<Link href="/tours">
<DropdownMenuItem>Tours</DropdownMenuItem>
</Link>
{/* Leaderboards hidden until community activity grows */}
))}
</DropdownMenuContent>
</DropdownMenu>
@ -60,20 +61,21 @@ export function Navbar() {
About
</Link>
</nav>
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div className="w-full flex-1 md:w-auto md:flex-none">
{/* Right side - search, theme, auth */}
<div className="flex items-center gap-2">
<div className="hidden sm:block">
<SearchDialog />
</div>
<ThemeToggle />
<nav className="flex items-center gap-2">
{/* Desktop auth */}
<div className="hidden md:flex items-center gap-2">
{user ? (
<>
{(user.role === 'admin' || user.role === 'moderator') && (
<Link href="/mod">
<Button variant="ghost" size="sm">
Mod
</Button>
<Button variant="ghost" size="sm">Mod</Button>
</Link>
)}
<NotificationBell />
@ -103,22 +105,119 @@ export function Navbar() {
</DropdownMenu>
</>
) : (
<div className="flex items-center gap-2">
<>
<Link href="/login">
<Button variant="ghost" size="sm">
Sign In
</Button>
<Button variant="ghost" size="sm">Sign In</Button>
</Link>
<Link href="/register">
<Button size="sm">
Sign Up
<Button size="sm">Sign Up</Button>
</Link>
</>
)}
</div>
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
<span className="sr-only">Toggle menu</span>
</Button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-background">
<div className="container mx-auto max-w-7xl px-4 py-4 space-y-4">
{/* Mobile search */}
<div className="sm:hidden">
<SearchDialog />
</div>
{/* Mobile nav links */}
<nav className="flex flex-col space-y-2">
<Link
href="/archive"
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
Archive
</Link>
<div className="px-3 py-2 text-sm font-medium text-muted-foreground">Browse</div>
{browseLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="px-6 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
))}
<Link
href="/about"
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
About
</Link>
</nav>
{/* Mobile auth */}
<div className="border-t pt-4">
{user ? (
<div className="flex flex-col space-y-2">
<div className="px-3 py-2 text-sm text-muted-foreground">{user.email}</div>
{(user.role === 'admin' || user.role === 'moderator') && (
<Link
href="/mod"
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
Moderation
</Link>
)}
<Link
href="/profile"
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
Profile
</Link>
<Link
href="/settings"
className="px-3 py-2 rounded-md hover:bg-muted transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
Settings
</Link>
<button
onClick={() => { logout(); setMobileMenuOpen(false); }}
className="px-3 py-2 text-left rounded-md text-red-500 hover:bg-muted transition-colors"
>
Sign Out
</button>
</div>
) : (
<div className="flex gap-2">
<Link href="/login" className="flex-1" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">Sign In</Button>
</Link>
<Link href="/register" className="flex-1" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">Sign Up</Button>
</Link>
</div>
)}
</nav>
</div>
</div>
</div>
)}
</header>
)
}

View file

@ -0,0 +1,149 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { UserAvatar } from "@/components/ui/user-avatar"
import { getApiUrl } from "@/lib/api-config"
import { Check } from "lucide-react"
const PRESET_COLORS = [
{ value: "#0F4C81", name: "Sapphire" },
{ value: "#9B111E", name: "Ruby" },
{ value: "#50C878", name: "Emerald" },
{ value: "#9966CC", name: "Amethyst" },
{ value: "#0D98BA", name: "Topaz" },
{ value: "#E0115F", name: "Rose Quartz" },
{ value: "#082567", name: "Lapis" },
{ value: "#FF7518", name: "Carnelian" },
{ value: "#006B3C", name: "Jade" },
{ value: "#1C1C1C", name: "Onyx" },
{ value: "#E6E200", name: "Citrine" },
{ value: "#702963", name: "Garnet" },
]
interface AvatarSettingsProps {
currentBgColor?: string
currentText?: string
username?: string
onSave?: (bgColor: string, text: string) => void
}
export function AvatarSettings({
currentBgColor = "#3B82F6",
currentText = "",
username = "",
onSave
}: AvatarSettingsProps) {
const [bgColor, setBgColor] = useState(currentBgColor)
const [text, setText] = useState(currentText)
const [saving, setSaving] = useState(false)
const [error, setError] = useState("")
const handleTextChange = (value: string) => {
// Only allow alphanumeric, max 3 chars
const cleaned = value.replace(/[^A-Za-z0-9]/g, '').slice(0, 3).toUpperCase()
setText(cleaned)
setError("")
}
const handleSave = async () => {
setSaving(true)
setError("")
try {
const token = localStorage.getItem("token")
const res = await fetch(`${getApiUrl()}/users/me/avatar`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
bg_color: bgColor,
text: text || null,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || "Failed to save")
}
onSave?.(bgColor, text)
} catch (e: any) {
setError(e.message || "Failed to save avatar")
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Customize Avatar</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Preview */}
<div className="flex justify-center">
<UserAvatar
bgColor={bgColor}
text={text}
username={username}
size="xl"
/>
</div>
{/* Color Selection */}
<div className="space-y-2">
<Label>Background Color</Label>
<div className="grid grid-cols-4 gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setBgColor(color.value)}
className="relative h-10 rounded-lg transition-transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{ backgroundColor: color.value }}
title={color.name}
>
{bgColor === color.value && (
<Check className="absolute inset-0 m-auto h-5 w-5 text-white drop-shadow-md" />
)}
</button>
))}
</div>
</div>
{/* Text Input */}
<div className="space-y-2">
<Label htmlFor="avatar-text">Display Text (1-3 characters)</Label>
<Input
id="avatar-text"
placeholder="e.g. ABC or 123"
value={text}
onChange={(e) => handleTextChange(e.target.value)}
maxLength={3}
className="text-center text-lg font-bold uppercase"
/>
<p className="text-xs text-muted-foreground">
Leave empty to show first letter of username
</p>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
<Button
onClick={handleSave}
disabled={saving}
className="w-full"
>
{saving ? "Saving..." : "Save Avatar"}
</Button>
</CardContent>
</Card>
)
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<div
ref={ref}
role={decorative ? "none" : "separator"}
aria-orientation={decorative ? undefined : orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }

View file

@ -3,23 +3,38 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, ...props }, ref) => (
<label className="relative inline-flex items-center cursor-pointer">
interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
onCheckedChange?: (checked: boolean) => void
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => (
<label className={cn(
"relative inline-flex items-center cursor-pointer",
disabled && "cursor-not-allowed opacity-50"
)}>
<input
type="checkbox"
className="sr-only peer"
ref={ref}
checked={checked}
disabled={disabled}
onChange={(e) => onCheckedChange?.(e.target.checked)}
{...props}
/>
<div className={cn(
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600",
"w-11 h-6 rounded-full transition-colors",
"bg-muted peer-checked:bg-primary",
"peer-focus-visible:outline-none peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
"after:content-[''] after:absolute after:top-[2px] after:left-[2px]",
"after:bg-background after:border after:border-muted after:rounded-full",
"after:h-5 after:w-5 after:transition-transform",
"peer-checked:after:translate-x-5 peer-checked:after:border-primary",
className
)}></div>
</label>
))
)
)
Switch.displayName = "Switch"
export { Switch }

View file

@ -0,0 +1,42 @@
"use client"
import { cn } from "@/lib/utils"
interface UserAvatarProps {
bgColor?: string
text?: string
username?: string
size?: "sm" | "md" | "lg" | "xl"
className?: string
}
const sizeClasses = {
sm: "h-8 w-8 text-xs",
md: "h-10 w-10 text-sm",
lg: "h-16 w-16 text-xl",
xl: "h-32 w-32 text-4xl",
}
export function UserAvatar({
bgColor = "#3B82F6",
text,
username = "",
size = "md",
className
}: UserAvatarProps) {
// If no custom text, use first letter of username
const displayText = text || username.charAt(0).toUpperCase() || "?"
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-bold text-white shadow-md select-none",
sizeClasses[size],
className
)}
style={{ backgroundColor: bgColor }}
>
{displayText}
</div>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import React, { createContext, useContext, useState, useEffect } from "react"
import React, { createContext, useContext, useState, useEffect, useCallback } from "react"
import { getApiUrl } from "@/lib/api-config"
interface User {
@ -32,15 +32,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const fetchUser = useCallback(async (authToken: string): Promise<boolean> => {
const res = await fetch(`${getApiUrl()}/auth/users/me`, {
headers: {
Authorization: `Bearer ${authToken}`
}
})
if (res.ok) {
const userData = await res.json()
setUser(userData)
return true
} else if (res.status === 401 || res.status === 403) {
// Token expired or invalid - handle silently
return false
} else {
// Unexpected error
console.warn("Auth check failed with status:", res.status)
return false
}
}, [])
useEffect(() => {
const initAuth = async () => {
const storedToken = localStorage.getItem("token")
if (storedToken) {
setToken(storedToken)
try {
await fetchUser(storedToken)
} catch (err) {
console.error("Auth init failed", err)
const success = await fetchUser(storedToken)
if (!success) {
localStorage.removeItem("token")
setToken(null)
}
@ -48,21 +66,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setLoading(false)
}
initAuth()
}, [])
const fetchUser = async (token: string) => {
const res = await fetch(`${getApiUrl()}/auth/users/me`, {
headers: {
Authorization: `Bearer ${token}`
}
})
if (res.ok) {
const userData = await res.json()
setUser(userData)
} else {
throw new Error("Failed to fetch user")
}
}
}, [fetchUser])
const login = async (newToken: string) => {
localStorage.setItem("token", newToken)