diff --git a/app/main.py b/app/main.py index 07313c1..12caace 100644 --- a/app/main.py +++ b/app/main.py @@ -81,11 +81,11 @@ BADGE_DEFINITIONS = ( ("admin", "Admin", "🏴‍☠️", None, "Verantwortung für Pingu Concerts", "special"), ("beta_tester", "Beta Tester", "🧪", None, "In der Beta dabei", "beta"), ("first_gig", "Erster Gig", "🎸", 1, "Dein erstes besuchtes Konzert", "attendance"), - ("regular", "Stammgast", "🤘", 5, "5 Konzerte besucht", "attendance"), - ("ten_gigs", "Zehnerrunde", "🔥", 10, "10 Konzerte besucht", "attendance"), - ("tour_veteran", "Tourveteran", "⚡", 25, "25 Konzerte besucht", "attendance"), - ("fifty_gigs", "Fünfzig Konzerte", "💀", 50, "50 Konzerte besucht", "attendance"), - ("hundred_gigs", "Hunderterclub", "👑", 100, "100 Konzerte besucht", "attendance"), + ("regular", "Stammgast", "🤘", 5, "5 Konzerte am selben Veranstaltungsort besucht", "attendance"), + ("ten_gigs", "Stammgast · Level 10", "🔥", 10, "10 Konzerte am selben Veranstaltungsort besucht", "attendance"), + ("tour_veteran", "Stammgast · Level 25", "⚡", 25, "25 Konzerte am selben Veranstaltungsort besucht", "attendance"), + ("fifty_gigs", "Stammgast · Level 50", "💀", 50, "50 Konzerte am selben Veranstaltungsort besucht", "attendance"), + ("hundred_gigs", "Stammgast · Level 100", "👑", 100, "100 Konzerte am selben Veranstaltungsort besucht", "attendance"), ) ATTENDANCE_BADGE_CODES = tuple( badge_code @@ -1085,31 +1085,43 @@ def remove_uploaded_file(path: str | None, destination_dir: str, url_prefix: str return True -def attended_concert_count(user_id: int) -> int: +def attended_concert_stats(user_id: int) -> tuple[int, int]: with get_db_connection() as connection: with connection.cursor() as cursor: cursor.execute( """ - SELECT COUNT(*) - FROM concert_attendance - JOIN concerts ON concerts.id = concert_attendance.concert_id - WHERE concert_attendance.user_id = %s - AND concert_attendance.status = 'attending' - AND COALESCE( - concerts.end_datetime, - concerts.start_datetime - ) < CURRENT_TIMESTAMP + WITH attended AS ( + SELECT concerts.venue_id + , concerts.event_type + FROM concert_attendance + JOIN concerts ON concerts.id = concert_attendance.concert_id + WHERE concert_attendance.user_id = %s + AND concert_attendance.status = 'attending' + AND COALESCE(concerts.end_datetime, concerts.start_datetime) < CURRENT_TIMESTAMP + ), venue_counts AS ( + SELECT venue_id, COUNT(*) AS visit_count + FROM attended + WHERE venue_id IS NOT NULL AND event_type <> 'other' + GROUP BY venue_id + ) + SELECT + (SELECT COUNT(*) FROM attended), + COALESCE((SELECT MAX(visit_count) FROM venue_counts), 0) """, (user_id,), ) - return cursor.fetchone()[0] + total, max_same_venue = cursor.fetchone() + return total, max_same_venue -def grant_earned_badges(user_id: int, attended_count: int, registered_at): +def grant_earned_badges(user_id: int, attended_count: int, max_same_venue, registered_at): highest_attendance_badge = None for badge_code, _name, _icon, threshold, _description, category in BADGE_DEFINITIONS: - if category == "attendance" and attended_count >= threshold: + qualifies = category == "attendance" and attended_count >= threshold + if category == "attendance" and threshold >= 5: + qualifies = max_same_venue >= threshold + if qualifies: highest_attendance_badge = badge_code with get_db_connection() as connection: @@ -2205,10 +2217,11 @@ def render_profile( connections["incoming"].append(item) else: connections["outgoing"].append(item) - attended_count = attended_concert_count(profile["id"]) + attended_count, max_same_venue = attended_concert_stats(profile["id"]) grant_earned_badges( profile["id"], attended_count, + max_same_venue, profile["registered_at"], ) profile = load_profile(username) diff --git a/app/static/css/style.css b/app/static/css/style.css index 73a853e..d5f6a9f 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -23,10 +23,20 @@ body { "Segoe UI", sans-serif; - background: radial-gradient(circle at 50% -15%, #320909 0, transparent 32rem), var(--bg); + background: + linear-gradient(rgba(4, 4, 6, .58), rgba(4, 4, 6, .72)), + url('/static/images/crowd-stage-background.webp') center top / cover fixed no-repeat, + var(--bg); color: var(--text); } +@media (max-width: 700px) { + body { + background-attachment: scroll; + background-position: center top; + } +} + a { color: inherit; text-decoration: none; @@ -366,6 +376,74 @@ form .button { .admin-danger { padding: 9px 12px; color: #fecaca; border: 1px solid #7f1d1d; background: #450a0a; border-radius: 8px; cursor: pointer; } .admin-danger:disabled { opacity: .4; cursor: not-allowed; } +/* Heavy-Metal-Theme: Poster, Bühne und Backstage-Pass statt Standard-UI */ +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: .16; + background-image: + radial-gradient(circle at 18% 22%, rgba(255,255,255,.12) 0 1px, transparent 1px), + radial-gradient(circle at 73% 64%, rgba(255,255,255,.08) 0 1px, transparent 1px); + background-size: 7px 9px, 11px 13px; + mix-blend-mode: screen; +} + +header, main, .container { position: relative; z-index: 1; } + +h1, h2, h3, .logo, .button, button, .page-title h1 { + font-family: Impact, Haettenschweiler, "Arial Narrow Bold", sans-serif; + letter-spacing: .045em; +} + +h1, .page-title h1 { + text-transform: uppercase; + text-shadow: 2px 2px 0 #250707, 0 0 18px rgba(220,38,38,.24); +} + +.page-title { + border-left: 4px solid var(--accent); + padding-left: 16px; +} + +.button { + border: 1px solid #ef4444; + box-shadow: 0 3px 0 #450a0a, 0 0 14px rgba(185,28,28,.2); + text-transform: uppercase; +} + +.button:hover { transform: translateY(-1px); box-shadow: 0 4px 0 #450a0a, 0 0 20px rgba(220,38,38,.35); } + +.concert-card, .admin-section, .profile-card, .badges-card, .profile-edit { + box-shadow: 0 12px 28px rgba(0,0,0,.3), inset 0 1px rgba(255,255,255,.025); +} + +.concert-card { position: relative; border-left: 3px solid #7f1d1d; } +.concert-card::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + background: linear-gradient(120deg, rgba(255,255,255,.035), transparent 32%); +} + +.event-category { + padding: 3px 8px; + border: 1px solid #5f1515; + border-radius: 999px; + background: rgba(127,29,29,.3); +} + +.empty-state { background: rgba(16,16,16,.86); border-style: solid; } + +@media (max-width: 600px) { + .page-title { padding-left: 11px; } + .button { min-height: 42px; } +} + @media (max-width: 700px) { .admin-nav { display: grid; grid-template-columns: 1fr; } .admin-section { padding: 16px; } diff --git a/app/static/images/crowd-stage-background.webp b/app/static/images/crowd-stage-background.webp new file mode 100644 index 0000000..73d1544 Binary files /dev/null and b/app/static/images/crowd-stage-background.webp differ diff --git a/app/templates/index.html b/app/templates/index.html index ee6f052..92a5eb5 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -14,7 +14,7 @@