Files
FFCardGame/tools/card_reviewer.py

998 lines
39 KiB
Python

#!/usr/bin/env python3
"""
FFTCG Card Data Reviewer — Local web tool for browsing and editing scanned card data.
Usage:
python tools/card_reviewer.py # Start on port 8080
python tools/card_reviewer.py --port 9000 # Custom port
Opens a browser showing each card's image alongside its detected data.
Edit fields and save changes back to data/cards.json.
"""
import argparse
import json
import os
import sys
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
PROJECT_ROOT = Path(__file__).parent.parent
CARDS_FILE = PROJECT_ROOT / "data" / "cards.json"
REVIEWED_FILE = PROJECT_ROOT / "data" / "reviewed.json"
SOURCE_CARDS_DIR = PROJECT_ROOT / "source-cards"
# Load cards into memory
cards_data = None
reviewed_data = None
def load_cards():
global cards_data
with open(CARDS_FILE, "r") as f:
cards_data = json.load(f)
def save_cards():
with open(CARDS_FILE, "w") as f:
json.dump(cards_data, f, indent=2, ensure_ascii=False)
def load_reviewed():
global reviewed_data
if REVIEWED_FILE.exists():
with open(REVIEWED_FILE, "r") as f:
reviewed_data = json.load(f)
else:
reviewed_data = {"reviewed": []}
def save_reviewed():
with open(REVIEWED_FILE, "w") as f:
json.dump(reviewed_data, f, indent=2, ensure_ascii=False)
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FFTCG Card Reviewer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; height: 100vh; display: flex; flex-direction: column; }
/* Top bar */
.topbar { background: #16213e; padding: 8px 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; border-bottom: 1px solid #333; }
.topbar label { font-size: 12px; color: #888; }
.topbar select, .topbar input { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
.topbar select:focus, .topbar input:focus { outline: none; border-color: #e94560; }
.filter-group { display: flex; align-items: center; gap: 4px; }
.search-input { width: 160px; }
.card-counter { margin-left: auto; font-size: 13px; color: #888; }
/* Main area — grid */
.main { flex: 1; overflow-y: auto; padding: 16px; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; }
.grid-card { cursor: pointer; border-radius: 8px; overflow: hidden; background: #16213e; border: 2px solid transparent; transition: border-color 0.15s, transform 0.15s; position: relative; }
.grid-card:hover { border-color: #e94560; transform: translateY(-2px); }
.grid-card img { width: 100%; display: block; }
.grid-card .grid-label { padding: 6px 8px; font-size: 11px; line-height: 1.3; }
.grid-card .grid-id { color: #e94560; font-weight: bold; }
.grid-card .grid-name { color: #ccc; }
.grid-card .grid-meta { color: #666; font-size: 10px; margin-top: 2px; }
/* Pagination */
.pagination { background: #16213e; padding: 10px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; border-top: 1px solid #333; }
.page-btn { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; }
.page-btn:hover { background: #e94560; border-color: #e94560; }
.page-btn:disabled { background: #222; color: #555; cursor: not-allowed; border-color: #333; }
.page-btn.active { background: #e94560; border-color: #e94560; font-weight: bold; }
.page-info { font-size: 13px; color: #888; margin: 0 8px; }
.page-size-group { margin-left: 20px; display: flex; align-items: center; gap: 4px; }
.page-size-group label { font-size: 12px; color: #888; }
.page-size-group select { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 13px; }
/* Modal overlay */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #1a1a2e; border: 1px solid #333; border-radius: 12px; width: 90vw; max-width: 900px; max-height: 90vh; display: flex; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }
/* Modal left — card image */
.modal-image { width: 360px; min-width: 360px; display: flex; align-items: center; justify-content: center; padding: 16px; background: #0f0f23; }
.modal-image img { max-width: 100%; max-height: calc(90vh - 40px); object-fit: contain; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
/* Modal right — editor */
.modal-editor { flex: 1; overflow-y: auto; padding: 20px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.card-id-display { font-size: 22px; font-weight: bold; color: #e94560; }
.close-btn { background: none; border: 1px solid #555; color: #aaa; font-size: 20px; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.close-btn:hover { background: #e94560; color: white; border-color: #e94560; }
.field-group { margin-bottom: 12px; }
.field-group label { display: block; font-size: 11px; text-transform: uppercase; color: #666; margin-bottom: 3px; letter-spacing: 0.5px; }
.field-group input, .field-group select { width: 100%; background: #16213e; color: #e0e0e0; border: 1px solid #333; padding: 8px 10px; border-radius: 4px; font-size: 14px; }
.field-group input:focus, .field-group select:focus { outline: none; border-color: #e94560; }
.field-row { display: flex; gap: 12px; }
.field-row .field-group { flex: 1; }
.checkbox-group { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.checkbox-group input[type="checkbox"] { width: 18px; height: 18px; accent-color: #e94560; }
.checkbox-group label { font-size: 13px; color: #ccc; }
.abilities-section { margin-top: 16px; }
.abilities-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.abilities-header h3 { font-size: 13px; color: #888; text-transform: uppercase; }
.add-ability-btn { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.add-ability-btn:hover { background: #e94560; border-color: #e94560; }
.ability-card { background: #16213e; border: 1px solid #333; border-radius: 6px; padding: 10px; margin-bottom: 8px; position: relative; }
.ability-card .ab-top-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.ability-card .ab-top-row select, .ability-card .ab-top-row input { background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.ability-card .ab-top-row select { width: 90px; }
.ability-card .ab-top-row input[type="text"] { flex: 1; }
.ability-card .ab-top-row .ab-ex-label { font-size: 11px; color: #888; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.ability-card .ab-top-row .ab-ex-label input { width: 14px; height: 14px; accent-color: #e94560; }
.ability-card .ab-remove { background: none; border: none; color: #666; cursor: pointer; font-size: 16px; padding: 0 4px; line-height: 1; }
.ability-card .ab-remove:hover { color: #e94560; }
.ability-card .ab-field { margin-top: 4px; }
.ability-card .ab-field label { display: block; font-size: 10px; color: #555; text-transform: uppercase; margin-bottom: 2px; }
.ability-card .ab-field input, .ability-card .ab-field textarea { width: 100%; background: #0f3460; color: #e0e0e0; border: 1px solid #444; padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; }
.ability-card .ab-field textarea { resize: vertical; min-height: 50px; }
/* Modal nav bar */
.modal-nav { display: flex; align-items: center; gap: 12px; margin-top: 16px; padding-top: 12px; border-top: 1px solid #333; }
.nav-btn { background: #e94560; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: bold; }
.nav-btn:hover { background: #c73650; }
.nav-btn:disabled { background: #444; cursor: not-allowed; }
.save-btn { background: #0f9b58; }
.save-btn:hover { background: #0d8a4d; }
.save-btn.saved { background: #666; }
.nav-spacer { flex: 1; }
.status-msg { font-size: 12px; color: #0f9b58; transition: opacity 0.5s; }
/* Null power toggle */
.null-toggle { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.null-toggle label { font-size: 11px; color: #888; }
/* Element color pills on grid cards */
.elem-pill { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; margin-right: 3px; }
.elem-Fire { background: #c0392b; color: #fff; }
.elem-Ice { background: #2980b9; color: #fff; }
.elem-Wind { background: #27ae60; color: #fff; }
.elem-Earth { background: #d4a017; color: #fff; }
.elem-Lightning { background: #8e44ad; color: #fff; }
.elem-Water { background: #1abc9c; color: #fff; }
.elem-Light { background: #f1c40f; color: #333; }
.elem-Dark { background: #2c3e50; color: #ccc; }
/* Grid card badges */
.grid-badge { position: absolute; top: 4px; right: 4px; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; z-index: 1; }
.badge-issue { background: #e94560; color: #fff; }
.badge-reviewed { background: #0f9b58; color: #fff; }
.grid-card.has-issue { border-color: #e94560; }
/* Multi-element editor */
.multi-elem { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.multi-elem label { display: flex; align-items: center; gap: 3px; font-size: 12px; cursor: pointer; padding: 3px 8px; border-radius: 4px; border: 1px solid #333; background: #0f3460; }
.multi-elem label:hover { border-color: #e94560; }
.multi-elem input[type="checkbox"] { width: 14px; height: 14px; accent-color: #e94560; }
.multi-elem label.checked { border-color: #e94560; background: #2a1040; }
/* Reviewed toggle in modal */
.reviewed-toggle { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 8px 12px; background: #0f3460; border-radius: 6px; }
.reviewed-toggle input { width: 18px; height: 18px; accent-color: #0f9b58; }
.reviewed-toggle label { font-size: 13px; color: #ccc; }
.reviewed-toggle .review-count { margin-left: auto; font-size: 11px; color: #666; }
</style>
</head>
<body>
<!-- Top filter bar -->
<div class="topbar">
<div class="filter-group">
<label>Set:</label>
<select id="filterSet"><option value="">All</option></select>
</div>
<div class="filter-group">
<label>Element:</label>
<select id="filterElement">
<option value="">All</option>
<option>Fire</option><option>Ice</option><option>Wind</option><option>Earth</option>
<option>Lightning</option><option>Water</option><option>Light</option><option>Dark</option>
</select>
</div>
<div class="filter-group">
<label>Type:</label>
<select id="filterType">
<option value="">All</option>
<option>Forward</option><option>Backup</option><option>Summon</option><option>Monster</option>
</select>
</div>
<div class="filter-group">
<label>Category:</label>
<select id="filterCategory"><option value="">All</option></select>
</div>
<div class="filter-group">
<label>Issues:</label>
<select id="filterIssues">
<option value="">All</option>
<option value="any">Has Issues</option>
<option value="fwd_no_power">Forward w/o Power</option>
<option value="backup_power">Backup/Summon w/ Power</option>
<option value="no_abilities">No Abilities</option>
<option value="no_cost">No Cost</option>
</select>
</div>
<div class="filter-group">
<label>Reviewed:</label>
<select id="filterReviewed">
<option value="">All</option>
<option value="no">Not Reviewed</option>
<option value="yes">Reviewed</option>
</select>
</div>
<div class="filter-group">
<label>Search:</label>
<input type="text" id="filterSearch" class="search-input" placeholder="Name, job, ID...">
</div>
<span class="card-counter" id="cardCounter">0 cards</span>
</div>
<!-- Main grid area -->
<div class="main" id="mainArea">
<div class="card-grid" id="cardGrid"></div>
</div>
<!-- Pagination -->
<div class="pagination" id="paginationBar">
<button class="page-btn" id="pageFirst" title="First page">&laquo;</button>
<button class="page-btn" id="pagePrev">&lsaquo; Prev</button>
<span class="page-info" id="pageInfo">Page 1 / 1</span>
<button class="page-btn" id="pageNext">Next &rsaquo;</button>
<button class="page-btn" id="pageLast" title="Last page">&raquo;</button>
<div class="page-size-group">
<label>Per page:</label>
<select id="pageSize">
<option value="24">24</option>
<option value="48" selected>48</option>
<option value="96">96</option>
<option value="192">192</option>
</select>
</div>
</div>
<!-- Detail/edit modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-image">
<img id="cardImage" src="" alt="Card Image">
</div>
<div class="modal-editor">
<div class="modal-header">
<div class="card-id-display" id="cardId"></div>
<button class="close-btn" id="modalClose" title="Escape">&times;</button>
</div>
<div class="field-group">
<label>Name</label>
<input type="text" id="fieldName" data-field="name">
</div>
<div class="field-row">
<div class="field-group">
<label>Type</label>
<select id="fieldType" data-field="type">
<option>Forward</option><option>Backup</option><option>Summon</option><option>Monster</option>
</select>
</div>
<div class="field-group">
<label>Element(s)</label>
<div class="multi-elem" id="elemCheckboxes">
<label><input type="checkbox" value="Fire"> Fire</label>
<label><input type="checkbox" value="Ice"> Ice</label>
<label><input type="checkbox" value="Wind"> Wind</label>
<label><input type="checkbox" value="Earth"> Earth</label>
<label><input type="checkbox" value="Lightning"> Lightning</label>
<label><input type="checkbox" value="Water"> Water</label>
<label><input type="checkbox" value="Light"> Light</label>
<label><input type="checkbox" value="Dark"> Dark</label>
</div>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label>Cost</label>
<input type="number" id="fieldCost" data-field="cost" min="0" max="20">
</div>
<div class="field-group">
<label>Power</label>
<input type="number" id="fieldPower" data-field="power" min="0" step="1000">
<div class="null-toggle">
<input type="checkbox" id="powerNull">
<label for="powerNull">No power (null)</label>
</div>
</div>
</div>
<div class="field-row">
<div class="field-group">
<label>Job</label>
<input type="text" id="fieldJob" data-field="job">
</div>
<div class="field-group">
<label>Category</label>
<input type="text" id="fieldCategory" data-field="category">
</div>
</div>
<div style="display:flex; gap: 24px; margin: 12px 0;">
<div class="checkbox-group">
<input type="checkbox" id="fieldGeneric" data-field="is_generic">
<label for="fieldGeneric">Generic</label>
</div>
<div class="checkbox-group">
<input type="checkbox" id="fieldExBurst" data-field="has_ex_burst">
<label for="fieldExBurst">EX Burst</label>
</div>
</div>
<div class="abilities-section">
<div class="abilities-header">
<h3>Abilities</h3>
<button class="add-ability-btn" id="btnAddAbility">+ Add Ability</button>
</div>
<div id="abilitiesList"></div>
</div>
<div class="reviewed-toggle">
<input type="checkbox" id="fieldReviewed">
<label for="fieldReviewed">Reviewed</label>
<span class="review-count" id="reviewCount"></span>
</div>
<div class="modal-nav">
<button class="nav-btn" id="btnPrev" title="Left Arrow">&larr; Prev</button>
<button class="nav-btn" id="btnNext" title="Right Arrow">Next &rarr;</button>
<span class="nav-spacer"></span>
<span class="status-msg" id="statusMsg"></span>
<button class="nav-btn save-btn" id="btnSave" title="Ctrl+S">Save</button>
</div>
</div>
</div>
</div>
<script>
let allCards = [];
let filteredCards = [];
let currentPage = 0;
let pageSize = 48;
let modalCardIndex = -1; // index within filteredCards
let hasUnsavedChanges = false;
let reviewedSet = new Set(); // card IDs that have been reviewed
function hasIssues(card, which) {
if (which === 'fwd_no_power') return card.type === 'Forward' && !card.power;
if (which === 'backup_power') return (card.type === 'Backup' || card.type === 'Summon') && card.power;
if (which === 'no_abilities') return !card.abilities || card.abilities.length === 0;
if (which === 'no_cost') return !card.cost && card.cost !== 0;
// 'any' — check all
return (card.type === 'Forward' && !card.power) ||
((card.type === 'Backup' || card.type === 'Summon') && card.power) ||
(!card.abilities || card.abilities.length === 0) ||
(!card.cost && card.cost !== 0);
}
async function init() {
const resp = await fetch('/api/cards');
const data = await resp.json();
allCards = data.cards;
// Load reviewed state
try {
const rResp = await fetch('/api/reviewed');
const rData = await rResp.json();
reviewedSet = new Set(rData.reviewed || []);
} catch(e) { reviewedSet = new Set(); }
populateSetFilter();
populateCategoryFilter();
updateReviewCount();
applyFilters();
}
function updateReviewCount() {
const el = document.getElementById('reviewCount');
if (el) el.textContent = reviewedSet.size + ' / ' + allCards.length + ' reviewed';
}
function populateSetFilter() {
const sets = new Set();
allCards.forEach(c => sets.add(c.id.split('-')[0]));
const sel = document.getElementById('filterSet');
[...sets].sort((a, b) => {
const aNum = parseInt(a), bNum = parseInt(b);
if (!isNaN(aNum) && !isNaN(bNum)) return aNum - bNum;
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
return a.localeCompare(b);
}).forEach(s => {
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
sel.appendChild(opt);
});
}
function populateCategoryFilter() {
const cats = new Set();
allCards.forEach(c => { if (c.category) cats.add(c.category); });
const sel = document.getElementById('filterCategory');
[...cats].sort().forEach(c => {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
sel.appendChild(opt);
});
}
function applyFilters() {
const setVal = document.getElementById('filterSet').value;
const elemVal = document.getElementById('filterElement').value;
const typeVal = document.getElementById('filterType').value;
const catVal = document.getElementById('filterCategory').value;
const issuesVal = document.getElementById('filterIssues').value;
const reviewedVal = document.getElementById('filterReviewed').value;
const searchVal = document.getElementById('filterSearch').value.toLowerCase();
filteredCards = allCards.filter(c => {
if (setVal && c.id.split('-')[0] !== setVal) return false;
if (elemVal) {
const el = Array.isArray(c.element) ? c.element : [c.element];
if (!el.includes(elemVal)) return false;
}
if (typeVal && c.type !== typeVal) return false;
if (catVal && c.category !== catVal) return false;
if (issuesVal && !hasIssues(c, issuesVal)) return false;
if (reviewedVal === 'yes' && !reviewedSet.has(c.id)) return false;
if (reviewedVal === 'no' && reviewedSet.has(c.id)) return false;
if (searchVal) {
const hay = [c.name, c.job, c.category, c.id].join(' ').toLowerCase();
if (!hay.includes(searchVal)) return false;
}
return true;
});
currentPage = 0;
updateCounter();
renderGrid();
updatePagination();
}
function updateCounter() {
document.getElementById('cardCounter').textContent = filteredCards.length + ' cards';
}
function totalPages() {
return Math.max(1, Math.ceil(filteredCards.length / pageSize));
}
function renderGrid() {
const grid = document.getElementById('cardGrid');
grid.innerHTML = '';
const start = currentPage * pageSize;
const end = Math.min(start + pageSize, filteredCards.length);
for (let i = start; i < end; i++) {
const card = filteredCards[i];
const div = document.createElement('div');
div.className = 'grid-card';
div.dataset.index = i;
const elems = Array.isArray(card.element) ? card.element : [card.element];
const pills = elems.map(e => `<span class="elem-pill elem-${e}">${e}</span>`).join('');
const isIssue = hasIssues(card, 'any');
const isReviewed = reviewedSet.has(card.id);
if (isIssue) div.classList.add('has-issue');
let badges = '';
if (isIssue) badges += '<span class="grid-badge badge-issue">!</span>';
if (isReviewed) badges += '<span class="grid-badge badge-reviewed" style="' + (isIssue ? 'top:22px' : '') + '">OK</span>';
div.innerHTML = `
${badges}
<img src="/images/${card.image}" alt="${card.name}" loading="lazy">
<div class="grid-label">
<div class="grid-id">${card.id}</div>
<div class="grid-name">${card.name || ''}</div>
<div class="grid-meta">${card.type} ${pills} ${card.power ? card.power/1000 + 'k' : ''}</div>
</div>
`;
div.addEventListener('click', () => openModal(i));
grid.appendChild(div);
}
// Scroll to top of grid
document.getElementById('mainArea').scrollTop = 0;
}
function updatePagination() {
const total = totalPages();
document.getElementById('pageInfo').textContent = `Page ${currentPage + 1} / ${total}`;
document.getElementById('pageFirst').disabled = (currentPage <= 0);
document.getElementById('pagePrev').disabled = (currentPage <= 0);
document.getElementById('pageNext').disabled = (currentPage >= total - 1);
document.getElementById('pageLast').disabled = (currentPage >= total - 1);
}
function goToPage(page) {
const total = totalPages();
page = Math.max(0, Math.min(page, total - 1));
if (page === currentPage) return;
currentPage = page;
renderGrid();
updatePagination();
}
// --- Modal / Detail editor ---
function openModal(filteredIndex) {
modalCardIndex = filteredIndex;
showCardInModal(filteredIndex);
document.getElementById('modalOverlay').classList.add('open');
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('open');
modalCardIndex = -1;
hasUnsavedChanges = false;
}
function showCardInModal(index) {
if (index < 0 || index >= filteredCards.length) return;
modalCardIndex = index;
const card = filteredCards[index];
document.getElementById('cardImage').src = '/images/' + card.image;
document.getElementById('cardId').textContent = card.id;
document.getElementById('fieldName').value = card.name || '';
document.getElementById('fieldType').value = card.type || 'Forward';
// Multi-element checkboxes
const elems = Array.isArray(card.element) ? card.element : [card.element || 'Fire'];
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]').forEach(cb => {
cb.checked = elems.includes(cb.value);
cb.parentElement.classList.toggle('checked', cb.checked);
});
document.getElementById('fieldCost').value = card.cost || 0;
const powerInput = document.getElementById('fieldPower');
const powerNull = document.getElementById('powerNull');
if (card.power === null || card.power === undefined) {
powerInput.value = '';
powerInput.disabled = true;
powerNull.checked = true;
} else {
powerInput.value = card.power;
powerInput.disabled = false;
powerNull.checked = false;
}
document.getElementById('fieldJob').value = card.job || '';
document.getElementById('fieldCategory').value = card.category || '';
document.getElementById('fieldGeneric').checked = !!card.is_generic;
document.getElementById('fieldExBurst').checked = !!card.has_ex_burst;
const abList = document.getElementById('abilitiesList');
abList.innerHTML = '';
(card.abilities || []).forEach(ab => {
abList.appendChild(renderAbilityCard(ab));
});
// Reviewed state
document.getElementById('fieldReviewed').checked = reviewedSet.has(card.id);
updateReviewCount();
hasUnsavedChanges = false;
document.getElementById('btnSave').classList.remove('saved');
document.getElementById('statusMsg').textContent = '';
document.getElementById('btnPrev').disabled = (index <= 0);
document.getElementById('btnNext').disabled = (index >= filteredCards.length - 1);
}
function renderAbilityCard(ab) {
const div = document.createElement('div');
div.className = 'ability-card';
const esc = s => (s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
div.innerHTML = `
<div class="ab-top-row">
<select class="ab-type">
<option value="field"${ab.type==='field'?' selected':''}>Field</option>
<option value="auto"${ab.type==='auto'?' selected':''}>Auto</option>
<option value="action"${ab.type==='action'?' selected':''}>Action</option>
<option value="special"${ab.type==='special'?' selected':''}>Special</option>
</select>
<input type="text" class="ab-name" value="${esc(ab.name)}" placeholder="Ability name">
<label class="ab-ex-label"><input type="checkbox" class="ab-ex-burst"${ab.is_ex_burst?' checked':''}> EX</label>
<button class="ab-remove" title="Remove ability">&times;</button>
</div>
<div class="ab-field">
<label>Trigger</label>
<input type="text" class="ab-trigger" value="${esc(ab.trigger)}" placeholder="e.g. When this card enters the field...">
</div>
<div class="ab-field">
<label>Effect</label>
<textarea class="ab-effect" placeholder="Effect text...">${esc(ab.effect)}</textarea>
</div>
`;
div.querySelector('.ab-remove').addEventListener('click', () => {
div.remove();
hasUnsavedChanges = true;
});
div.querySelectorAll('input, select, textarea').forEach(el => {
el.addEventListener('input', () => { hasUnsavedChanges = true; });
el.addEventListener('change', () => { hasUnsavedChanges = true; });
});
return div;
}
function getAbilitiesFromDOM() {
const cards = document.querySelectorAll('#abilitiesList .ability-card');
return Array.from(cards).map(div => ({
type: div.querySelector('.ab-type').value,
name: div.querySelector('.ab-name').value,
trigger: div.querySelector('.ab-trigger').value,
effect: div.querySelector('.ab-effect').value,
is_ex_burst: div.querySelector('.ab-ex-burst').checked,
}));
}
function getSelectedElements() {
const checked = [];
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]:checked').forEach(cb => {
checked.push(cb.value);
});
// Return string if single element, array if multi
if (checked.length === 1) return checked[0];
if (checked.length > 1) return checked;
return 'Fire'; // fallback
}
function getCurrentEdits() {
const card = filteredCards[modalCardIndex];
if (!card) return null;
const powerNull = document.getElementById('powerNull').checked;
return {
id: card.id,
name: document.getElementById('fieldName').value,
type: document.getElementById('fieldType').value,
element: getSelectedElements(),
cost: parseInt(document.getElementById('fieldCost').value) || 0,
power: powerNull ? null : (parseInt(document.getElementById('fieldPower').value) || null),
job: document.getElementById('fieldJob').value,
category: document.getElementById('fieldCategory').value,
is_generic: document.getElementById('fieldGeneric').checked,
has_ex_burst: document.getElementById('fieldExBurst').checked,
abilities: getAbilitiesFromDOM(),
image: card.image,
};
}
async function saveCard() {
const edits = getCurrentEdits();
if (!edits) return;
const resp = await fetch('/api/cards/' + encodeURIComponent(edits.id), {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(edits),
});
if (resp.ok) {
const idx = allCards.findIndex(c => c.id === edits.id);
if (idx >= 0) allCards[idx] = edits;
const fIdx = filteredCards.findIndex(c => c.id === edits.id);
if (fIdx >= 0) filteredCards[fIdx] = edits;
// Update the grid card if visible
const gridCard = document.querySelector(`.grid-card[data-index="${modalCardIndex}"]`);
if (gridCard) {
const elems = Array.isArray(edits.element) ? edits.element : [edits.element];
const pills = elems.map(e => `<span class="elem-pill elem-${e}">${e}</span>`).join('');
gridCard.querySelector('.grid-name').textContent = edits.name || '';
gridCard.querySelector('.grid-meta').innerHTML = `${edits.type} ${pills} ${edits.power ? edits.power/1000 + 'k' : ''}`;
}
hasUnsavedChanges = false;
document.getElementById('btnSave').classList.add('saved');
document.getElementById('statusMsg').textContent = 'Saved!';
setTimeout(() => { document.getElementById('statusMsg').textContent = ''; }, 2000);
} else {
document.getElementById('statusMsg').textContent = 'Save failed!';
document.getElementById('statusMsg').style.color = '#e94560';
}
}
// Power null toggle
document.getElementById('powerNull').addEventListener('change', function() {
const powerInput = document.getElementById('fieldPower');
if (this.checked) {
powerInput.value = '';
powerInput.disabled = true;
} else {
powerInput.disabled = false;
powerInput.focus();
}
hasUnsavedChanges = true;
});
// Track changes
document.querySelectorAll('[data-field]').forEach(el => {
el.addEventListener('input', () => { hasUnsavedChanges = true; });
el.addEventListener('change', () => { hasUnsavedChanges = true; });
});
// Multi-element checkbox styling + change tracking
document.querySelectorAll('#elemCheckboxes input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', () => {
cb.parentElement.classList.toggle('checked', cb.checked);
hasUnsavedChanges = true;
});
});
// Reviewed toggle — saves immediately via API
document.getElementById('fieldReviewed').addEventListener('change', async function() {
const card = filteredCards[modalCardIndex];
if (!card) return;
if (this.checked) {
reviewedSet.add(card.id);
} else {
reviewedSet.delete(card.id);
}
updateReviewCount();
// Persist reviewed state
await fetch('/api/reviewed', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ reviewed: [...reviewedSet] }),
});
});
// Add ability
document.getElementById('btnAddAbility').addEventListener('click', () => {
const abList = document.getElementById('abilitiesList');
abList.appendChild(renderAbilityCard({ type: 'field', name: '', trigger: '', effect: '', is_ex_burst: false }));
hasUnsavedChanges = true;
// Scroll to the new ability
abList.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
// Modal navigation
document.getElementById('btnPrev').addEventListener('click', () => showCardInModal(modalCardIndex - 1));
document.getElementById('btnNext').addEventListener('click', () => showCardInModal(modalCardIndex + 1));
document.getElementById('btnSave').addEventListener('click', saveCard);
document.getElementById('modalClose').addEventListener('click', closeModal);
document.getElementById('modalOverlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// Pagination
document.getElementById('pageFirst').addEventListener('click', () => goToPage(0));
document.getElementById('pagePrev').addEventListener('click', () => goToPage(currentPage - 1));
document.getElementById('pageNext').addEventListener('click', () => goToPage(currentPage + 1));
document.getElementById('pageLast').addEventListener('click', () => goToPage(totalPages() - 1));
document.getElementById('pageSize').addEventListener('change', function() {
pageSize = parseInt(this.value);
currentPage = 0;
renderGrid();
updatePagination();
});
// Filters
['filterSet', 'filterElement', 'filterType', 'filterCategory', 'filterIssues', 'filterReviewed'].forEach(id => {
document.getElementById(id).addEventListener('change', applyFilters);
});
document.getElementById('filterSearch').addEventListener('input', applyFilters);
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
const tag = e.target.tagName;
const modalOpen = document.getElementById('modalOverlay').classList.contains('open');
// Escape closes modal
if (e.key === 'Escape' && modalOpen) {
closeModal();
return;
}
// Ctrl+S saves in modal
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (modalOpen) saveCard();
return;
}
// Don't trigger navigation when typing in inputs
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if (modalOpen) {
if (e.key === 'ArrowLeft') showCardInModal(modalCardIndex - 1);
else if (e.key === 'ArrowRight') showCardInModal(modalCardIndex + 1);
} else {
if (e.key === 'ArrowLeft') goToPage(currentPage - 1);
else if (e.key === 'ArrowRight') goToPage(currentPage + 1);
}
});
init();
</script>
</body>
</html>
"""
class CardReviewerHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
# Suppress default access logging
pass
def _send_json(self, data, status=200):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_html(self, html):
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_404(self):
self.send_response(404)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Not Found")
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
# Serve main page
if path == "/" or path == "":
self._send_html(HTML_PAGE)
return
# API: get all cards
if path == "/api/cards":
self._send_json(cards_data)
return
# API: get reviewed state
if path == "/api/reviewed":
self._send_json(reviewed_data)
return
# API: get single card
if path.startswith("/api/cards/"):
card_id = path[len("/api/cards/"):]
card = next((c for c in cards_data["cards"] if c["id"] == card_id), None)
if card:
self._send_json(card)
else:
self._send_404()
return
# Serve card images
if path.startswith("/images/"):
filename = path[len("/images/"):]
filepath = SOURCE_CARDS_DIR / filename
if filepath.exists() and filepath.is_file():
with open(filepath, "rb") as f:
data = f.read()
self.send_response(200)
ext = filepath.suffix.lower()
if ext in (".jpg", ".jpeg"):
self.send_header("Content-Type", "image/jpeg")
elif ext == ".png":
self.send_header("Content-Type", "image/png")
else:
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "public, max-age=86400")
self.end_headers()
self.wfile.write(data)
else:
self._send_404()
return
self._send_404()
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
# API: save reviewed state
if path == "/api/reviewed":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
global reviewed_data
reviewed_data = json.loads(body)
save_reviewed()
self._send_json({"ok": True})
except json.JSONDecodeError:
self._send_json({"error": "Invalid JSON"}, 400)
return
# API: save card
if path.startswith("/api/cards/"):
card_id = path[len("/api/cards/"):]
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
new_data = json.loads(body)
except json.JSONDecodeError:
self._send_json({"error": "Invalid JSON"}, 400)
return
# Find and update card
for i, card in enumerate(cards_data["cards"]):
if card["id"] == card_id:
# Preserve abilities from existing card if not provided
if "abilities" not in new_data:
new_data["abilities"] = card.get("abilities", [])
cards_data["cards"][i] = new_data
save_cards()
self._send_json({"ok": True})
return
self._send_json({"error": "Card not found"}, 404)
return
self._send_404()
def main():
parser = argparse.ArgumentParser(description="FFTCG Card Data Reviewer")
parser.add_argument("--port", type=int, default=8080, help="Port to serve on")
parser.add_argument("--no-browser", action="store_true", help="Don't open browser automatically")
args = parser.parse_args()
load_cards()
load_reviewed()
print(f"Loaded {len(cards_data['cards'])} cards from {CARDS_FILE}")
print(f"Reviewed: {len(reviewed_data.get('reviewed', []))} cards")
print(f"Serving card images from {SOURCE_CARDS_DIR}")
print(f"Starting server at http://localhost:{args.port}")
server = HTTPServer(("localhost", args.port), CardReviewerHandler)
if not args.no_browser:
webbrowser.open(f"http://localhost:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
server.shutdown()
if __name__ == "__main__":
main()