Initial commit: Eurovision 2026 Scorecard web app
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Eurovision 2026 Scorecard
|
||||
|
||||
Vienna Grand Final — Saturday 16 May
|
||||
|
||||
Single-file interactive web app for scoring, ranking, and sharing your Eurovision predictions.
|
||||
|
||||
Features:
|
||||
- 35 countries with artists, songs, and betting odds
|
||||
- Score input (0-12 pts) + notes per entry
|
||||
- Live rankings leaderboard
|
||||
- Export/import scores (copy text or shareable link)
|
||||
- Sort by running order, country name, score, or odds
|
||||
- Scores persist in localStorage
|
||||
- Dark theme, mobile responsive
|
||||
+756
@@ -0,0 +1,756 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eurovision 2026 — Vienna Scorecard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a1a;
|
||||
--card: #141428;
|
||||
--card-hover: #1a1a36;
|
||||
--accent: #7c5cfc;
|
||||
--accent2: #fc5c8c;
|
||||
--text: #e8e8f0;
|
||||
--muted: #7878a0;
|
||||
--gold: #ffd700;
|
||||
--silver: #c0c0c0;
|
||||
--bronze: #cd7f32;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #1a0a3e 0%, #0a1a3e 50%, #1a0a2e 100%);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(124,92,252,0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 50%, rgba(252,92,140,0.1) 0%, transparent 50%);
|
||||
animation: shimmer 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translate(-10%, -10%); }
|
||||
100% { transform: translate(10%, 10%); }
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero .subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero .venue {
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid #1e1e3a;
|
||||
}
|
||||
|
||||
.controls button, .controls select {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid #2a2a4a;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.controls button:hover, .controls select:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.controls button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 4rem;
|
||||
}
|
||||
|
||||
.country-card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 1fr auto auto;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.country-card:hover {
|
||||
border-color: #2a2a4a;
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.country-card.scored {
|
||||
border-color: #2a4a2a;
|
||||
}
|
||||
|
||||
.rank-num {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.country-name {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.country-flag {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.odds-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #2a2a4a;
|
||||
color: var(--accent);
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artist-song {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.15rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.score-input {
|
||||
width: 5rem;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a2a4a;
|
||||
background: #0e0e20;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.score-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(124,92,252,0.3);
|
||||
}
|
||||
|
||||
.notes-input {
|
||||
width: 10rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a2a4a;
|
||||
background: #0e0e20;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.notes-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.notes-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Ranking view */
|
||||
.country-card.rank-1 { border-left: 4px solid var(--gold); }
|
||||
.country-card.rank-2 { border-left: 4px solid var(--silver); }
|
||||
.country-card.rank-3 { border-left: 4px solid var(--bronze); }
|
||||
|
||||
.rank-1 .rank-num::after { content: '👑'; }
|
||||
.rank-2 .rank-num::after { content: '🥈'; }
|
||||
.rank-3 .rank-num::after { content: '🥉'; }
|
||||
|
||||
.summary-box {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.summary-box h3 {
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.top3-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.top3-list li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #1e1e3a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top3-list li:last-child { border-bottom: none; }
|
||||
|
||||
.top3-list .place {
|
||||
font-weight: 700;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.top3-list .score-val {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.share-section {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.share-section h3 {
|
||||
color: var(--accent2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.share-section textarea {
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
background: #0e0e20;
|
||||
color: var(--text);
|
||||
border: 1px solid #2a2a4a;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
resize: vertical;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.share-section textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.share-section .import-area {
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.share-section .import-area label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.share-btn:hover {
|
||||
background: #6a4ce0;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.share-btn.secondary {
|
||||
background: var(--card);
|
||||
border: 1px solid #2a2a4a;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.share-btn.secondary:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.share-btn.import-btn {
|
||||
background: var(--accent2);
|
||||
}
|
||||
|
||||
.share-btn.import-btn:hover {
|
||||
background: #e04c7c;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--card);
|
||||
border-radius: 8px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid #2a2a4a;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
border: none !important;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
background: transparent !important;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--accent) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
/* Compact view */
|
||||
.compact .country-card {
|
||||
grid-template-columns: 2.5rem 1fr 4rem 8rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.compact .country-name { font-size: 0.9rem; }
|
||||
.compact .artist-song { font-size: 0.75rem; }
|
||||
.compact .score-input { width: 4rem; font-size: 1rem; }
|
||||
.compact .notes-input { width: 7rem; font-size: 0.75rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.hero h1 { font-size: 1.8rem; }
|
||||
.country-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rank-num {
|
||||
display: none;
|
||||
}
|
||||
.score-input, .notes-input {
|
||||
width: 100%;
|
||||
}
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.reset-link {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin-top: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.reset-link:hover { color: var(--accent2); }
|
||||
|
||||
.scored-count {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hero">
|
||||
<h1>🎤 Eurovision 2026</h1>
|
||||
<div class="subtitle">Vienna Grand Final — <span class="venue">Saturday 16 May</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<div class="view-toggle">
|
||||
<button id="btn-score" class="active" onclick="setView('score')">✏️ Score</button>
|
||||
<button id="btn-rank" onclick="setView('rank')">🏆 Rankings</button>
|
||||
<button id="btn-share" onclick="setView('share')">📤 Share</button>
|
||||
</div>
|
||||
<select id="sort-select" onchange="render()" title="Sort by">
|
||||
<option value="number">By running order</option>
|
||||
<option value="alpha">By country name</option>
|
||||
<option value="score">By your score</option>
|
||||
<option value="odds">By bookmaker odds</option>
|
||||
</select>
|
||||
<span class="scored-count" id="scored-count">0 / 35 scored</span>
|
||||
</div>
|
||||
|
||||
<div class="container" id="app">
|
||||
<div id="score-view"></div>
|
||||
<div id="rank-view" class="hidden"></div>
|
||||
<div id="share-view" class="hidden">
|
||||
<div class="share-section">
|
||||
<h3>📤 Share Your Scores</h3>
|
||||
<p style="color:var(--muted);margin-bottom:0.75rem;font-size:0.9rem;">Copy this to send to friends, or paste someone else's scores below to load them.</p>
|
||||
<textarea id="export-area" readonly></textarea>
|
||||
<div>
|
||||
<button class="share-btn" onclick="copyScores()">📋 Copy Scores</button>
|
||||
<button class="share-btn secondary" onclick="generateLink()">🔗 Generate Link</button>
|
||||
</div>
|
||||
<div class="import-area">
|
||||
<label>Paste scores to import:</label>
|
||||
<textarea id="import-area" placeholder="Paste exported scores here..."></textarea>
|
||||
<button class="share-btn import-btn" onclick="importScores()">📥 Import Scores</button>
|
||||
</div>
|
||||
<div style="margin-top:1rem">
|
||||
<a class="reset-link" onclick="resetAll()">🗑️ Reset all scores</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const COUNTRIES = [
|
||||
{num:1, code:"AL", name:"Albania", artist:"Alis", song:"Nân", odds:126, chance:"1%"},
|
||||
{num:2, code:"AM", name:"Armenia", artist:"SIMÓN", song:"Paloma Rumba", odds:251, chance:"<1%"},
|
||||
{num:3, code:"AU", name:"Australia", artist:"Delta Goodrem", song:"Eclipse", odds:3, chance:"23%"},
|
||||
{num:4, code:"AT", name:"Austria", artist:"COSMÓ", song:"Tanzschein", odds:501, chance:"<1%"},
|
||||
{num:5, code:"AZ", name:"Azerbaijan", artist:"JIVA", song:"Just Go", odds:1001, chance:"<1%"},
|
||||
{num:6, code:"BE", name:"Belgium", artist:"ESSYLA", song:"Dancing on the Ice", odds:501, chance:"<1%"},
|
||||
{num:7, code:"BG", name:"Bulgaria", artist:"DARA", song:"Bangaranga", odds:10, chance:"7%"},
|
||||
{num:8, code:"HR", name:"Croatia", artist:"LELEK", song:"Andromeda", odds:126, chance:"1%"},
|
||||
{num:9, code:"CY", name:"Cyprus", artist:"Antigoni", song:"JALLA", odds:201, chance:"1%"},
|
||||
{num:10, code:"CZ", name:"Czechia", artist:"Daniel Žižka", song:"Crossroads", odds:81, chance:"1%"},
|
||||
{num:11, code:"DK", name:"Denmark", artist:"Søren Torpegaard Lund", song:"Før Vi Går Hjem", odds:26, chance:"2%"},
|
||||
{num:12, code:"EE", name:"Estonia", artist:"Vanilla Ninja", song:"Too Epic To Be True", odds:301, chance:"<1%"},
|
||||
{num:13, code:"FI", name:"Finland", artist:"Linda Lampenius & Pete Parkkonen", song:"Liekinheitin", odds:2, chance:"40%"},
|
||||
{num:14, code:"FR", name:"France", artist:"Monroe", song:"Regarde !", odds:81, chance:"1%"},
|
||||
{num:15, code:"GE", name:"Georgia", artist:"Bzikebi", song:"On Replay", odds:151, chance:"1%"},
|
||||
{num:16, code:"DE", name:"Germany", artist:"Sarah Engels", song:"Fire", odds:301, chance:"<1%"},
|
||||
{num:17, code:"GR", name:"Greece", artist:"Akylas", song:"Ferto", odds:13, chance:"6%"},
|
||||
{num:18, code:"IL", name:"Israel", artist:"Noam Bettan", song:"Michelle", odds:14, chance:"5%"},
|
||||
{num:19, code:"IT", name:"Italy", artist:"Sal Da Vinci", song:"Per Sempre Sì", odds:51, chance:"2%"},
|
||||
{num:20, code:"LV", name:"Latvia", artist:"Atvara", song:"Ēnā", odds:201, chance:"1%"},
|
||||
{num:21, code:"LT", name:"Lithuania", artist:"Lion Ceccah", song:"Sólo Quiero Más", odds:301, chance:"<1%"},
|
||||
{num:22, code:"LU", name:"Luxembourg", artist:"Eva Marija", song:"Mother Nature", odds:500, chance:"<1%"},
|
||||
{num:23, code:"MT", name:"Malta", artist:"AIDAN", song:"Bella", odds:101, chance:"1%"},
|
||||
{num:24, code:"MD", name:"Moldova", artist:"Satoshi", song:"Viva, Moldova!", odds:81, chance:"1%"},
|
||||
{num:25, code:"ME", name:"Montenegro", artist:"Tamara Živković", song:"Nova Zora", odds:500, chance:"<1%"},
|
||||
{num:26, code:"NO", name:"Norway", artist:"JONAS LOVV", song:"Ya Ya Ya", odds:251, chance:"<1%"},
|
||||
{num:27, code:"PL", name:"Poland", artist:"ALICJA", song:"Pray", odds:251, chance:"<1%"},
|
||||
{num:28, code:"PT", name:"Portugal", artist:"Bandidos do Cante", song:"Rosa", odds:201, chance:"1%"},
|
||||
{num:29, code:"RO", name:"Romania", artist:"Alexandra Căpitănescu", song:"Choke Me", odds:20, chance:"4%"},
|
||||
{num:30, code:"SM", name:"San Marino", artist:"SENHIT", song:"Superstar", odds:401, chance:"<1%"},
|
||||
{num:31, code:"RS", name:"Serbia", artist:"LAVINA", song:"Kraj Mene", odds:251, chance:"<1%"},
|
||||
{num:32, code:"SE", name:"Sweden", artist:"Felicia", song:"My System", odds:51, chance:"1%"},
|
||||
{num:33, code:"CH", name:"Switzerland", artist:"Gio Pane", song:"Alice", odds:201, chance:"1%"},
|
||||
{num:34, code:"UA", name:"Ukraine", artist:"Leléka", song:"Ridnym", odds:101, chance:"1%"},
|
||||
{num:35, code:"GB", name:"United Kingdom", artist:"Look Mum No Computer", song:"Eins, Zwei, Drei", odds:301, chance:"<1%"}
|
||||
];
|
||||
|
||||
const FLAGS = {
|
||||
AL:'🇦🇱',AM:'🇦🇲',AU:'🇦🇺',AT:'🇦🇹',AZ:'🇦🇿',BE:'🇧🇪',BG:'🇧🇬',HR:'🇭🇷',CY:'🇨🇾',
|
||||
CZ:'🇨🇿',DK:'🇩🇰',EE:'🇪🇪',FI:'🇫🇮',FR:'🇫🇷',GE:'🇬🇪',DE:'🇩🇪',GR:'🇬🇷',IL:'🇮🇱',
|
||||
IT:'🇮🇹',LV:'🇱🇻',LT:'🇱🇹',LU:'🇱🇺',MT:'🇲🇹',MD:'🇲🇩',ME:'🇲🇪',NO:'🇳🇴',PL:'🇵🇱',
|
||||
PT:'🇵🇹',RO:'🇷🇴',SM:'🇸🇲',RS:'🇷🇸',SE:'🇸🇪',CH:'🇨🇭',UA:'🇺🇦',GB:'🇬🇧'
|
||||
};
|
||||
|
||||
let scores = {};
|
||||
let currentView = 'score';
|
||||
|
||||
function loadScores() {
|
||||
try {
|
||||
const saved = localStorage.getItem('eurovision2026_scores');
|
||||
if (saved) scores = JSON.parse(saved);
|
||||
} catch(e) {}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const data = params.get('scores');
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(atob(decodeURIComponent(data)));
|
||||
scores = parsed;
|
||||
saveScores();
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveScores() {
|
||||
localStorage.setItem('eurovision2026_scores', JSON.stringify(scores));
|
||||
}
|
||||
|
||||
function getSorted() {
|
||||
const sort = document.getElementById('sort-select').value;
|
||||
let list = [...COUNTRIES];
|
||||
switch(sort) {
|
||||
case 'alpha': list.sort((a,b) => a.name.localeCompare(b.name)); break;
|
||||
case 'score': list.sort((a,b) => (scores[b.code]||0) - (scores[a.code]||0)); break;
|
||||
case 'odds': list.sort((a,b) => a.odds - b.odds); break;
|
||||
default: list.sort((a,b) => a.num - b.num);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
document.getElementById('score-view').classList.toggle('hidden', view !== 'score');
|
||||
document.getElementById('rank-view').classList.toggle('hidden', view !== 'rank');
|
||||
document.getElementById('share-view').classList.toggle('hidden', view !== 'share');
|
||||
|
||||
document.getElementById('btn-score').classList.toggle('active', view === 'score');
|
||||
document.getElementById('btn-rank').classList.toggle('active', view === 'rank');
|
||||
document.getElementById('btn-share').classList.toggle('active', view === 'share');
|
||||
|
||||
if (view === 'share') generateExport();
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = getSorted();
|
||||
const scoredCount = Object.keys(scores).filter(k => scores[k] !== '' && scores[k] !== null).length;
|
||||
document.getElementById('scored-count').textContent = `${scoredCount} / ${COUNTRIES.length} scored`;
|
||||
|
||||
const scoreView = document.getElementById('score-view');
|
||||
scoreView.innerHTML = list.map((c, i) => `
|
||||
<div class="country-card ${scores[c.code] !== '' && scores[c.code] !== null && scores[c.code] !== undefined ? 'scored' : ''}">
|
||||
<div class="rank-num">${c.num}</div>
|
||||
<div class="info">
|
||||
<div class="country-name">
|
||||
<span class="country-flag">${FLAGS[c.code]||''}</span>
|
||||
${c.name}
|
||||
<span class="odds-badge" title="Win chance: ${c.chance}">🎲 ${c.odds === 1 ? 'EVEN' : c.odds}</span>
|
||||
</div>
|
||||
<div class="artist-song">${c.artist} — ${c.song}</div>
|
||||
</div>
|
||||
<input type="number" class="score-input" placeholder="Pts"
|
||||
value="${scores[c.code] !== undefined && scores[c.code] !== '' ? scores[c.code] : ''}"
|
||||
onchange="updateScore('${c.code}', this.value)" title="Your score" min="0" max="12">
|
||||
<input type="text" class="notes-input" placeholder="Notes..."
|
||||
value="${scores[c.code + '_n'] || ''}"
|
||||
onchange="updateNote('${c.code}', this.value)" title="Your notes">
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
renderRankings();
|
||||
}
|
||||
|
||||
function renderRankings() {
|
||||
const ranked = COUNTRIES
|
||||
.filter(c => scores[c.code] !== '' && scores[c.code] !== null && scores[c.code] !== undefined && scores[c.code] > 0)
|
||||
.sort((a,b) => (scores[b.code]||0) - (scores[a.code]||0));
|
||||
|
||||
const rankView = document.getElementById('rank-view');
|
||||
if (!ranked.length) {
|
||||
rankView.innerHTML = '<div class="summary-box"><p style="color:var(--muted);text-align:center">Score some countries first to see rankings!</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="summary-box"><h3>🏆 Your Rankings</h3><ul class="top3-list">';
|
||||
ranked.forEach((c, i) => {
|
||||
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : '';
|
||||
html += `<li class="${i<3 ? 'rank-' + (i+1) : ''}">
|
||||
<span><span class="place">${medal} #${i+1}</span> ${FLAGS[c.code]||''} ${c.name}</span>
|
||||
<span class="score-val">${scores[c.code]} pts</span>
|
||||
</li>`;
|
||||
});
|
||||
html += '</ul></div>';
|
||||
rankView.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateScore(code, value) {
|
||||
if (value === '' || value === null) {
|
||||
delete scores[code];
|
||||
} else {
|
||||
scores[code] = parseInt(value) || 0;
|
||||
}
|
||||
saveScores();
|
||||
render();
|
||||
}
|
||||
|
||||
function updateNote(code, value) {
|
||||
if (value) {
|
||||
scores[code + '_n'] = value;
|
||||
} else {
|
||||
delete scores[code + '_n'];
|
||||
}
|
||||
saveScores();
|
||||
}
|
||||
|
||||
function generateExport() {
|
||||
const data = {};
|
||||
COUNTRIES.forEach(c => {
|
||||
if (scores[c.code] !== '' && scores[c.code] !== null && scores[c.code] !== undefined) {
|
||||
data[c.name] = {
|
||||
score: scores[c.code],
|
||||
...(scores[c.code + '_n'] ? {note: scores[c.code + '_n']} : {})
|
||||
};
|
||||
}
|
||||
});
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
document.getElementById('export-area').value =
|
||||
`🎤 Eurovision 2026 Scores\n${'═'.repeat(40)}\n\n${json}\n\n${'═'.repeat(40)}\n🔗 Generated by Eurovision 2026 Scorecard`;
|
||||
}
|
||||
|
||||
function copyScores() {
|
||||
const area = document.getElementById('export-area');
|
||||
navigator.clipboard.writeText(area.value).then(() => showToast('📋 Copied to clipboard!'));
|
||||
}
|
||||
|
||||
function generateLink() {
|
||||
const data = btoa(encodeURIComponent(JSON.stringify(scores)));
|
||||
const url = window.location.origin + window.location.pathname + '?scores=' + encodeURIComponent(data);
|
||||
document.getElementById('export-area').value = url;
|
||||
navigator.clipboard.writeText(url).then(() => showToast('🔗 Link copied!'));
|
||||
}
|
||||
|
||||
function importScores() {
|
||||
const input = document.getElementById('import-area').value.trim();
|
||||
if (!input) return showToast('⚠️ Paste some scores first!');
|
||||
|
||||
const urlMatch = input.match(/scores=([^&\s]+)/);
|
||||
if (urlMatch) {
|
||||
try {
|
||||
scores = JSON.parse(decodeURIComponent(atob(urlMatch[1])));
|
||||
saveScores();
|
||||
document.getElementById('import-area').value = '';
|
||||
render();
|
||||
showToast('✅ Scores imported!');
|
||||
setView('score');
|
||||
return;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonMatch = input.match(/\{[\s\S]+\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
Object.entries(parsed).forEach(([name, val]) => {
|
||||
const country = COUNTRIES.find(c => c.name.toLowerCase() === name.toLowerCase());
|
||||
if (country) {
|
||||
scores[country.code] = typeof val === 'object' ? val.score : val;
|
||||
if (typeof val === 'object' && val.note) {
|
||||
scores[country.code + '_n'] = val.note;
|
||||
}
|
||||
}
|
||||
});
|
||||
saveScores();
|
||||
document.getElementById('import-area').value = '';
|
||||
render();
|
||||
showToast('✅ Scores imported!');
|
||||
setView('score');
|
||||
} else {
|
||||
showToast('⚠️ Could not parse scores');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('⚠️ Invalid format');
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (confirm('Clear all scores and notes?')) {
|
||||
scores = {};
|
||||
saveScores();
|
||||
render();
|
||||
showToast('🗑️ All cleared');
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
loadScores();
|
||||
render();
|
||||
setView('score');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user