Compare commits
26 commits
171b8a38ca
...
e94cb91010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94cb91010 | ||
|
|
9eeba8571c | ||
|
|
5a60f3303a | ||
|
|
9c92eb7953 | ||
|
|
0af64f5862 | ||
|
|
033c9f9bd0 | ||
|
|
08587f21f9 | ||
|
|
1f29cdf290 | ||
|
|
68453d6865 | ||
|
|
1f7f83a31a | ||
|
|
cddd3e2389 | ||
|
|
14a509ddb5 | ||
|
|
2da46eaa16 | ||
|
|
824a70d303 | ||
|
|
9e48dd78ff | ||
|
|
cc0d0255c0 | ||
|
|
f989414323 | ||
|
|
a4d63a9e2c | ||
|
|
c6ffc67fdd | ||
|
|
1b11ad8b52 | ||
|
|
735fd1a6ea | ||
|
|
d706a777a7 | ||
|
|
18cc7ea011 | ||
|
|
b0f919f9ff | ||
|
|
483d6dcb0d | ||
|
|
4a103511da |
32 changed files with 4088 additions and 350 deletions
|
|
@ -1,8 +1,12 @@
|
||||||
from fastapi import FastAPI
|
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 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
|
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 = FastAPI()
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
@ -39,6 +43,11 @@ app.include_router(chase.router)
|
||||||
app.include_router(gamification.router)
|
app.include_router(gamification.router)
|
||||||
app.include_router(videos.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("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"Hello": "World"}
|
return {"Hello": "World"}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class Performance(SQLModel, table=True):
|
||||||
notes: Optional[str] = Field(default=None)
|
notes: Optional[str] = Field(default=None)
|
||||||
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
|
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
|
||||||
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
|
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")
|
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
|
||||||
show: "Show" = Relationship(back_populates="performances")
|
show: "Show" = Relationship(back_populates="performances")
|
||||||
|
|
@ -173,6 +175,13 @@ class User(SQLModel, table=True):
|
||||||
role: str = Field(default="user") # user, moderator, admin
|
role: str = Field(default="user") # user, moderator, admin
|
||||||
bio: Optional[str] = Field(default=None)
|
bio: Optional[str] = Field(default=None)
|
||||||
avatar: 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
|
# Gamification
|
||||||
xp: int = Field(default=0, description="Experience points")
|
xp: int = Field(default=0, description="Experience points")
|
||||||
|
|
|
||||||
140
backend/models_tickets.py
Normal file
140
backend/models_tickets.py
Normal 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
|
||||||
|
|
@ -432,3 +432,114 @@ def delete_tour(
|
||||||
session.delete(tour)
|
session.delete(tour)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"message": "Tour deleted", "tour_id": tour_id}
|
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
371
backend/routers/tickets.py
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -14,14 +14,31 @@ class UserProfileUpdate(BaseModel):
|
||||||
avatar: Optional[str] = None
|
avatar: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
display_name: 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)
|
# Preset avatar colors - Jewel Tones (Primary Set)
|
||||||
def get_user_public(user_id: int, session: Session = Depends(get_session)):
|
AVATAR_COLORS = [
|
||||||
"""Get public user profile"""
|
"#0F4C81", # Sapphire
|
||||||
user = session.get(User, user_id)
|
"#9B111E", # Ruby
|
||||||
if not user:
|
"#50C878", # Emerald
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
"#9966CC", # Amethyst
|
||||||
return user
|
"#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)
|
@router.patch("/me", response_model=UserRead)
|
||||||
def update_my_profile(
|
def update_my_profile(
|
||||||
|
|
@ -34,6 +51,15 @@ def update_my_profile(
|
||||||
current_user.bio = update.bio
|
current_user.bio = update.bio
|
||||||
if update.avatar is not None:
|
if update.avatar is not None:
|
||||||
current_user.avatar = update.avatar
|
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:
|
if update.username or update.display_name:
|
||||||
# Find or create primary profile
|
# Find or create primary profile
|
||||||
|
|
@ -74,6 +100,71 @@ def update_my_profile(
|
||||||
session.refresh(current_user)
|
session.refresh(current_user)
|
||||||
return 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)
|
@router.patch("/me/preferences", response_model=UserPreferencesUpdate)
|
||||||
def update_preferences(
|
def update_preferences(
|
||||||
prefs: UserPreferencesUpdate,
|
prefs: UserPreferencesUpdate,
|
||||||
|
|
@ -178,3 +269,13 @@ def get_user_groups(
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
).all()
|
||||||
return groups
|
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
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ class UserRead(SQLModel):
|
||||||
email: str
|
email: str
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_superuser: 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):
|
class Token(SQLModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|
@ -91,6 +96,7 @@ class PerformanceRead(PerformanceBase):
|
||||||
slug: Optional[str] = None
|
slug: Optional[str] = None
|
||||||
song: Optional["SongRead"] = None
|
song: Optional["SongRead"] = None
|
||||||
nicknames: List["PerformanceNicknameRead"] = []
|
nicknames: List["PerformanceNicknameRead"] = []
|
||||||
|
youtube_link: Optional[str] = None
|
||||||
|
|
||||||
class PerformanceReadWithShow(PerformanceRead):
|
class PerformanceReadWithShow(PerformanceRead):
|
||||||
show_date: datetime
|
show_date: datetime
|
||||||
|
|
@ -151,6 +157,7 @@ class ShowRead(ShowBase):
|
||||||
tour: Optional["TourRead"] = None
|
tour: Optional["TourRead"] = None
|
||||||
tags: List["TagRead"] = []
|
tags: List["TagRead"] = []
|
||||||
performances: List["PerformanceRead"] = []
|
performances: List["PerformanceRead"] = []
|
||||||
|
youtube_link: Optional[str] = None
|
||||||
|
|
||||||
class ShowUpdate(SQLModel):
|
class ShowUpdate(SQLModel):
|
||||||
date: Optional[datetime] = None
|
date: Optional[datetime] = None
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,171 @@
|
||||||
|
"""
|
||||||
|
Email Service - Postal SMTP (primary), Mailgun, or AWS SES fallback
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import boto3
|
import httpx
|
||||||
import secrets
|
import secrets
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
from typing import Optional
|
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:
|
class EmailService:
|
||||||
def __init__(self):
|
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_access_key_id = os.getenv("AWS_ACCESS_KEY_ID")
|
||||||
self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY")
|
self.aws_secret_access_key = os.getenv("AWS_SECRET_ACCESS_KEY")
|
||||||
|
|
||||||
|
# Common settings
|
||||||
self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
self.email_from = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
||||||
self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://elmeg.xyz")
|
||||||
|
|
||||||
# Initialize SES client if credentials exist
|
# Determine which provider to use (priority: SMTP -> Mailgun -> SES -> Dummy)
|
||||||
if self.aws_access_key_id and self.aws_secret_access_key:
|
if self.smtp_host and self.smtp_username and self.smtp_password:
|
||||||
self.client = boto3.client(
|
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",
|
"ses",
|
||||||
region_name=self.region_name,
|
region_name=self.aws_region,
|
||||||
aws_access_key_id=self.aws_access_key_id,
|
aws_access_key_id=self.aws_access_key_id,
|
||||||
aws_secret_access_key=self.aws_secret_access_key,
|
aws_secret_access_key=self.aws_secret_access_key,
|
||||||
)
|
)
|
||||||
|
print("Email service: Using AWS SES")
|
||||||
else:
|
else:
|
||||||
self.client = None
|
self.provider = "dummy"
|
||||||
print("WARNING: AWS credentials not found. Email service running in dummy mode.")
|
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):
|
def send_email(self, to_email: str, subject: str, html_content: str, text_content: str):
|
||||||
"""Send an email using AWS SES"""
|
"""Send an email using configured provider"""
|
||||||
if not self.client:
|
if self.provider == "smtp":
|
||||||
print(f"DUMMY EMAIL to {to_email}: {subject}")
|
return self._send_smtp(to_email, subject, html_content, text_content)
|
||||||
print(text_content)
|
elif self.provider == "mailgun":
|
||||||
return True
|
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:
|
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,
|
Source=self.email_from,
|
||||||
Destination={
|
Destination={"ToAddresses": [to_email]},
|
||||||
"ToAddresses": [to_email],
|
|
||||||
},
|
|
||||||
Message={
|
Message={
|
||||||
"Subject": {
|
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
||||||
"Data": subject,
|
|
||||||
"Charset": "UTF-8",
|
|
||||||
},
|
|
||||||
"Body": {
|
"Body": {
|
||||||
"Html": {
|
"Html": {"Data": html_content, "Charset": "UTF-8"},
|
||||||
"Data": html_content,
|
"Text": {"Data": text_content, "Charset": "UTF-8"},
|
||||||
"Charset": "UTF-8",
|
|
||||||
},
|
|
||||||
"Text": {
|
|
||||||
"Data": text_content,
|
|
||||||
"Charset": "UTF-8",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
print(f"Email sent via SES to {to_email}")
|
||||||
return response
|
return response
|
||||||
except ClientError as e:
|
except ClientError as e:
|
||||||
print(f"Error sending email: {e.response['Error']['Message']}")
|
print(f"SES error: {e.response['Error']['Message']}")
|
||||||
return False
|
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
|
# Global instance
|
||||||
email_service = EmailService()
|
email_service = EmailService()
|
||||||
|
|
||||||
# --- Helper Functions (used by auth router) ---
|
|
||||||
|
# --- Helper Functions ---
|
||||||
|
|
||||||
def generate_token() -> str:
|
def generate_token() -> str:
|
||||||
"""Generate a secure random token"""
|
"""Generate a secure random token"""
|
||||||
|
|
@ -85,29 +187,29 @@ def send_verification_email(to_email: str, token: str):
|
||||||
|
|
||||||
html_content = f"""
|
html_content = f"""
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<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; 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;">Welcome to Elmeg!</h2>
|
<h2 style="color: #2563eb; margin-top: 0;">Welcome to Elmeg! 🎵</h2>
|
||||||
<p>Thanks for signing up. Please verify your email address to get started.</p>
|
<p>Thanks for joining the community. Please verify your email address to get started.</p>
|
||||||
<div style="margin: 30px 0;">
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
|
<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;">
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text_content = f"""
|
text_content = f"""
|
||||||
Welcome to Elmeg!
|
Welcome to Elmeg!
|
||||||
|
|
||||||
Please verify your email address by visiting this link:
|
Please verify your email address by visiting this link:
|
||||||
{verify_url}
|
{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)
|
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_content = f"""
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<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; 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;">Password Reset Request</h2>
|
<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>
|
<p>We received a request to reset your password. Click the button below to choose a new one.</p>
|
||||||
<div style="margin: 30px 0;">
|
<div style="margin: 30px 0; text-align: center;">
|
||||||
<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>
|
<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>
|
</div>
|
||||||
<p style="font-size: 14px; color: #666;">Or copy this link to your browser:</p>
|
<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: 12px; color: #888; word-break: break-all; background: #f5f5f5; padding: 10px; border-radius: 4px;">{reset_url}</p>
|
||||||
<p style="font-size: 14px; color: #666;">This link expires in 1 hour.</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;">
|
<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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
text_content = f"""
|
text_content = f"""
|
||||||
Reset your Elmeg password
|
Reset your Elmeg password
|
||||||
|
|
||||||
Click the link below to choose a new password:
|
Click the link below to choose a new password:
|
||||||
{reset_url}
|
{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)
|
return email_service.send_email(to_email, subject, html_content, text_content)
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
def get_leaderboard(session: Session, limit: int = 10) -> List[dict]:
|
||||||
"""Get top users by XP"""
|
"""Get top users by XP"""
|
||||||
users = session.exec(
|
# Test accounts to hide until we have real users
|
||||||
select(User)
|
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)
|
.where(User.is_active == True)
|
||||||
.order_by(User.xp.desc())
|
).one() or 0
|
||||||
.limit(limit)
|
|
||||||
|
# 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()
|
).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,20 @@ services:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
environment:
|
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}
|
- 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_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||||
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||||
- AWS_SES_REGION=${AWS_SES_REGION}
|
- 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}
|
- FRONTEND_URL=${FRONTEND_URL:-https://elmeg.xyz}
|
||||||
command: sh start.sh
|
command: sh start.sh
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -28,6 +36,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- elmeg
|
- elmeg
|
||||||
- traefik-public
|
- traefik-public
|
||||||
|
- postal-internal
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.elmeg-backend.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/api`)"
|
- "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.middlewares=elmeg-strip"
|
||||||
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
|
- "traefik.http.routers.elmeg-backend.service=elmeg-backend-svc"
|
||||||
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
|
- "traefik.http.services.elmeg-backend-svc.loadbalancer.server.port=8000"
|
||||||
- "traefik.docker.network=traefik-public"
|
- "traefik.docker.network=traefik"
|
||||||
# Direct routes for docs (no strip)
|
# Direct routes for docs (no strip)
|
||||||
- "traefik.http.routers.elmeg-backend-docs.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
|
- "traefik.http.routers.elmeg-backend-docs.rule=(Host(`elmeg.runfoo.run`) || Host(`elmeg.xyz`)) && PathPrefix(`/docs`, `/openapi.json`)"
|
||||||
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
|
- "traefik.http.routers.elmeg-backend-docs.entrypoints=websecure"
|
||||||
|
|
@ -71,7 +80,7 @@ services:
|
||||||
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.elmeg-frontend.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.elmeg-frontend.priority=50"
|
- "traefik.http.routers.elmeg-frontend.priority=50"
|
||||||
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
|
- "traefik.http.services.elmeg-frontend.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=traefik-public"
|
- "traefik.docker.network=traefik"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
|
|
@ -90,9 +99,63 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- elmeg
|
- 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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
umami_data:
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -100,3 +163,6 @@ networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
name: ${TRAEFIK_NETWORK:-traefik}
|
name: ${TRAEFIK_NETWORK:-traefik}
|
||||||
external: true
|
external: true
|
||||||
|
postal-internal:
|
||||||
|
name: postal_postal-internal
|
||||||
|
external: true
|
||||||
|
|
|
||||||
217
docs/BANDCAMP_NUGS_SPEC.md
Normal file
217
docs/BANDCAMP_NUGS_SPEC.md
Normal 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
248
docs/BUGS_TRACKER_SPEC.md
Normal 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
233
docs/MAILGUN_SETUP.md
Normal 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 |
|
||||||
199
docs/ROADMAP.md
199
docs/ROADMAP.md
|
|
@ -1,72 +1,171 @@
|
||||||
# Future Roadmap & Implementation Plan
|
# Elmeg Platform Roadmap
|
||||||
|
|
||||||
## 1. Cross-Vertical "Fandom Federation" (Future Feature)
|
**Last Updated:** 2023-12-23
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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.
|
**SMTP Credentials:**
|
||||||
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.
|
|
||||||
|
|
||||||
### 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`).
|
| Template | Status |
|
||||||
2. **Nickname Queue**: List `pending` nicknames with Approve/Reject actions.
|
|----------|--------|
|
||||||
3. **Report Queue**: List reported content with Dismiss/Delete actions.
|
| Email Verification | ✅ Ready |
|
||||||
4. **User Management**: Basic list of users with Ban/Promote options.
|
| 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:
|
| Feature | Status |
|
||||||
* New Reviews
|
|---------|--------|
|
||||||
1. **Global Feed**: Aggregated stream of:
|
| Privacy Settings (3 toggles) | ✅ Complete |
|
||||||
* New Reviews
|
| Sticky Settings Sidebar | ✅ Complete |
|
||||||
* New Show Attendance
|
| Bug Tracker MVP | ✅ Deployed |
|
||||||
* New Groups created
|
| Auth Console Error Fix | ✅ Fixed |
|
||||||
* Rare stats/milestones (e.g., "User X attended their 100th show")
|
| Videos Page Link Fix | ✅ Fixed |
|
||||||
2. **Home Page Widget**: Replace static content on Home with this dynamic feed.
|
| 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.
|
### Infrastructure
|
||||||
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.
|
|
||||||
|
|
||||||
### 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.
|
| Type | Name | Value |
|
||||||
2. **Edit History**: Track suggested edits with approval workflow.
|
|------|------|-------|
|
||||||
3. **Public Pages**: `/glossary` index and `/glossary/[term]` detail pages.
|
| A | postal | 159.69.219.254 (DNS only) |
|
||||||
4. **Moderation**: Admin queue for approving/rejecting entries and edits.
|
| A | smtp | 159.69.219.254 (DNS only) |
|
||||||
5. **Integration**: Include in global search, auto-link in comments.
|
| 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
|
||||||
|
|
|
||||||
177
docs/VIDEO_INTEGRATION_SPEC.md
Normal file
177
docs/VIDEO_INTEGRATION_SPEC.md
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
# Video Integration Specification
|
||||||
|
|
||||||
|
**Date:** 2025-12-22
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This spec outlines the complete video integration for elmeg.xyz, ensuring YouTube videos are properly displayed and discoverable across the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Database Schema ✅
|
||||||
|
|
||||||
|
- `Performance.youtube_link` - Individual performance video URL
|
||||||
|
- `Show.youtube_link` - Full show video URL
|
||||||
|
- `Song.youtube_link` - Studio/canonical video URL
|
||||||
|
|
||||||
|
### Import Pipeline ✅
|
||||||
|
|
||||||
|
- `import_youtube.py` processes `youtube_videos.json`
|
||||||
|
- Handles: single songs, sequences (→), and full shows
|
||||||
|
- Sequences link the SAME video to ALL performances in the sequence
|
||||||
|
|
||||||
|
### Frontend Display (Current)
|
||||||
|
|
||||||
|
| Page | Video Display | Status |
|
||||||
|
|------|--------------|--------|
|
||||||
|
| Show Page | Full show video (`show.youtube_link`) | ✅ Working |
|
||||||
|
| Song Page | Top performance video or song video | ✅ Working |
|
||||||
|
| Performance Page | Should show `performance.youtube_link` | ❌ MISSING |
|
||||||
|
| Videos Page | Lists all videos | ✅ Working |
|
||||||
|
|
||||||
|
### Visual Indicators (Current)
|
||||||
|
|
||||||
|
| Location | Indicator | Status |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Setlist items | Video icon for performances with video | ❌ MISSING |
|
||||||
|
| Archive/Show list | Video badge for shows with video | ❌ MISSING |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Performance Page Video Display ⚡ HIGH PRIORITY
|
||||||
|
|
||||||
|
**File:** `frontend/app/performances/[id]/page.tsx`
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
1. Import `YouTubeEmbed` component
|
||||||
|
2. Add video section ABOVE the "Version Timeline" card when `performance.youtube_link` exists
|
||||||
|
3. Style consistently with show page video section
|
||||||
|
|
||||||
|
**UI Placement:**
|
||||||
|
|
||||||
|
```
|
||||||
|
[Hero Banner]
|
||||||
|
[VIDEO EMBED] <-- NEW: Only when youtube_link exists
|
||||||
|
[Version Timeline]
|
||||||
|
[About This Performance]
|
||||||
|
[Comments]
|
||||||
|
[Reviews]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Setlist Video Indicators
|
||||||
|
|
||||||
|
**File:** `frontend/app/shows/[id]/page.tsx`
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
1. Add small YouTube icon (📹 or `<Youtube>`) next to song title when `perf.youtube_link` exists
|
||||||
|
2. Make icon clickable - links to performance page (where video is embedded)
|
||||||
|
3. Use red color for YouTube brand recognition
|
||||||
|
|
||||||
|
**Visual Design:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Dramophone 📹 >
|
||||||
|
2. The Empress of Organos 📹
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Archive Video Badge
|
||||||
|
|
||||||
|
**File:** `frontend/app/archive/page.tsx` (or show list component)
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
1. Add video badge to show cards that have:
|
||||||
|
- `show.youtube_link` (full show video), OR
|
||||||
|
- Any `performance.youtube_link` in their setlist
|
||||||
|
2. API enhancement: Add `has_videos` or `video_count` to show list endpoint
|
||||||
|
|
||||||
|
**Backend Enhancement:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In routers/shows.py - list_shows endpoint
|
||||||
|
# Add computed field: has_videos = show.youtube_link is not None or any performance has youtube_link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visual Design:**
|
||||||
|
|
||||||
|
- Small YouTube icon in corner of show card
|
||||||
|
- Tooltip: "Full show video available" or "X song videos available"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
YouTube Video → import_youtube.py → Database
|
||||||
|
↓
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
↓ ↓ ↓
|
||||||
|
Show.youtube_link Performance.youtube_link Song.youtube_link
|
||||||
|
↓ ↓ ↓
|
||||||
|
Show Page Performance Page Song Page
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Changes Required
|
||||||
|
|
||||||
|
### 1. Shows List Enhancement (Phase 3)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /shows/`
|
||||||
|
|
||||||
|
**New Response Fields:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"date": "2025-12-13T00:00:00",
|
||||||
|
"has_video": true, // NEW: true if show.youtube_link OR any perf.youtube_link
|
||||||
|
"video_count": 3 // NEW: count of performances with videos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Performance Detail (Already Exists)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /performances/{id}`
|
||||||
|
|
||||||
|
**Verify Field Included:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"youtube_link": "https://www.youtube.com/watch?v=zQI6-LloYwI"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Dramophone 2025-12-13 shows video on performance page
|
||||||
|
- [ ] Empress of Organos 2025-12-13 shows SAME video on performance page
|
||||||
|
- [ ] Setlist on 2025-12-13 show shows video icons for both songs
|
||||||
|
- [ ] Archive view shows video indicator for 2025-12-13 show
|
||||||
|
- [ ] Video page accurately reflects all linked videos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- `frontend/app/performances/[id]/page.tsx` - Add video embed
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- `frontend/app/shows/[id]/page.tsx` - Add video icons to setlist
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- `backend/routers/shows.py` - Add has_videos to list response
|
||||||
|
- `frontend/app/archive/page.tsx` - Add video badge to cards
|
||||||
143
frontend/app/bugs/known-issues/page.tsx
Normal file
143
frontend/app/bugs/known-issues/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
frontend/app/bugs/my-tickets/page.tsx
Normal file
146
frontend/app/bugs/my-tickets/page.tsx
Normal 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
299
frontend/app/bugs/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
frontend/app/bugs/ticket/[id]/page.tsx
Normal file
241
frontend/app/bugs/ticket/[id]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { ArrowLeft, Calendar, MapPin, ChevronRight, ChevronLeft, Music, Clock, Hash, Play, ExternalLink, Sparkles } 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 Link from "next/link"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
@ -9,6 +9,7 @@ import { EntityReviews } from "@/components/reviews/entity-reviews"
|
||||||
import { SocialWrapper } from "@/components/social/social-wrapper"
|
import { SocialWrapper } from "@/components/social/social-wrapper"
|
||||||
import { EntityRating } from "@/components/social/entity-rating"
|
import { EntityRating } from "@/components/social/entity-rating"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
|
||||||
|
|
||||||
async function getPerformance(id: string) {
|
async function getPerformance(id: string) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -143,6 +144,24 @@ export default async function PerformanceDetailPage({ params }: { params: Promis
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
|
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
|
||||||
<div className="flex flex-col gap-6">
|
<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 */}
|
{/* Version Navigation - Prominent */}
|
||||||
<Card className="border-2">
|
<Card className="border-2">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { UserGroupsList } from "@/components/profile/user-groups-list"
|
||||||
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
|
import { ChaseSongsList } from "@/components/profile/chase-songs-list"
|
||||||
import { AttendanceSummary } from "@/components/profile/attendance-summary"
|
import { AttendanceSummary } from "@/components/profile/attendance-summary"
|
||||||
import { LevelProgressCard } from "@/components/gamification/level-progress"
|
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"
|
import { motion } from "framer-motion"
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -23,6 +23,8 @@ interface UserProfile {
|
||||||
email: string
|
email: string
|
||||||
username: string
|
username: string
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
|
avatar_bg_color: string | null
|
||||||
|
avatar_text: string | null
|
||||||
bio: string | null
|
bio: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -125,10 +127,13 @@ export default function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 items-start pt-8">
|
<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">
|
<UserAvatar
|
||||||
<AvatarImage src={`https://api.dicebear.com/7.x/notionists/svg?seed=${user.id}`} />
|
bgColor={user.avatar_bg_color || "#3B82F6"}
|
||||||
<AvatarFallback><User className="h-12 w-12" /></AvatarFallback>
|
text={user.avatar_text || undefined}
|
||||||
</Avatar>
|
username={displayName}
|
||||||
|
size="xl"
|
||||||
|
className="border-4 border-background"
|
||||||
|
/>
|
||||||
<div className="space-y-4 flex-1">
|
<div className="space-y-4 flex-1">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -7,27 +7,82 @@ import { Switch } from "@/components/ui/switch"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { usePreferences } from "@/contexts/preferences-context"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
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() {
|
export default function SettingsPage() {
|
||||||
const { preferences, updatePreferences, loading } = usePreferences()
|
const { preferences, updatePreferences, loading } = usePreferences()
|
||||||
const { user } = useAuth()
|
const { user, refreshUser } = useAuth()
|
||||||
|
|
||||||
|
// Profile state
|
||||||
const [bio, setBio] = useState("")
|
const [bio, setBio] = useState("")
|
||||||
const [saving, setSaving] = useState(false)
|
const [username, setUsername] = useState("")
|
||||||
const [saved, setSaved] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
// Bio might be in extended user response - check dynamically
|
if (user) {
|
||||||
if (user && 'bio' in user && typeof (user as Record<string, unknown>).bio === 'string') {
|
const extUser = user as any
|
||||||
setBio((user as Record<string, unknown>).bio as string)
|
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])
|
}, [user])
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
setSaving(true)
|
setProfileSaving(true)
|
||||||
setSaved(false)
|
setProfileSaved(false)
|
||||||
const token = localStorage.getItem("token")
|
const token = localStorage.getItem("token")
|
||||||
try {
|
try {
|
||||||
await fetch(`${getApiUrl()}/users/me`, {
|
await fetch(`${getApiUrl()}/users/me`, {
|
||||||
|
|
@ -36,104 +91,614 @@ export default function SettingsPage() {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${token}`
|
"Authorization": `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ bio })
|
body: JSON.stringify({ bio, username })
|
||||||
})
|
})
|
||||||
setSaved(true)
|
setProfileSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setProfileSaved(false), 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} 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) {
|
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 (
|
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>
|
<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 */}
|
{/* Desktop: Side-by-side layout, Mobile: Tabs */}
|
||||||
<Card>
|
<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>
|
<CardHeader>
|
||||||
<CardTitle>Profile</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Profile
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Tell other fans about yourself.
|
Your public profile information visible to other users
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bio">Bio</Label>
|
<Label htmlFor="bio">Bio</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="bio"
|
id="bio"
|
||||||
placeholder="Been following the band since 2019..."
|
placeholder="Tell other fans about yourself... When did you start following the band?"
|
||||||
value={bio}
|
value={bio}
|
||||||
onChange={(e) => setBio(e.target.value)}
|
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>
|
</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>
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* Preferences Section */}
|
// Appearance Section
|
||||||
<Card>
|
function AppearanceSection({
|
||||||
|
avatarBgColor,
|
||||||
|
setAvatarBgColor,
|
||||||
|
avatarText,
|
||||||
|
handleAvatarTextChange,
|
||||||
|
username,
|
||||||
|
saving,
|
||||||
|
saved,
|
||||||
|
error,
|
||||||
|
onSave
|
||||||
|
}: any) {
|
||||||
|
return (
|
||||||
|
<Card id="appearance">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Preferences</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
Appearance
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Customize your browsing experience.
|
Customize how you appear across the site
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center justify-between space-x-2">
|
{/* Avatar Preview */}
|
||||||
<div className="space-y-0.5">
|
<div className="flex flex-col md:flex-row gap-6 items-start md:items-center">
|
||||||
<Label htmlFor="wiki-mode">Wiki Mode</Label>
|
<div className="flex flex-col items-center gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<UserAvatar
|
||||||
Hide all social features (comments, ratings, reviews) for a pure archive experience.
|
bgColor={avatarBgColor}
|
||||||
</p>
|
text={avatarText}
|
||||||
</div>
|
username={username}
|
||||||
<Switch
|
size="xl"
|
||||||
id="wiki-mode"
|
|
||||||
checked={preferences.wiki_mode}
|
|
||||||
onChange={(e) => updatePreferences({ wiki_mode: e.target.checked })}
|
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">Preview</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between space-x-2">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="show-ratings">Show Ratings</Label>
|
<Label>Avatar Text (1-3 characters)</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<Input
|
||||||
Display 1-10 ratings on shows and songs.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
</div>
|
||||||
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">
|
{/* Color Grid */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="show-comments">Show Comments</Label>
|
<Label>Background Color</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="grid grid-cols-6 gap-3">
|
||||||
Display comment sections on pages.
|
{PRESET_COLORS.map((color) => (
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<Switch
|
</div>
|
||||||
id="show-comments"
|
|
||||||
checked={preferences.show_comments}
|
{error && (
|
||||||
disabled={preferences.wiki_mode}
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
onChange={(e) => updatePreferences({ show_comments: e.target.checked })}
|
)}
|
||||||
/>
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={onSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : saved ? "Saved ✓" : "Save Avatar"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,15 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Full Show Video */}
|
||||||
{show.youtube_link && (
|
{show.youtube_link && (
|
||||||
<Card>
|
<Card className="border-2 border-red-500/20 bg-gradient-to-br from-red-500/5 to-transparent">
|
||||||
<CardHeader>
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
<Youtube className="h-5 w-5 text-red-500" />
|
<Youtube className="h-4 w-4 text-red-500" />
|
||||||
Video
|
Full Show Video
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -152,8 +155,6 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[2fr_1fr]">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Setlist</CardTitle>
|
<CardTitle>Setlist</CardTitle>
|
||||||
|
|
@ -190,6 +191,36 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
<PlayCircle className="h-3.5 w-3.5" />
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
</a>
|
</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">></span>}
|
{perf.segue && <span className="ml-1 text-muted-foreground">></span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useEffect, useState, Suspense } from "react"
|
||||||
import { getApiUrl } from "@/lib/api-config"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Calendar, MapPin, Loader2 } from "lucide-react"
|
import { Calendar, MapPin, Loader2, Youtube } from "lucide-react"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface Show {
|
||||||
id: number
|
id: number
|
||||||
slug?: string
|
slug?: string
|
||||||
date: string
|
date: string
|
||||||
|
youtube_link?: string
|
||||||
venue: {
|
venue: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -84,7 +85,12 @@ function ShowsContent() {
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{shows.map((show) => (
|
{shows.map((show) => (
|
||||||
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group">
|
<Link key={show.id} href={`/shows/${show.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>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
|
<CardTitle className="flex items-center gap-2 group-hover:text-primary transition-colors">
|
||||||
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />
|
<Calendar className="h-5 w-5 text-muted-foreground group-hover:text-primary/70 transition-colors" />
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ export default function VideosPage() {
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<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"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{(video as PerformanceVideo).song_title}
|
{(video as PerformanceVideo).song_title}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,57 @@ import Link from "next/link"
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t py-6 md:py-8 mt-12 bg-muted/30">
|
<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 flex flex-col md:flex-row items-center justify-between gap-4">
|
<div className="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col gap-1 items-center md:items-start text-center md:text-left">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-8">
|
||||||
<span className="font-bold">Elmeg</span>
|
{/* Brand */}
|
||||||
<span className="text-sm text-muted-foreground">The community archive.</span>
|
<div className="col-span-2 md:col-span-1">
|
||||||
<p className="text-xs text-muted-foreground mt-2">© {new Date().getFullYear()} Elmeg. All rights reserved.</p>
|
<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>
|
||||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
|
||||||
<Link href="/about" className="hover:underline hover:text-foreground">About</Link>
|
{/* Explore */}
|
||||||
<Link href="/terms" className="hover:underline hover:text-foreground">Terms</Link>
|
<div>
|
||||||
<Link href="/privacy" className="hover:underline hover:text-foreground">Privacy</Link>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
import { useState } from "react"
|
||||||
import Link from "next/link"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { SearchDialog } from "@/components/ui/search-dialog"
|
import { SearchDialog } from "@/components/ui/search-dialog"
|
||||||
import { NotificationBell } from "@/components/notifications/notification-bell"
|
import { NotificationBell } from "@/components/notifications/notification-bell"
|
||||||
|
|
@ -14,19 +15,30 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
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() {
|
export function Navbar() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-14 items-center justify-between">
|
||||||
<div className="mr-4 hidden md:flex">
|
{/* Logo - always visible */}
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
<Music className="h-6 w-6" />
|
<Music className="h-6 w-6" />
|
||||||
<span className="hidden font-bold sm:inline-block">
|
<span className="font-bold">Elmeg</span>
|
||||||
Elmeg
|
|
||||||
</span>
|
|
||||||
</Link>
|
</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">
|
<Link href="/archive" className="transition-colors hover:text-foreground/80 text-foreground/60">
|
||||||
Archive
|
Archive
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -37,22 +49,11 @@ export function Navbar() {
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-4 w-4" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<Link href="/shows">
|
{browseLinks.map((link) => (
|
||||||
<DropdownMenuItem>Shows</DropdownMenuItem>
|
<Link key={link.href} href={link.href}>
|
||||||
|
<DropdownMenuItem>{link.label}</DropdownMenuItem>
|
||||||
</Link>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
|
@ -60,20 +61,21 @@ export function Navbar() {
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
{/* Right side - search, theme, auth */}
|
||||||
<div className="w-full flex-1 md:w-auto md:flex-none">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="hidden sm:block">
|
||||||
<SearchDialog />
|
<SearchDialog />
|
||||||
</div>
|
</div>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<nav className="flex items-center gap-2">
|
|
||||||
|
{/* Desktop auth */}
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
{(user.role === 'admin' || user.role === 'moderator') && (
|
{(user.role === 'admin' || user.role === 'moderator') && (
|
||||||
<Link href="/mod">
|
<Link href="/mod">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">Mod</Button>
|
||||||
Mod
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
|
@ -103,22 +105,119 @@ export function Navbar() {
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Link href="/login">
|
<Link href="/login">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">Sign In</Button>
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/register">
|
<Link href="/register">
|
||||||
<Button size="sm">
|
<Button size="sm">Sign Up</Button>
|
||||||
Sign Up
|
</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>
|
</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
frontend/components/profile/avatar-settings.tsx
Normal file
149
frontend/components/profile/avatar-settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/components/ui/separator.tsx
Normal file
28
frontend/components/ui/separator.tsx
Normal 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 }
|
||||||
|
|
@ -3,23 +3,38 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
interface SwitchProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||||
HTMLInputElement,
|
onCheckedChange?: (checked: boolean) => void
|
||||||
React.InputHTMLAttributes<HTMLInputElement>
|
}
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
<div className={cn(
|
<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
|
className
|
||||||
)}></div>
|
)}></div>
|
||||||
</label>
|
</label>
|
||||||
))
|
)
|
||||||
|
)
|
||||||
Switch.displayName = "Switch"
|
Switch.displayName = "Switch"
|
||||||
|
|
||||||
export { Switch }
|
export { Switch }
|
||||||
|
|
|
||||||
42
frontend/components/ui/user-avatar.tsx
Normal file
42
frontend/components/ui/user-avatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"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"
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
|
@ -32,15 +32,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [token, setToken] = useState<string | null>(null)
|
const [token, setToken] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
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(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
const storedToken = localStorage.getItem("token")
|
const storedToken = localStorage.getItem("token")
|
||||||
if (storedToken) {
|
if (storedToken) {
|
||||||
setToken(storedToken)
|
setToken(storedToken)
|
||||||
try {
|
const success = await fetchUser(storedToken)
|
||||||
await fetchUser(storedToken)
|
if (!success) {
|
||||||
} catch (err) {
|
|
||||||
console.error("Auth init failed", err)
|
|
||||||
localStorage.removeItem("token")
|
localStorage.removeItem("token")
|
||||||
setToken(null)
|
setToken(null)
|
||||||
}
|
}
|
||||||
|
|
@ -48,21 +66,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
initAuth()
|
initAuth()
|
||||||
}, [])
|
}, [fetchUser])
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (newToken: string) => {
|
const login = async (newToken: string) => {
|
||||||
localStorage.setItem("token", newToken)
|
localStorage.setItem("token", newToken)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue