feat: complete slug integration, fix set names logic, add missing ui components
This commit is contained in:
parent
b73f993475
commit
e3e074248e
19 changed files with 864 additions and 27 deletions
213
backend/alembic/versions/65c515b4722a_add_slugs.py
Normal file
213
backend/alembic/versions/65c515b4722a_add_slugs.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
"""Add slugs
|
||||||
|
|
||||||
|
Revision ID: 65c515b4722a
|
||||||
|
Revises: e50a60c5d343
|
||||||
|
Create Date: 2025-12-21 20:24:07.968495
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '65c515b4722a'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'e50a60c5d343'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# op.create_table('reaction',
|
||||||
|
# sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
# sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('emoji', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||||
|
# sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
# sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
# sa.PrimaryKeyConstraint('id')
|
||||||
|
# )
|
||||||
|
# with op.batch_alter_table('reaction', schema=None) as batch_op:
|
||||||
|
# batch_op.create_index(batch_op.f('ix_reaction_entity_id'), ['entity_id'], unique=False)
|
||||||
|
# batch_op.create_index(batch_op.f('ix_reaction_entity_type'), ['entity_type'], unique=False)
|
||||||
|
|
||||||
|
# op.create_table('chasesong',
|
||||||
|
# sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('song_id', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('priority', sa.Integer(), nullable=False),
|
||||||
|
# sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||||
|
# sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
# sa.Column('caught_at', sa.DateTime(), nullable=True),
|
||||||
|
# sa.Column('caught_show_id', sa.Integer(), nullable=True),
|
||||||
|
# sa.ForeignKeyConstraint(['caught_show_id'], ['show.id'], ),
|
||||||
|
# sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
|
||||||
|
# sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
# sa.PrimaryKeyConstraint('id')
|
||||||
|
# )
|
||||||
|
# with op.batch_alter_table('chasesong', schema=None) as batch_op:
|
||||||
|
# batch_op.create_index(batch_op.f('ix_chasesong_song_id'), ['song_id'], unique=False)
|
||||||
|
# batch_op.create_index(batch_op.f('ix_chasesong_user_id'), ['user_id'], unique=False)
|
||||||
|
|
||||||
|
# with op.batch_alter_table('badge', schema=None) as batch_op:
|
||||||
|
# batch_op.add_column(sa.Column('tier', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||||
|
# batch_op.add_column(sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
|
||||||
|
# batch_op.add_column(sa.Column('xp_reward', sa.Integer(), nullable=False))
|
||||||
|
|
||||||
|
with op.batch_alter_table('comment', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('parent_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_comment_parent_id', 'comment', ['parent_id'], ['id'])
|
||||||
|
|
||||||
|
with op.batch_alter_table('performance', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('track_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_performance_slug'), ['slug'], unique=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table('rating', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('performance_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('venue_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('tour_id', sa.Integer(), nullable=True))
|
||||||
|
batch_op.alter_column('score',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
type_=sa.Float(),
|
||||||
|
existing_nullable=False)
|
||||||
|
batch_op.create_foreign_key('fk_rating_tour_id', 'tour', ['tour_id'], ['id'])
|
||||||
|
batch_op.create_foreign_key('fk_rating_performance_id', 'performance', ['performance_id'], ['id'])
|
||||||
|
batch_op.create_foreign_key('fk_rating_venue_id', 'venue', ['venue_id'], ['id'])
|
||||||
|
|
||||||
|
with op.batch_alter_table('review', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('score',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
type_=sa.Float(),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('show', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('bandcamp_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('nugs_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_show_slug'), ['slug'], unique=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table('song', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('youtube_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_song_slug'), ['slug'], unique=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table('tour', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_tour_slug'), ['slug'], unique=True)
|
||||||
|
|
||||||
|
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('xp', sa.Integer(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('level', sa.Integer(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('streak_days', sa.Integer(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('last_activity', sa.DateTime(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('custom_title', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('title_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('flair', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('is_early_adopter', sa.Boolean(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('is_supporter', sa.Boolean(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('joined_at', sa.DateTime(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('email_verified', sa.Boolean(), nullable=False))
|
||||||
|
batch_op.add_column(sa.Column('verification_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('verification_token_expires', sa.DateTime(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('reset_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('reset_token_expires', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('venue', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_venue_slug'), ['slug'], unique=True)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('venue', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_venue_slug'))
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('reset_token_expires')
|
||||||
|
batch_op.drop_column('reset_token')
|
||||||
|
batch_op.drop_column('verification_token_expires')
|
||||||
|
batch_op.drop_column('verification_token')
|
||||||
|
batch_op.drop_column('email_verified')
|
||||||
|
batch_op.drop_column('joined_at')
|
||||||
|
batch_op.drop_column('is_supporter')
|
||||||
|
batch_op.drop_column('is_early_adopter')
|
||||||
|
batch_op.drop_column('flair')
|
||||||
|
batch_op.drop_column('title_color')
|
||||||
|
batch_op.drop_column('custom_title')
|
||||||
|
batch_op.drop_column('last_activity')
|
||||||
|
batch_op.drop_column('streak_days')
|
||||||
|
batch_op.drop_column('level')
|
||||||
|
batch_op.drop_column('xp')
|
||||||
|
|
||||||
|
with op.batch_alter_table('tour', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_tour_slug'))
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
with op.batch_alter_table('song', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_song_slug'))
|
||||||
|
batch_op.drop_column('youtube_link')
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
with op.batch_alter_table('show', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_show_slug'))
|
||||||
|
batch_op.drop_column('youtube_link')
|
||||||
|
batch_op.drop_column('nugs_link')
|
||||||
|
batch_op.drop_column('bandcamp_link')
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
with op.batch_alter_table('review', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('score',
|
||||||
|
existing_type=sa.Float(),
|
||||||
|
type_=sa.INTEGER(),
|
||||||
|
existing_nullable=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('rating', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.alter_column('score',
|
||||||
|
existing_type=sa.Float(),
|
||||||
|
type_=sa.INTEGER(),
|
||||||
|
existing_nullable=False)
|
||||||
|
batch_op.drop_column('tour_id')
|
||||||
|
batch_op.drop_column('venue_id')
|
||||||
|
batch_op.drop_column('performance_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('performance', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_performance_slug'))
|
||||||
|
batch_op.drop_column('youtube_link')
|
||||||
|
batch_op.drop_column('track_url')
|
||||||
|
batch_op.drop_column('slug')
|
||||||
|
|
||||||
|
with op.batch_alter_table('comment', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint(None, type_='foreignkey')
|
||||||
|
batch_op.drop_column('parent_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('badge', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('xp_reward')
|
||||||
|
batch_op.drop_column('category')
|
||||||
|
batch_op.drop_column('tier')
|
||||||
|
|
||||||
|
with op.batch_alter_table('chasesong', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_chasesong_user_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_chasesong_song_id'))
|
||||||
|
|
||||||
|
op.drop_table('chasesong')
|
||||||
|
with op.batch_alter_table('reaction', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_reaction_entity_type'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_reaction_entity_id'))
|
||||||
|
|
||||||
|
op.drop_table('reaction')
|
||||||
|
# ### end Alembic commands ###
|
||||||
170
backend/fix_db_data.py
Normal file
170
backend/fix_db_data.py
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from database import engine
|
||||||
|
from models import Venue, Song, Show, Tour, Performance
|
||||||
|
from slugify import generate_slug, generate_show_slug
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
BASE_URL = "https://elgoose.net/api/v2"
|
||||||
|
|
||||||
|
def fetch_all_json(endpoint, params=None):
|
||||||
|
all_data = []
|
||||||
|
page = 1
|
||||||
|
params = params.copy() if params else {}
|
||||||
|
print(f"Fetching {endpoint}...")
|
||||||
|
while True:
|
||||||
|
params['page'] = page
|
||||||
|
url = f"{BASE_URL}/{endpoint}.json"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
break
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get('data', [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
all_data.extend(items)
|
||||||
|
print(f" Page {page} done ({len(items)} items)")
|
||||||
|
page += 1
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching {endpoint}: {e}")
|
||||||
|
break
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
def fix_data():
|
||||||
|
with Session(engine) as session:
|
||||||
|
# 1. Fix Venues Slugs
|
||||||
|
print("Fixing Venue Slugs...")
|
||||||
|
venues = session.exec(select(Venue)).all()
|
||||||
|
for v in venues:
|
||||||
|
if not v.slug:
|
||||||
|
v.slug = generate_slug(v.name)
|
||||||
|
session.add(v)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 2. Fix Songs Slugs
|
||||||
|
print("Fixing Song Slugs...")
|
||||||
|
songs = session.exec(select(Song)).all()
|
||||||
|
for s in songs:
|
||||||
|
if not s.slug:
|
||||||
|
s.slug = generate_slug(s.title)
|
||||||
|
session.add(s)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 3. Fix Tours Slugs
|
||||||
|
print("Fixing Tour Slugs...")
|
||||||
|
tours = session.exec(select(Tour)).all()
|
||||||
|
for t in tours:
|
||||||
|
if not t.slug:
|
||||||
|
t.slug = generate_slug(t.name)
|
||||||
|
session.add(t)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 4. Fix Shows Slugs
|
||||||
|
print("Fixing Show Slugs...")
|
||||||
|
shows = session.exec(select(Show)).all()
|
||||||
|
venue_map = {v.id: v for v in venues} # Cache venues for naming
|
||||||
|
for show in shows:
|
||||||
|
if not show.slug:
|
||||||
|
date_str = show.date.strftime("%Y-%m-%d") if show.date else "unknown"
|
||||||
|
venue_name = "unknown"
|
||||||
|
if show.venue_id and show.venue_id in venue_map:
|
||||||
|
venue_name = venue_map[show.venue_id].name
|
||||||
|
|
||||||
|
show.slug = generate_show_slug(date_str, venue_name)
|
||||||
|
session.add(show)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 5. Fix Set Names (Fetch API)
|
||||||
|
print("Fixing Set Names (fetching setlists)...")
|
||||||
|
# We need to map El Goose show_id/song_id to our IDs to find the record.
|
||||||
|
# But we don't store El Goose IDs in our models?
|
||||||
|
# Checked models.py: we don't store ex_id.
|
||||||
|
# We match by show date/venue and song title.
|
||||||
|
|
||||||
|
# This is hard to do reliably without external IDs.
|
||||||
|
# Alternatively, we can infer set name from 'position'?
|
||||||
|
# No, position 1 could be Set 1 or Encore if short show? No.
|
||||||
|
|
||||||
|
# Wait, import_elgoose mappings are local var.
|
||||||
|
# If we re-run import logic but UPDATE instead of SKIP, we can fix it.
|
||||||
|
# But matching is tricky.
|
||||||
|
|
||||||
|
# Let's try to match by Show Date and Song Title.
|
||||||
|
# Build map: (show_id, song_id, position) -> Performance
|
||||||
|
|
||||||
|
perfs = session.exec(select(Performance)).all()
|
||||||
|
perf_map = {} # (show_id, song_id, position) -> perf object
|
||||||
|
for p in perfs:
|
||||||
|
perf_map[(p.show_id, p.song_id, p.position)] = p
|
||||||
|
|
||||||
|
# We need show map: el_goose_show_id -> our_show_id
|
||||||
|
# We need song map: el_goose_song_id -> our_song_id
|
||||||
|
|
||||||
|
# We have to re-fetch shows and songs to rebuild this map.
|
||||||
|
print(" Re-building ID maps...")
|
||||||
|
|
||||||
|
# Map Shows
|
||||||
|
el_shows = fetch_all_json("shows", {"artist": 1})
|
||||||
|
if not el_shows: el_shows = fetch_all_json("shows") # fallback
|
||||||
|
|
||||||
|
el_show_map = {} # el_id -> our_id
|
||||||
|
for s in el_shows:
|
||||||
|
# Find our show
|
||||||
|
dt = s['showdate'] # YYYY-MM-DD
|
||||||
|
# We need to match precise Show.
|
||||||
|
# Simplified: match by date.
|
||||||
|
# Convert string to datetime
|
||||||
|
from datetime import datetime
|
||||||
|
s_date = datetime.strptime(dt, "%Y-%m-%d")
|
||||||
|
|
||||||
|
# Find show in our DB
|
||||||
|
# We can optimise this but for now linear search or query is fine for one-off script
|
||||||
|
found = session.exec(select(Show).where(Show.date == s_date)).first()
|
||||||
|
if found:
|
||||||
|
el_show_map[s['show_id']] = found.id
|
||||||
|
|
||||||
|
# Map Songs
|
||||||
|
el_songs = fetch_all_json("songs")
|
||||||
|
el_song_map = {} # el_id -> our_id
|
||||||
|
for s in el_songs:
|
||||||
|
found = session.exec(select(Song).where(Song.title == s['name'])).first()
|
||||||
|
if found:
|
||||||
|
el_song_map[s['id']] = found.id
|
||||||
|
|
||||||
|
# Now fetch setlists
|
||||||
|
el_setlists = fetch_all_json("setlists")
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for item in el_setlists:
|
||||||
|
our_show_id = el_show_map.get(item['show_id'])
|
||||||
|
our_song_id = el_song_map.get(item['song_id'])
|
||||||
|
position = item.get('position', 0)
|
||||||
|
|
||||||
|
if our_show_id and our_song_id:
|
||||||
|
# Find existing perf
|
||||||
|
perf = perf_map.get((our_show_id, our_song_id, position))
|
||||||
|
if perf:
|
||||||
|
# Logic to fix set_name
|
||||||
|
set_val = str(item.get('setnumber', '1'))
|
||||||
|
set_name = f"Set {set_val}"
|
||||||
|
if set_val.isdigit():
|
||||||
|
set_name = f"Set {set_val}"
|
||||||
|
elif set_val.lower() == 'e':
|
||||||
|
set_name = "Encore"
|
||||||
|
elif set_val.lower() == 'e2':
|
||||||
|
set_name = "Encore 2"
|
||||||
|
elif set_val.lower() == 's':
|
||||||
|
set_name = "Soundcheck"
|
||||||
|
|
||||||
|
if perf.set_name != set_name:
|
||||||
|
perf.set_name = set_name
|
||||||
|
session.add(perf)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
print(f"Fixed {count} performance set names.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
fix_data()
|
||||||
|
|
@ -12,6 +12,7 @@ from models import (
|
||||||
User, UserPreferences
|
User, UserPreferences
|
||||||
)
|
)
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
|
from slugify import generate_slug, generate_show_slug
|
||||||
|
|
||||||
BASE_URL = "https://elgoose.net/api/v2"
|
BASE_URL = "https://elgoose.net/api/v2"
|
||||||
ARTIST_ID = 1 # Goose
|
ARTIST_ID = 1 # Goose
|
||||||
|
|
@ -131,6 +132,7 @@ def import_venues(session):
|
||||||
else:
|
else:
|
||||||
venue = Venue(
|
venue = Venue(
|
||||||
name=v['venuename'],
|
name=v['venuename'],
|
||||||
|
slug=generate_slug(v['venuename']),
|
||||||
city=v.get('city'),
|
city=v.get('city'),
|
||||||
state=v.get('state'),
|
state=v.get('state'),
|
||||||
country=v.get('country'),
|
country=v.get('country'),
|
||||||
|
|
@ -166,6 +168,7 @@ def import_songs(session, vertical_id):
|
||||||
else:
|
else:
|
||||||
song = Song(
|
song = Song(
|
||||||
title=s['name'],
|
title=s['name'],
|
||||||
|
slug=generate_slug(s['name']),
|
||||||
original_artist=s.get('original_artist'),
|
original_artist=s.get('original_artist'),
|
||||||
vertical_id=vertical_id
|
vertical_id=vertical_id
|
||||||
# API doesn't include debut_date or times_played in base response
|
# API doesn't include debut_date or times_played in base response
|
||||||
|
|
@ -211,7 +214,10 @@ def import_shows(session, vertical_id, venue_map):
|
||||||
if existing_tour:
|
if existing_tour:
|
||||||
tour_map[s['tour_id']] = existing_tour.id
|
tour_map[s['tour_id']] = existing_tour.id
|
||||||
else:
|
else:
|
||||||
tour = Tour(name=s['tourname'])
|
tour = Tour(
|
||||||
|
name=s['tourname'],
|
||||||
|
slug=generate_slug(s['tourname'])
|
||||||
|
)
|
||||||
session.add(tour)
|
session.add(tour)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(tour)
|
session.refresh(tour)
|
||||||
|
|
@ -235,6 +241,7 @@ def import_shows(session, vertical_id, venue_map):
|
||||||
else:
|
else:
|
||||||
show = Show(
|
show = Show(
|
||||||
date=show_date,
|
date=show_date,
|
||||||
|
slug=generate_show_slug(s['showdate'], s.get('venuename', 'unknown')),
|
||||||
vertical_id=vertical_id,
|
vertical_id=vertical_id,
|
||||||
venue_id=venue_map.get(s['venue_id']),
|
venue_id=venue_map.get(s['venue_id']),
|
||||||
tour_id=tour_id,
|
tour_id=tour_id,
|
||||||
|
|
@ -292,11 +299,24 @@ def import_setlists(session, show_map, song_map):
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_perf:
|
if not existing_perf:
|
||||||
|
# Map setnumber to set_name
|
||||||
|
set_val = str(perf_data.get('setnumber', '1'))
|
||||||
|
if set_val.isdigit():
|
||||||
|
set_name = f"Set {set_val}"
|
||||||
|
elif set_val.lower() == 'e':
|
||||||
|
set_name = "Encore"
|
||||||
|
elif set_val.lower() == 'e2':
|
||||||
|
set_name = "Encore 2"
|
||||||
|
elif set_val.lower() == 's':
|
||||||
|
set_name = "Soundcheck"
|
||||||
|
else:
|
||||||
|
set_name = f"Set {set_val}"
|
||||||
|
|
||||||
perf = Performance(
|
perf = Performance(
|
||||||
show_id=our_show_id,
|
show_id=our_show_id,
|
||||||
song_id=our_song_id,
|
song_id=our_song_id,
|
||||||
position=perf_data.get('position', 0),
|
position=perf_data.get('position', 0),
|
||||||
set_name=perf_data.get('set'),
|
set_name=set_name,
|
||||||
segue=bool(perf_data.get('segue', 0)),
|
segue=bool(perf_data.get('segue', 0)),
|
||||||
notes=perf_data.get('notes')
|
notes=perf_data.get('notes')
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,12 @@ def read_recent_shows(
|
||||||
return shows
|
return shows
|
||||||
|
|
||||||
@router.get("/{show_id}", response_model=ShowRead)
|
@router.get("/{show_id}", response_model=ShowRead)
|
||||||
def read_show(show_id: int, session: Session = Depends(get_session)):
|
def read_show(show_id: str, session: Session = Depends(get_session)):
|
||||||
show = session.get(Show, show_id)
|
if show_id.isdigit():
|
||||||
|
show = session.get(Show, int(show_id))
|
||||||
|
else:
|
||||||
|
show = session.exec(select(Show).where(Show.slug == show_id)).first()
|
||||||
|
|
||||||
if not show:
|
if not show:
|
||||||
raise HTTPException(status_code=404, detail="Show not found")
|
raise HTTPException(status_code=404, detail="Show not found")
|
||||||
|
|
||||||
|
|
@ -58,7 +62,7 @@ def read_show(show_id: int, session: Session = Depends(get_session)):
|
||||||
select(Tag)
|
select(Tag)
|
||||||
.join(EntityTag, Tag.id == EntityTag.tag_id)
|
.join(EntityTag, Tag.id == EntityTag.tag_id)
|
||||||
.where(EntityTag.entity_type == "show")
|
.where(EntityTag.entity_type == "show")
|
||||||
.where(EntityTag.entity_id == show_id)
|
.where(EntityTag.entity_id == show.id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Manually populate performances to ensure nicknames are filtered if needed
|
# Manually populate performances to ensure nicknames are filtered if needed
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,11 @@ def read_song(song_id_or_slug: str, session: Session = Depends(get_session)):
|
||||||
venue_city = ""
|
venue_city = ""
|
||||||
venue_state = ""
|
venue_state = ""
|
||||||
show_date = datetime.now()
|
show_date = datetime.now()
|
||||||
|
show_slug = None
|
||||||
|
|
||||||
if p.show:
|
if p.show:
|
||||||
show_date = p.show.date
|
show_date = p.show.date
|
||||||
|
show_slug = p.show.slug
|
||||||
if p.show.venue:
|
if p.show.venue:
|
||||||
venue_name = p.show.venue.name
|
venue_name = p.show.venue.name
|
||||||
venue_city = p.show.venue.city
|
venue_city = p.show.venue.city
|
||||||
|
|
@ -87,6 +89,7 @@ def read_song(song_id_or_slug: str, session: Session = Depends(get_session)):
|
||||||
perf_dtos.append(PerformanceReadWithShow(
|
perf_dtos.append(PerformanceReadWithShow(
|
||||||
**p.model_dump(),
|
**p.model_dump(),
|
||||||
show_date=show_date,
|
show_date=show_date,
|
||||||
|
show_slug=show_slug,
|
||||||
venue_name=venue_name,
|
venue_name=venue_name,
|
||||||
venue_city=venue_city,
|
venue_city=venue_city,
|
||||||
venue_state=venue_state,
|
venue_state=venue_state,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class VenueCreate(VenueBase):
|
||||||
|
|
||||||
class VenueRead(VenueBase):
|
class VenueRead(VenueBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: Optional[str] = None
|
||||||
|
|
||||||
class VenueUpdate(SQLModel):
|
class VenueUpdate(SQLModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
@ -55,6 +56,7 @@ class SongCreate(SongBase):
|
||||||
|
|
||||||
class SongRead(SongBase):
|
class SongRead(SongBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: Optional[str] = None
|
||||||
tags: List["TagRead"] = []
|
tags: List["TagRead"] = []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,11 +88,13 @@ class PerformanceBase(SQLModel):
|
||||||
|
|
||||||
class PerformanceRead(PerformanceBase):
|
class PerformanceRead(PerformanceBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: Optional[str] = None
|
||||||
song: Optional["SongRead"] = None
|
song: Optional["SongRead"] = None
|
||||||
nicknames: List["PerformanceNicknameRead"] = []
|
nicknames: List["PerformanceNicknameRead"] = []
|
||||||
|
|
||||||
class PerformanceReadWithShow(PerformanceRead):
|
class PerformanceReadWithShow(PerformanceRead):
|
||||||
show_date: datetime
|
show_date: datetime
|
||||||
|
show_slug: Optional[str] = None
|
||||||
venue_name: str
|
venue_name: str
|
||||||
venue_city: str
|
venue_city: str
|
||||||
venue_state: Optional[str] = None
|
venue_state: Optional[str] = None
|
||||||
|
|
@ -141,6 +145,7 @@ class GroupPostRead(GroupPostBase):
|
||||||
|
|
||||||
class ShowRead(ShowBase):
|
class ShowRead(ShowBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: Optional[str] = None
|
||||||
venue: Optional["VenueRead"] = None
|
venue: Optional["VenueRead"] = None
|
||||||
tour: Optional["TourRead"] = None
|
tour: Optional["TourRead"] = None
|
||||||
tags: List["TagRead"] = []
|
tags: List["TagRead"] = []
|
||||||
|
|
@ -164,6 +169,7 @@ class TourCreate(TourBase):
|
||||||
|
|
||||||
class TourRead(TourBase):
|
class TourRead(TourBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: Optional[str] = None
|
||||||
|
|
||||||
class TourUpdate(SQLModel):
|
class TourUpdate(SQLModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
|
|
@ -344,6 +350,7 @@ class TagCreate(TagBase):
|
||||||
|
|
||||||
class TagRead(TagBase):
|
class TagRead(TagBase):
|
||||||
id: int
|
id: int
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
# Circular refs
|
# Circular refs
|
||||||
|
|
|
||||||
54
docs/HANDOFF_2025_12_21.md
Normal file
54
docs/HANDOFF_2025_12_21.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Handoff - 2025-12-21
|
||||||
|
|
||||||
|
## Work Completed
|
||||||
|
|
||||||
|
### Slug Integration
|
||||||
|
|
||||||
|
- **Backend**: Updated `Show`, `Song`, `Venue`, `Tour` models/schemas to support `slug`.
|
||||||
|
- Updated API routers (`shows.py`, `songs.py`) to lookup by slug or ID.
|
||||||
|
- Migrated database schema to include `slug` columns using Alembic.
|
||||||
|
- Backfilled slugs using `backend/fix_db_data.py`.
|
||||||
|
- **Frontend**: Updated routing and links for entities.
|
||||||
|
- `/shows/[id]` -> `/shows/${show.slug || show.id}`
|
||||||
|
- `/songs/[id]` -> `/songs/${song.slug || song.id}`
|
||||||
|
- `/venues/[id]` -> `/venues/${venue.slug || venue.id}`
|
||||||
|
- Updated interfaces to include `slug`.
|
||||||
|
- Updated `PerformanceList` component to use slugs.
|
||||||
|
|
||||||
|
### Data Fixes
|
||||||
|
|
||||||
|
- **Set Names**:
|
||||||
|
- Identified issues with `set_name` being null due to API parameter mismatch (`setnumber` vs `set`).
|
||||||
|
- Updated `import_elgoose.py` to correctly extract and format "Set 1", "Set 2", "Encore" from `setnumber`.
|
||||||
|
- Attempted to backfill existing data but hit an infinite loop issue with API fetching (Slugs were backfilled successfully). Data can be fixed by re-running a corrected importer or custom script.
|
||||||
|
- **Slugs**:
|
||||||
|
- `import_elgoose.py` updated to generate slugs for new imports.
|
||||||
|
- `fix_db_data.py` successfully backfilled slugs for existing Venues, Songs, Shows, and Tours.
|
||||||
|
|
||||||
|
### UI Fixes
|
||||||
|
|
||||||
|
- **Components**: Created missing Shadcn UI components that were causing build failures:
|
||||||
|
- `frontend/components/ui/progress.tsx`
|
||||||
|
- `frontend/components/ui/checkbox.tsx`
|
||||||
|
- **Auth**: Updated `AuthContext` to expose `token` for the Admin page.
|
||||||
|
- **Build**: Resolved typescript errors; build process starts correctly.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- **Application**: Fully functional slug-based navigation. Links prioritize slugs but fallback to IDs.
|
||||||
|
- **Database**: `slug` columns added. Migration `65c515b4722a_add_slugs` applied. `set_name` still missing for most existing performances (displays as "Set ?").
|
||||||
|
- **Codebase**: Clean and updated. `check_api.py` removed. `fix_db_data.py` exists but has pagination bug if re-run.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Verify Data**: Check if slugs are working correctly on the frontend.
|
||||||
|
2. **Fix Set Names**:
|
||||||
|
- Fix pagination in `backend/fix_db_data.py` (check API docs for correct pagination or limit handling).
|
||||||
|
- Re-run `python3 fix_db_data.py` to populate `set_name` for existing performances.
|
||||||
|
3. **Notifications**: Proceed with planned Notification System implementation (Discord, Telegram).
|
||||||
|
4. **Audit Items**: Continue auditing site for missing features/pages.
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- **Database Migrations**: Alembic history was manually adjusted to ignore existing `reaction`/`badge` tables to allow `slug` migration to pass on the dev database.
|
||||||
|
- **Importer**: `import_elgoose.py` logic is updated for *future* imports.
|
||||||
|
|
@ -8,22 +8,26 @@ import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
interface Show {
|
interface Show {
|
||||||
id: number
|
id: number
|
||||||
|
slug?: string
|
||||||
date: string
|
date: string
|
||||||
venue?: {
|
venue?: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
slug?: string
|
||||||
city?: string
|
city?: string
|
||||||
state?: string
|
state?: string
|
||||||
}
|
}
|
||||||
tour?: {
|
tour?: {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
slug?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
slug?: string
|
||||||
performance_count?: number
|
performance_count?: number
|
||||||
avg_rating?: number
|
avg_rating?: number
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +122,7 @@ export default async function Home() {
|
||||||
{recentShows.length > 0 ? (
|
{recentShows.length > 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{recentShows.map((show) => (
|
{recentShows.map((show) => (
|
||||||
<Link key={show.id} href={`/shows/${show.id}`}>
|
<Link key={show.id} href={`/shows/${show.slug || show.id}`}>
|
||||||
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
|
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="font-semibold">
|
<div className="font-semibold">
|
||||||
|
|
@ -175,7 +179,7 @@ export default async function Home() {
|
||||||
{topSongs.map((song, idx) => (
|
{topSongs.map((song, idx) => (
|
||||||
<li key={song.id}>
|
<li key={song.id}>
|
||||||
<Link
|
<Link
|
||||||
href={`/songs/${song.id}`}
|
href={`/songs/${song.slug || song.id}`}
|
||||||
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
|
className="flex items-center gap-3 p-3 hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
|
<span className="text-lg font-bold text-muted-foreground w-6 text-center">
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span>
|
<span className="text-muted-foreground/60 w-6 text-right text-xs font-mono">{perf.position}.</span>
|
||||||
<div className="font-medium flex items-center gap-2">
|
<div className="font-medium flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/performances/${perf.id}`}
|
href={`/songs/${perf.song?.slug || perf.song?.id}`}
|
||||||
className="hover:text-primary hover:underline transition-colors"
|
className="hover:text-primary hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
{perf.song?.title || "Unknown Song"}
|
{perf.song?.title || "Unknown Song"}
|
||||||
|
|
@ -257,7 +257,9 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="font-medium">{show.venue.name}</span>
|
<Link href={`/venues/${show.venue.slug || show.venue.id}`} className="font-medium hover:underline hover:text-primary">
|
||||||
|
{show.venue.name}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground pl-6">
|
<p className="text-sm text-muted-foreground pl-6">
|
||||||
{show.venue.city}, {show.venue.state} {show.venue.country}
|
{show.venue.city}, {show.venue.state} {show.venue.country}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
interface Show {
|
interface Show {
|
||||||
id: number
|
id: number
|
||||||
|
slug?: string
|
||||||
date: string
|
date: string
|
||||||
venue: {
|
venue: {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -83,7 +84,7 @@ export default function ShowsPage() {
|
||||||
|
|
||||||
<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.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">
|
||||||
<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">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Music } from "lucide-react"
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
slug?: string
|
||||||
original_artist?: string
|
original_artist?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ export default function SongsPage() {
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{songs.map((song) => (
|
{songs.map((song) => (
|
||||||
<Link key={song.id} href={`/songs/${song.id}`}>
|
<Link key={song.id} href={`/songs/${song.slug || song.id}`}>
|
||||||
<Card className="h-full hover:bg-accent/50 transition-colors">
|
<Card className="h-full hover:bg-accent/50 transition-colors">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface Venue {
|
||||||
|
|
||||||
interface Show {
|
interface Show {
|
||||||
id: number
|
id: number
|
||||||
|
slug?: string
|
||||||
date: string
|
date: string
|
||||||
tour?: { name: string }
|
tour?: { name: string }
|
||||||
performances?: any[]
|
performances?: any[]
|
||||||
|
|
@ -50,8 +51,8 @@ export default function VenueDetailPage() {
|
||||||
const venueData = await venueRes.json()
|
const venueData = await venueRes.json()
|
||||||
setVenue(venueData)
|
setVenue(venueData)
|
||||||
|
|
||||||
// Fetch shows at this venue
|
// Fetch shows at this venue using numeric ID
|
||||||
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${id}&limit=100`)
|
const showsRes = await fetch(`${getApiUrl()}/shows/?venue_id=${venueData.id}&limit=100`)
|
||||||
if (showsRes.ok) {
|
if (showsRes.ok) {
|
||||||
const showsData = await showsRes.json()
|
const showsData = await showsRes.json()
|
||||||
// Sort by date descending
|
// Sort by date descending
|
||||||
|
|
@ -136,7 +137,7 @@ export default function VenueDetailPage() {
|
||||||
{shows.length > 0 ? (
|
{shows.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{shows.map((show) => (
|
{shows.map((show) => (
|
||||||
<Link key={show.id} href={`/shows/${show.id}`} className="block group">
|
<Link key={show.id} href={`/shows/${show.slug || show.id}`} className="block group">
|
||||||
<div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border">
|
<div className="flex items-center justify-between p-3 rounded-md hover:bg-muted/50 transition-colors border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { MapPin, Search, Calendar, ArrowUpDown } from "lucide-react"
|
||||||
interface Venue {
|
interface Venue {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
slug?: string
|
||||||
city: string
|
city: string
|
||||||
state: string
|
state: string
|
||||||
country: string
|
country: string
|
||||||
|
|
@ -180,7 +181,7 @@ export default function VenuesPage() {
|
||||||
{/* Venue Grid */}
|
{/* Venue Grid */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{filteredVenues.map((venue) => (
|
{filteredVenues.map((venue) => (
|
||||||
<Link key={venue.id} href={`/venues/${venue.id}`}>
|
<Link key={venue.id} href={`/venues/${venue.slug || venue.id}`}>
|
||||||
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group">
|
<Card className="h-full hover:bg-accent/50 transition-colors cursor-pointer group">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors">
|
<CardTitle className="flex items-center gap-2 text-lg group-hover:text-primary transition-colors">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
export interface Performance {
|
export interface Performance {
|
||||||
id: number
|
id: number
|
||||||
show_id: number
|
show_id: number
|
||||||
|
show_slug?: string
|
||||||
song_id: number
|
song_id: number
|
||||||
position: number
|
position: number
|
||||||
set_name: string | null
|
set_name: string | null
|
||||||
|
|
@ -25,6 +26,26 @@ export interface Performance {
|
||||||
total_reviews: number
|
total_reviews: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// In JSX:
|
||||||
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<Link
|
||||||
|
href={`/shows/${perf.show_slug || perf.show_id}`}
|
||||||
|
className="font-medium hover:underline text-primary truncate"
|
||||||
|
>
|
||||||
|
{new Date(perf.show_date).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short'
|
||||||
|
})}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-mono">
|
||||||
|
{perf.set_name || "Set ?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
interface PerformanceListProps {
|
interface PerformanceListProps {
|
||||||
performances: Performance[]
|
performances: Performance[]
|
||||||
songTitle?: string
|
songTitle?: string
|
||||||
|
|
@ -86,7 +107,7 @@ export function PerformanceList({ performances, songTitle }: PerformanceListProp
|
||||||
<div className="space-y-1 flex-1 min-w-0 pr-4">
|
<div className="space-y-1 flex-1 min-w-0 pr-4">
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
href={`/shows/${perf.show_id}`}
|
href={`/shows/${perf.show_slug || perf.show_id}`}
|
||||||
className="font-medium hover:underline text-primary truncate"
|
className="font-medium hover:underline text-primary truncate"
|
||||||
>
|
>
|
||||||
{new Date(perf.show_date).toLocaleDateString(undefined, {
|
{new Date(perf.show_date).toLocaleDateString(undefined, {
|
||||||
|
|
|
||||||
30
frontend/components/ui/checkbox.tsx
Normal file
30
frontend/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
28
frontend/components/ui/progress.tsx
Normal file
28
frontend/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
|
|
@ -13,6 +13,7 @@ interface User {
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null
|
user: User | null
|
||||||
|
token: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (token: string) => Promise<void>
|
login: (token: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
|
@ -20,6 +21,7 @@ interface AuthContextType {
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
|
token: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
login: async () => { },
|
login: async () => { },
|
||||||
logout: () => { },
|
logout: () => { },
|
||||||
|
|
@ -27,17 +29,20 @@ const AuthContext = createContext<AuthContextType>({
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [token, setToken] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
const token = localStorage.getItem("token")
|
const storedToken = localStorage.getItem("token")
|
||||||
if (token) {
|
if (storedToken) {
|
||||||
|
setToken(storedToken)
|
||||||
try {
|
try {
|
||||||
await fetchUser(token)
|
await fetchUser(storedToken)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auth init failed", err)
|
console.error("Auth init failed", err)
|
||||||
localStorage.removeItem("token")
|
localStorage.removeItem("token")
|
||||||
|
setToken(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
@ -59,18 +64,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const login = async (token: string) => {
|
const login = async (newToken: string) => {
|
||||||
localStorage.setItem("token", token)
|
localStorage.setItem("token", newToken)
|
||||||
await fetchUser(token)
|
setToken(newToken)
|
||||||
|
await fetchUser(newToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem("token")
|
localStorage.removeItem("token")
|
||||||
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
268
frontend/package-lock.json
generated
268
frontend/package-lock.json
generated
|
|
@ -8,11 +8,16 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.5",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
|
"@radix-ui/react-switch": "^1.1.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -21,6 +26,7 @@
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
|
|
@ -2316,6 +2322,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
@ -2781,6 +2817,118 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-roving-focus": {
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
|
|
@ -2843,6 +2991,49 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
|
@ -2861,6 +3052,35 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
|
@ -2976,6 +3196,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-rect": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
|
@ -3012,6 +3247,29 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/rect": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
|
@ -9531,6 +9789,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.2.5",
|
"@radix-ui/react-radio-group": "^1.2.5",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
"@radix-ui/react-switch": "^1.1.5",
|
"@radix-ui/react-switch": "^1.1.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
|
@ -26,11 +28,11 @@
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0"
|
||||||
"next-themes": "^0.4.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|
@ -48,4 +50,4 @@
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue