Phase 16: Card Catalog, Settings/Profile, Squad Builder tabs

Card Catalog — full card database browser using GET /cards with client-
side filters (name, position, rarity, nation, league, club, OVR range),
pagination in 80-card chunks, "Owned" green overlay/badge for cards
already in the collection (cross-referenced via GET /collection).

Settings/Profile — profile card with username, level, XP progress bar;
editable game settings (default match difficulty, preferred formation)
via GET/PUT /settings; confirmation flash on save.

Squad Builder — Squad tab gains a Build/View toggle. Build mode shows
11 starter slots (labelled by position for the selected formation) plus
7 bench slots; clicking any slot opens an inline search-picker filtered
to unused collection cards; GK slot highlighted; filled slots show
player name, OVR, and position with a one-click clear button; Save
Squad POSTs the full squad to Core (validates 11 starters required) then
switches back to View mode and refreshes the chemistry display.

Formations supported: 4-4-2, 4-3-3, 4-2-3-1, 4-1-2-1-2, 3-5-2, 5-3-2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-25 17:31:15 -07:00
parent 67b84abe7c
commit 8087564df1
2 changed files with 405 additions and 10 deletions
+397 -4
View File
@@ -173,6 +173,35 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
.sell-form select, .sell-form input { width: 100%; background: #0d1117; border: 1px solid #30363d;
color: #c9d1d9; padding: 8px 10px; border-radius: 6px; font-size: 0.85rem; margin: 6px 0 12px; }
/* ── Catalog ── */
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(175px, 1fr)); gap: 10px; margin-top: 12px; }
.owned-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 8px;
border: 2px solid #3fb950; pointer-events: none; }
.owned-tag { position: absolute; bottom: 6px; left: 6px; font-size: 0.62rem; background: #14532d;
color: #4ade80; padding: 1px 5px; border-radius: 8px; }
.catalog-count { font-size: 0.8rem; color: #8b949e; margin: 8px 0; }
/* ── Settings ── */
.settings-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 480px; }
.settings-form label { font-size: 0.8rem; color: #8b949e; display: block; margin-bottom: 4px; }
.settings-form select, .settings-form input {
width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
padding: 8px 10px; border-radius: 6px; font-size: 0.85rem; margin-bottom: 14px; }
.profile-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 480px; }
.xp-bar { height: 6px; background: #21262d; border-radius: 3px; margin: 6px 0 14px; }
.xp-fill { height: 100%; border-radius: 3px; background: #1f6feb; }
/* ── Squad Builder ── */
.builder-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin: 12px 0; }
.builder-slot { background: #21262d; border: 1px dashed #30363d; border-radius: 6px; padding: 8px;
min-height: 60px; cursor: pointer; transition: border-color 0.15s; position: relative; }
.builder-slot:hover { border-color: #f5a623; }
.builder-slot.filled { background: #161b22; border-style: solid; border-color: #30363d; }
.builder-slot.gk-slot { border-color: #1f6feb44; }
.slot-label { font-size: 0.68rem; color: #8b949e; text-transform: uppercase; margin-bottom: 4px; }
.slot-clear { position: absolute; top: 4px; right: 6px; font-size: 0.7rem; color: #f85149;
cursor: pointer; line-height: 1; }
/* ── Matches ── */
.match-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 560px; }
.score-row { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
@@ -235,6 +264,8 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<button onclick="showTab('objectives')">Objectives</button>
<button onclick="showTab('division')">Division</button>
<button onclick="showTab('champs')">FUT Champs</button>
<button onclick="showTab('catalog')">Card Catalog</button>
<button onclick="showTab('settings')">Settings</button>
<button onclick="showTab('matches')">Matches</button>
<button onclick="showTab('store')">Pack Store</button>
<button onclick="showTab('sbc')">SBC</button>
@@ -346,6 +377,66 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
</div>
</div>
<!-- ── Card Catalog tab ───────────────────────────────────────────────────── -->
<div id="tab-catalog" class="tab-panel">
<div class="filters">
<input id="cat-name" placeholder="Name…" oninput="filterCatalog()">
<select id="cat-pos" onchange="filterCatalog()">
<option value="">All positions</option>
<option>GK</option><option>CB</option><option>RB</option><option>LB</option>
<option>CDM</option><option>CM</option><option>CAM</option>
<option>RM</option><option>LM</option><option>RW</option><option>LW</option>
<option>CF</option><option>ST</option>
</select>
<select id="cat-rarity" onchange="filterCatalog()">
<option value="">All rarities</option>
<option value="gold">Gold</option><option value="raregold">Rare Gold</option>
<option value="silver">Silver</option><option value="bronze">Bronze</option>
<option value="totw">TOTW</option><option value="icon">Icon</option>
</select>
<input id="cat-nation" placeholder="Nation…" oninput="filterCatalog()">
<input id="cat-league" placeholder="League…" oninput="filterCatalog()">
<input id="cat-club" placeholder="Club…" oninput="filterCatalog()">
<input id="cat-min-ovr" type="number" placeholder="Min OVR" min="1" max="99" oninput="filterCatalog()" style="width:80px">
<input id="cat-max-ovr" type="number" placeholder="Max OVR" min="1" max="99" oninput="filterCatalog()" style="width:80px">
</div>
<div id="catalog-count" class="catalog-count"></div>
<div id="catalog-grid" class="catalog-grid"><span class="loading">Loading…</span></div>
<div style="margin-top:16px;text-align:center">
<button id="catalog-more-btn" class="btn btn-secondary" style="display:none" onclick="catalogLoadMore()">Load more</button>
</div>
</div>
<!-- ── Settings tab ───────────────────────────────────────────────────────── -->
<div id="tab-settings" class="tab-panel">
<div class="two-col">
<div class="section">
<h2>Profile</h2>
<div class="profile-card" id="profile-card"><span class="loading">Loading…</span></div>
</div>
<div class="section">
<h2>Game Settings</h2>
<div class="settings-form">
<label>Default match difficulty</label>
<select id="set-difficulty">
<option value="beginner">Beginner</option>
<option value="professional">Professional</option>
<option value="world_class">World Class</option>
<option value="legendary">Legendary</option>
<option value="ultimate">Ultimate</option>
</select>
<label>Preferred formation</label>
<select id="set-formation">
<option>4-4-2</option><option>4-3-3</option><option>4-2-3-1</option>
<option>4-1-2-1-2</option><option>3-5-2</option><option>5-3-2</option>
</select>
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Save Settings</button>
<div id="settings-saved" style="font-size:0.8rem;color:#3fb950;margin-top:8px;display:none">✓ Saved</div>
</div>
</div>
</div>
</div>
<!-- ── Matches tab ───────────────────────────────────────────────────────── -->
<div id="tab-matches" class="tab-panel">
<div class="two-col">
@@ -424,12 +515,16 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<!-- ── Squad tab ─────────────────────────────────────────────────────────── -->
<div id="tab-squad" class="tab-panel">
<div class="two-col">
<div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px">
<h2 style="border:none;margin:0">Active Squad</h2>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
<h2 style="border:none;margin:0">Squad</h2>
<button class="btn btn-secondary btn-sm" id="squad-view-btn" onclick="squadMode('view')" style="border-color:#f5a623;color:#f5a623">View</button>
<button class="btn btn-secondary btn-sm" id="squad-build-btn" onclick="squadMode('build')">Build</button>
<button class="btn btn-secondary btn-sm" onclick="loadSquad()">Refresh</button>
</div>
<!-- View mode -->
<div id="squad-view-mode">
<div class="two-col">
<div>
<div id="squad-header"></div>
<div id="squad-grid" class="squad-grid"><span class="loading">Loading…</span></div>
</div>
@@ -438,6 +533,43 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<div id="squad-chemistry"><span class="loading">Loading…</span></div>
</div>
</div>
</div>
<!-- Build mode -->
<div id="squad-build-mode" style="display:none">
<div class="two-col" style="margin-bottom:16px">
<div>
<label style="font-size:0.8rem;color:#8b949e">Squad name</label>
<input id="builder-name" placeholder="My Squad" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin:4px 0 12px">
</div>
<div>
<label style="font-size:0.8rem;color:#8b949e">Formation</label>
<select id="builder-formation" onchange="rebuildSlots()" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin:4px 0 12px">
<option>4-4-2</option><option>4-3-3</option><option>4-2-3-1</option>
<option>4-1-2-1-2</option><option>3-5-2</option><option>5-3-2</option>
</select>
</div>
</div>
<h2>Starters (11)</h2>
<div id="builder-starters" class="builder-slots"></div>
<h2 style="margin-top:16px">Bench (up to 7)</h2>
<div id="builder-bench" class="builder-slots"></div>
<div style="margin-top:16px;display:flex;gap:8px">
<button class="btn btn-primary" onclick="saveSquad()">Save Squad</button>
<button class="btn btn-secondary" onclick="clearBuilder()">Clear All</button>
</div>
<!-- Mini card picker overlay -->
<div id="picker-overlay" style="display:none;margin-top:16px">
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;max-width:600px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<strong style="font-size:0.9rem">Pick player for slot <span id="picker-slot-label"></span></strong>
<button class="btn btn-secondary btn-sm" onclick="closePicker()">Cancel</button>
</div>
<input id="picker-search" placeholder="Filter by name…" oninput="renderPicker()"
style="background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin-bottom:10px">
<div id="picker-list" style="max-height:280px;overflow-y:auto;display:grid;gap:4px"></div>
</div>
</div>
</div>
</div>
<!-- ── Draft tab ──────────────────────────────────────────────────────────── -->
@@ -557,6 +689,7 @@ function showTab(name) {
champs: loadChamps, squad: loadSquad, draft: loadDraft,
market: loadMarket, events: loadEvents, matches: loadMatches,
store: loadStore, sbc: loadSbc, statistics: loadStatistics,
catalog: loadCatalog, settings: loadSettings,
notifications: loadNotifications })[name]?.();
}
@@ -1265,6 +1398,117 @@ async function loadStatistics() {
} catch (e) { el('stats-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
// ── Card Catalog ──────────────────────────────────────────────────────────────
let catalogCards = [];
let catalogOwned = new Set();
let catalogLimit = 80;
async function loadCatalog() {
el('catalog-grid').innerHTML = '<span class="loading">Loading…</span>';
try {
const [cardsData, collData] = await Promise.all([
api('GET', '/cards'),
api('GET', '/collection').catch(() => ({ collection: [] })),
]);
catalogCards = cardsData.cards || [];
catalogOwned = new Set((collData.collection || []).map(i => i.card?.id).filter(Boolean));
catalogLimit = 80;
filterCatalog();
} catch (e) { el('catalog-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
}
function filterCatalog() {
const name = (el('cat-name')?.value || '').toLowerCase();
const pos = el('cat-pos')?.value || '';
const rar = el('cat-rarity')?.value || '';
const nat = (el('cat-nation')?.value || '').toLowerCase();
const lea = (el('cat-league')?.value || '').toLowerCase();
const club = (el('cat-club')?.value || '').toLowerCase();
const minO = parseInt(el('cat-min-ovr')?.value || '0', 10) || 0;
const maxO = parseInt(el('cat-max-ovr')?.value || '99', 10) || 99;
const filtered = catalogCards.filter(c => {
if (name && !c.name.toLowerCase().includes(name)) return false;
if (pos && c.position !== pos) return false;
if (rar && (c.rarity||'').toLowerCase() !== rar) return false;
if (nat && !c.nation.toLowerCase().includes(nat)) return false;
if (lea && !c.league.toLowerCase().includes(lea)) return false;
if (club && !c.club.toLowerCase().includes(club)) return false;
if (c.overall < minO || c.overall > maxO) return false;
return true;
});
el('catalog-count').textContent = `${filtered.length} card${filtered.length !== 1 ? 's' : ''} in catalog`;
const shown = filtered.slice(0, catalogLimit);
el('catalog-grid').innerHTML = shown.map(c => {
const owned = catalogOwned.has(c.id);
return `<div class="player-card" style="position:relative" title="${c.club} · ${c.league} · ${c.nation}">
${owned ? '<div class="owned-overlay"></div><span class="owned-tag">Owned</span>' : ''}
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
<div class="card-ovr">${c.overall}</div>
<div class="card-pos">${c.position}</div>
<div class="card-name">${c.name}</div>
<div class="card-meta">${c.club}</div>
<div style="margin:6px 0 4px">
${statBar('PAC',c.pace)}${statBar('SHO',c.shooting)}${statBar('PAS',c.passing)}
${statBar('DRI',c.dribbling)}${statBar('DEF',c.defending)}${statBar('PHY',c.physical)}
</div>
</div>`;
}).join('');
const moreBtn = el('catalog-more-btn');
if (filtered.length > catalogLimit) {
moreBtn.style.display = '';
moreBtn.textContent = `Load more (${filtered.length - catalogLimit} remaining)`;
} else {
moreBtn.style.display = 'none';
}
}
function catalogLoadMore() {
catalogLimit += 80;
filterCatalog();
}
// ── Settings & Profile ────────────────────────────────────────────────────────
async function loadSettings() {
try {
const [profile, settings] = await Promise.all([
api('GET', '/profile'),
api('GET', '/settings'),
]);
const xpForNext = 1000;
const xpPct = Math.min(100, Math.round(((profile.xp || 0) % xpForNext) / xpForNext * 100));
el('profile-card').innerHTML = `
<div class="info-row"><span class="lbl">Username</span><strong>${profile.username || '—'}</strong></div>
<div class="info-row"><span class="lbl">Level</span><strong>${profile.level ?? 1}</strong></div>
<div class="info-row"><span class="lbl">XP</span>${(profile.xp || 0).toLocaleString()}</div>
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
<div style="font-size:0.75rem;color:#8b949e">Profile ID: <code>${profile.id}</code></div>
`;
const diffSel = el('set-difficulty');
const frmSel = el('set-formation');
if (diffSel) diffSel.value = settings.difficulty || 'beginner';
if (frmSel) frmSel.value = settings.preferred_formation || '4-4-2';
} catch (e) {
el('profile-card').innerHTML = `<div class="error-msg">${e.message}</div>`;
}
}
async function saveSettings() {
const difficulty = el('set-difficulty').value;
const preferred_formation = el('set-formation').value;
try {
await api('PUT', '/settings', { difficulty, preferred_formation });
const saved = el('settings-saved');
saved.style.display = '';
setTimeout(() => { saved.style.display = 'none'; }, 2000);
} catch (e) { showToast(e.message, true); }
}
// ── Squad ─────────────────────────────────────────────────────────────────────
async function loadSquad() {
@@ -1321,6 +1565,155 @@ async function loadSquad() {
}
}
// ── Squad Builder ────────────────────────────────────────────────────────────
// Formation → position labels for each of the 11 starter slots
const FORMATION_POSITIONS = {
'4-4-2': ['GK','RB','CB','CB','LB','RM','CM','CM','LM','ST','ST'],
'4-3-3': ['GK','RB','CB','CB','LB','CM','CM','CM','RW','ST','LW'],
'4-2-3-1': ['GK','RB','CB','CB','LB','CDM','CDM','CAM','RW','LW','ST'],
'4-1-2-1-2':['GK','RB','CB','CB','LB','CDM','CM','CM','CAM','ST','ST'],
'3-5-2': ['GK','CB','CB','CB','RM','CDM','CM','LM','CAM','ST','ST'],
'5-3-2': ['GK','RB','CB','CB','CB','LB','CM','CM','CM','ST','ST'],
};
const BENCH_LABELS = ['SUB 1','SUB 2','SUB 3','SUB 4','SUB 5','SUB 6','SUB 7'];
// Builder state: slot index → owned card id
const builderStarters = {};
const builderBench = {};
let pickerTargetSlot = null; // { type:'starter'|'bench', index }
function squadMode(mode) {
el('squad-view-mode').style.display = mode === 'view' ? '' : 'none';
el('squad-build-mode').style.display = mode === 'build' ? '' : 'none';
el('squad-view-btn').style.borderColor = mode === 'view' ? '#f5a623' : '';
el('squad-view-btn').style.color = mode === 'view' ? '#f5a623' : '';
el('squad-build-btn').style.borderColor = mode === 'build' ? '#f5a623' : '';
el('squad-build-btn').style.color = mode === 'build' ? '#f5a623' : '';
if (mode === 'build') {
rebuildSlots();
if (!allCards.length) {
api('GET', '/collection').then(d => { allCards = d.collection || []; }).catch(() => {});
}
}
}
function rebuildSlots() {
const formation = el('builder-formation')?.value || '4-4-2';
const positions = FORMATION_POSITIONS[formation] || FORMATION_POSITIONS['4-4-2'];
el('builder-starters').innerHTML = positions.map((pos, i) => slotHtml('starter', i, pos)).join('');
el('builder-bench').innerHTML = BENCH_LABELS.map((lbl, i) => slotHtml('bench', i, lbl)).join('');
}
function slotHtml(type, index, label) {
const store = type === 'starter' ? builderStarters : builderBench;
const ownedId = store[index];
const item = ownedId ? allCards.find(c => c.owned_card_id === ownedId) : null;
const isGk = label === 'GK';
if (item) {
const c = item.card;
const ovr = item.effective_overall ?? c.overall;
return `<div class="builder-slot filled${isGk ? ' gk-slot' : ''}" onclick="openPicker('${type}',${index},'${label}')">
<div class="slot-label">${label}</div>
<span class="slot-clear" onclick="event.stopPropagation();clearSlot('${type}',${index})">✕</span>
<div style="font-size:0.75rem;font-weight:700;color:#f5a623">${ovr}</div>
<div style="font-size:0.78rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</div>
<div style="font-size:0.65rem;color:#8b949e">${c.position}</div>
</div>`;
}
return `<div class="builder-slot${isGk ? ' gk-slot' : ''}" onclick="openPicker('${type}',${index},'${label}')">
<div class="slot-label">${label}</div>
<div style="color:#8b949e;font-size:0.8rem;margin-top:8px">+ Pick player</div>
</div>`;
}
function openPicker(type, index, label) {
pickerTargetSlot = { type, index };
el('picker-slot-label').textContent = label;
el('picker-search').value = '';
el('picker-overlay').style.display = '';
renderPicker();
}
function closePicker() {
el('picker-overlay').style.display = 'none';
pickerTargetSlot = null;
}
function renderPicker() {
const q = el('picker-search').value.toLowerCase();
const used = new Set([
...Object.values(builderStarters),
...Object.values(builderBench),
]);
const cards = allCards
.filter(i => !i.is_loan && !used.has(i.owned_card_id))
.filter(i => !q || i.card.name.toLowerCase().includes(q))
.sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall))
.slice(0, 60);
el('picker-list').innerHTML = cards.map(i => {
const c = i.card;
const ovr = i.effective_overall ?? c.overall;
return `<div onclick="pickSlotCard('${i.owned_card_id}')" style="
display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;
background:#21262d;cursor:pointer;font-size:0.82rem"
onmouseover="this.style.background='#30363d'" onmouseout="this.style.background='#21262d'">
<span style="font-weight:700;color:#f5a623;width:28px;text-align:right">${ovr}</span>
<span style="font-size:0.65rem;color:#8b949e;width:28px;text-transform:uppercase">${c.position}</span>
<span style="flex:1;font-weight:600">${c.name}</span>
<span style="font-size:0.7rem;color:#8b949e">${c.club}</span>
</div>`;
}).join('') || '<span class="empty">No matching cards.</span>';
}
function pickSlotCard(ownedId) {
if (!pickerTargetSlot) return;
const { type, index } = pickerTargetSlot;
(type === 'starter' ? builderStarters : builderBench)[index] = ownedId;
closePicker();
rebuildSlots();
}
function clearSlot(type, index) {
delete (type === 'starter' ? builderStarters : builderBench)[index];
rebuildSlots();
}
function clearBuilder() {
Object.keys(builderStarters).forEach(k => delete builderStarters[k]);
Object.keys(builderBench).forEach(k => delete builderBench[k]);
rebuildSlots();
}
async function saveSquad() {
const formation = el('builder-formation').value;
const name = el('builder-name').value.trim() || 'My Squad';
const positions = FORMATION_POSITIONS[formation] || FORMATION_POSITIONS['4-4-2'];
const players = [];
for (let i = 0; i < positions.length; i++) {
if (builderStarters[i]) {
players.push({ owned_card_id: builderStarters[i], position_index: i, is_captain: i === 0, is_on_bench: false });
}
}
for (let i = 0; i < BENCH_LABELS.length; i++) {
if (builderBench[i]) {
players.push({ owned_card_id: builderBench[i], position_index: 11 + i, is_captain: false, is_on_bench: true });
}
}
if (players.filter(p => !p.is_on_bench).length !== 11) {
showToast('Fill all 11 starter slots before saving', true);
return;
}
try {
await api('POST', '/squad', { name, formation, players });
showToast(`Squad "${name}" saved!`);
squadMode('view');
loadSquad();
} catch (e) { showToast(e.message, true); }
}
// ── Draft ─────────────────────────────────────────────────────────────────────
let activeDraftSession = null;
+2
View File
@@ -248,6 +248,8 @@ async fn test_dashboard_contains_key_sections() {
assert!(html.contains("tab-store"), "pack store tab missing");
assert!(html.contains("tab-sbc"), "sbc tab missing");
assert!(html.contains("tab-statistics"), "statistics tab missing");
assert!(html.contains("tab-catalog"), "card catalog tab missing");
assert!(html.contains("tab-settings"), "settings tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
}