998 lines
39 KiB
Python
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">«</button>
|
|
<button class="page-btn" id="pagePrev">‹ Prev</button>
|
|
<span class="page-info" id="pageInfo">Page 1 / 1</span>
|
|
<button class="page-btn" id="pageNext">Next ›</button>
|
|
<button class="page-btn" id="pageLast" title="Last page">»</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">×</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">← Prev</button>
|
|
<button class="nav-btn" id="btnNext" title="Right Arrow">Next →</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,'&').replace(/"/g,'"').replace(/</g,'<');
|
|
|
|
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">×</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()
|