Dateien nach "src" hochladen
This commit is contained in:
@@ -0,0 +1,400 @@
|
|||||||
|
/*
|
||||||
|
Apple-like CSV Card Viewer — v3
|
||||||
|
|
||||||
|
Updates in this version (per your request):
|
||||||
|
- Precompute image URLs once when CSV or template changes to avoid repeated heavy string work during render.
|
||||||
|
- Thumbnails use lazy-loading and low fetch priority; main image uses high priority — reduces network contention.
|
||||||
|
- Use "edition" as the preferred default column name (search for 'edition' before 'number').
|
||||||
|
- Add Dark Mode with a toggle (persisted to localStorage). Uses CSS variables so it works whether or not Tailwind is configured for dark mode.
|
||||||
|
- Removed the Apple logo and the CSV preview panel (cleaner UI).
|
||||||
|
|
||||||
|
Drop this into a React + Tailwind project (Vite/CRA). If you want a vanilla HTML/JS single-file version, I can produce that next.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// Minimal CSV parser (handles quoted values)
|
||||||
|
function parseCSV(text) {
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let cur = "";
|
||||||
|
let i = 0;
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||||
|
cur += '"';
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ch === ',') {
|
||||||
|
row.push(cur);
|
||||||
|
cur = '';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\r') { i++; continue; }
|
||||||
|
if (ch === '\n') {
|
||||||
|
row.push(cur);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
cur = '';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur !== '' || row.length > 0) {
|
||||||
|
row.push(cur);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
const maxLen = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
|
return rows.map(r => { while (r.length < maxLen) r.push(''); return r; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
if (!s && s !== 0) return '';
|
||||||
|
return String(s)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-\s]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(){
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [headers, setHeaders] = useState([]);
|
||||||
|
const [charCol, setCharCol] = useState(null);
|
||||||
|
const [edCol, setEdCol] = useState(null);
|
||||||
|
const [imgCol, setImgCol] = useState(null);
|
||||||
|
const [baseTemplate, setBaseTemplate] = useState('http://d2l56h9h5tj8ue.cloudfront.net/images/cards/{slug(character)}-{edition}.jpg');
|
||||||
|
const [useSlug, setUseSlug] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
|
const [filteredIdx, setFilteredIdx] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [imageCache, setImageCache] = useState([]); // precomputed image URLs per row
|
||||||
|
const fileInputRef = useRef();
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
|
try { return localStorage.getItem('darkMode') === 'true'; } catch { return false; }
|
||||||
|
});
|
||||||
|
useEffect(()=>{ try { localStorage.setItem('darkMode', darkMode ? 'true' : 'false'); } catch{} }, [darkMode]);
|
||||||
|
|
||||||
|
// Build filtered index whenever rows or filter change
|
||||||
|
useEffect(() => {
|
||||||
|
const idx = [];
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const r = rows[i];
|
||||||
|
const joined = r.join(' ').toLowerCase();
|
||||||
|
if (!filterText || joined.includes(filterText.toLowerCase())) idx.push(i);
|
||||||
|
}
|
||||||
|
setFilteredIdx(idx);
|
||||||
|
if (idx.length === 0) setCurrentIndex(0);
|
||||||
|
else if (!idx.includes(currentIndex)) setCurrentIndex(idx[0]);
|
||||||
|
}, [rows, filterText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e){
|
||||||
|
if (!rows.length) return;
|
||||||
|
if (e.key === 'ArrowRight') next();
|
||||||
|
if (e.key === 'ArrowLeft') prev();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [rows, filteredIdx, currentIndex]);
|
||||||
|
|
||||||
|
function prev(){
|
||||||
|
if (!filteredIdx.length) return;
|
||||||
|
const p = filteredIdx.indexOf(currentIndex);
|
||||||
|
const newIndex = filteredIdx[(p - 1 + filteredIdx.length) % filteredIdx.length];
|
||||||
|
setCurrentIndex(newIndex);
|
||||||
|
}
|
||||||
|
function next(){
|
||||||
|
if (!filteredIdx.length) return;
|
||||||
|
const p = filteredIdx.indexOf(currentIndex);
|
||||||
|
const newIndex = filteredIdx[(p + 1) % filteredIdx.length];
|
||||||
|
setCurrentIndex(newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute image URLs for every row to avoid doing string replacements repeatedly during render
|
||||||
|
useEffect(()=>{
|
||||||
|
if (!rows || rows.length === 0 || headers.length === 0) { setImageCache([]); return; }
|
||||||
|
const cache = new Array(rows.length);
|
||||||
|
for (let r = 0; r < rows.length; r++){
|
||||||
|
const row = rows[r];
|
||||||
|
// If user selected an image column, use it
|
||||||
|
if (imgCol !== null && headers[imgCol]){
|
||||||
|
const v = row[imgCol];
|
||||||
|
if (v && v.trim()) { cache[r] = v.trim(); continue; }
|
||||||
|
}
|
||||||
|
// Build from template (one pass)
|
||||||
|
let url = baseTemplate;
|
||||||
|
const ctx = {};
|
||||||
|
headers.forEach((h, i) => { ctx[h] = row[i]; });
|
||||||
|
const charVal = (charCol !== null && headers[charCol]) ? row[charCol] : '';
|
||||||
|
const edVal = (edCol !== null && headers[edCol]) ? row[edCol] : '';
|
||||||
|
// slug replacements
|
||||||
|
url = url.replace(/\{slug\(([^}]+)\)\}/g, (_, inner) => {
|
||||||
|
const key = inner.trim();
|
||||||
|
const v = key === 'character' ? charVal : (ctx[key] ?? '');
|
||||||
|
return slugify(v);
|
||||||
|
});
|
||||||
|
// basic placeholders
|
||||||
|
url = url.replace(/\{character\}/g, charVal);
|
||||||
|
url = url.replace(/\{edition\}/g, edVal);
|
||||||
|
// fallback replace any {field}
|
||||||
|
url = url.replace(/\{([^}]+)\}/g, (_, f) => {
|
||||||
|
return (ctx[f] ?? '');
|
||||||
|
});
|
||||||
|
if (useSlug) {
|
||||||
|
url = url.replace(/\{character\}/g, slugify(charVal));
|
||||||
|
}
|
||||||
|
cache[r] = url;
|
||||||
|
}
|
||||||
|
setImageCache(cache);
|
||||||
|
}, [rows, headers, baseTemplate, useSlug, imgCol, charCol, edCol]);
|
||||||
|
|
||||||
|
function onFile(e){
|
||||||
|
setError('');
|
||||||
|
const f = e.target.files ? e.target.files[0] : e;
|
||||||
|
if (!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = ev => {
|
||||||
|
try{
|
||||||
|
const txt = ev.target.result;
|
||||||
|
const parsed = parseCSV(txt);
|
||||||
|
if (parsed.length < 1) { setError('Empty CSV'); return; }
|
||||||
|
const hdr = parsed[0].map(h => h.trim() || 'col' );
|
||||||
|
const data = parsed.slice(1).map(r => r.map(c => c.trim()));
|
||||||
|
setHeaders(hdr);
|
||||||
|
setRows(data);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
// guess columns
|
||||||
|
const lower = hdr.map(h => h.toLowerCase());
|
||||||
|
// character guess
|
||||||
|
let charGuess = lower.findIndex(h => h.includes('character') || h.includes('name'));
|
||||||
|
if (charGuess === -1) charGuess = lower.findIndex(h=>h.includes('hero'));
|
||||||
|
if (charGuess === -1) charGuess = 0;
|
||||||
|
// edition guess: prefer 'edition' before 'number'
|
||||||
|
let edGuess = lower.findIndex(h => h.includes('edition'));
|
||||||
|
if (edGuess === -1) edGuess = lower.findIndex(h => h.includes('number') || h.includes('no') || h.includes('num') || h.includes('issue'));
|
||||||
|
if (edGuess === -1) edGuess = 1;
|
||||||
|
let imageGuess = lower.findIndex(h => h.includes('image') || h.includes('img') || h.includes('url'));
|
||||||
|
if (imageGuess === -1) imageGuess = null;
|
||||||
|
setCharCol(charGuess >=0 ? charGuess : null);
|
||||||
|
setEdCol(edGuess >=0 ? edGuess : null);
|
||||||
|
setImgCol(imageGuess);
|
||||||
|
} catch(err){
|
||||||
|
setError('Failed to parse CSV: '+String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept drag and drop
|
||||||
|
useEffect(()=>{
|
||||||
|
function onDrop(e){
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
function prevent(e){ e.preventDefault(); }
|
||||||
|
window.addEventListener('drop', onDrop);
|
||||||
|
window.addEventListener('dragover', prevent);
|
||||||
|
return () => { window.removeEventListener('drop', onDrop); window.removeEventListener('dragover', prevent); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentRow = rows[currentIndex] || null;
|
||||||
|
const currentImage = imageCache[currentIndex] || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${darkMode ? 'dark' : ''}`}>
|
||||||
|
<div style={{ background: 'var(--bg)', color: 'var(--text)' }} className="min-h-screen font-sans p-6">
|
||||||
|
|
||||||
|
<header className="max-w-7xl mx-auto flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">CSV Card Viewer</h1>
|
||||||
|
<p className="text-sm muted">Upload a CSV and browse cards with images.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="bg-white/6 backdrop-blur rounded-full px-4 py-2 shadow-sm cursor-pointer hover:shadow panel" style={{borderRadius:999}}>
|
||||||
|
<input ref={fileInputRef} onChange={onFile} type="file" accept=".csv,text/csv" className="hidden" />
|
||||||
|
Upload CSV
|
||||||
|
</label>
|
||||||
|
<button onClick={()=>{ setRows([]); setHeaders([]); setCurrentIndex(0); fileInputRef.current.value = null; }} className="px-4 py-2 rounded-full bg-transparent border panel">Clear</button>
|
||||||
|
<button onClick={()=> setDarkMode(d => !d)} title="Toggle dark" className="px-3 py-2 rounded-full border panel">
|
||||||
|
{darkMode ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left: Controls & list */}
|
||||||
|
<section>
|
||||||
|
<div className="p-4 rounded-2xl shadow glass panel">
|
||||||
|
<h2 className="font-semibold">Settings & Mapping</h2>
|
||||||
|
<p className="text-sm muted mb-3">Map CSV columns and set the image template.</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Character column</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={charCol ?? ''} onChange={e=> setCharCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Edition column (preferred)</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={edCol ?? ''} onChange={e=> setEdCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Image column (optional, use if CSV has direct URLs)</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={imgCol ?? ''} onChange={e=> setImgCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Image URL template</label>
|
||||||
|
<input value={baseTemplate} onChange={e=> setBaseTemplate(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
|
||||||
|
<p className="text-xs muted mt-1">Use placeholders: <code>{'{character}'}</code>, <code>{'{edition}'}</code>, or <code>{'{slug(character)}'}</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="slug" type="checkbox" checked={useSlug} onChange={e=> setUseSlug(e.target.checked)} />
|
||||||
|
<label htmlFor="slug" className="text-sm muted">Treat character as slug (lowercase, spaces → '-') when not using <code>{'{slug(...) }'}</code></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Filter / search</label>
|
||||||
|
<input placeholder="search across all columns" value={filterText} onChange={e=> setFilterText(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 rounded-2xl shadow glass panel">
|
||||||
|
<h3 className="font-semibold">Thumbnail gallery</h3>
|
||||||
|
<p className="text-xs muted">Click a thumbnail to jump to that card. Keyboard: ← →</p>
|
||||||
|
<div className="mt-3 grid grid-cols-4 gap-2 max-h-96 overflow-auto">
|
||||||
|
{filteredIdx.length === 0 && <div className="text-sm muted">No items (upload CSV first)</div>}
|
||||||
|
{filteredIdx.map(i => (
|
||||||
|
<button key={i} onClick={()=> setCurrentIndex(i)} className={`rounded-lg p-1 overflow-hidden border ${i=== currentIndex ? 'ring-2 ring-offset-2 ring-indigo-300' : 'hover:shadow-sm'} panel`}>
|
||||||
|
<img alt={rows[i][charCol] || rows[i][0] || ''} src={imageCache[i] || ''} loading="lazy" decoding="async" fetchpriority="low" className="w-full h-20 object-cover rounded-md" onError={(e)=> e.currentTarget.style.opacity=0.5} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main: Large image + details */}
|
||||||
|
<section>
|
||||||
|
<div className="p-6 rounded-3xl shadow-lg flex flex-col items-center justify-center panel">
|
||||||
|
{currentRow ? (
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl overflow-hidden shadow-xl panel">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="w-full aspect-w-4 aspect-h-5 bg-gray-100 rounded-md overflow-hidden">
|
||||||
|
{/* main image: high priority */}
|
||||||
|
<img alt={currentRow[charCol] || ''} src={currentImage} loading="eager" decoding="async" fetchpriority="high" onError={(e)=> { e.currentTarget.src = ''; e.currentTarget.alt = 'Image not available'; }} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{ headers[charCol] ? currentRow[charCol] : currentRow[0] }</div>
|
||||||
|
<div className="text-sm muted">{ headers[edCol] ? `${currentRow[edCol]}` : '' }</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={prev} className="px-3 py-2 rounded-full border">Prev</button>
|
||||||
|
<button onClick={next} className="px-3 py-2 rounded-full bg-black text-white">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="p-4 rounded-xl border h-full panel">
|
||||||
|
<h3 className="font-semibold">Details</h3>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-sm">
|
||||||
|
{headers.map((h, idx) => (
|
||||||
|
<div key={idx} className="p-2 rounded-md bg-gray-50">
|
||||||
|
<div className="text-xs muted">{h}</div>
|
||||||
|
<div className="text-sm font-medium">{ currentRow[idx] }</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center muted">
|
||||||
|
<p className="mb-3">No card selected.</p>
|
||||||
|
<p className="text-sm">Upload a CSV file to begin. Drag & drop CSV anywhere on the window too.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{error && <div className="fixed bottom-6 right-6 bg-red-50 border border-red-200 text-red-800 p-3 rounded-lg">{error}</div>}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
:root{
|
||||||
|
--bg: linear-gradient(to bottom, #f8fafc, #ffffff);
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--glass: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.dark{
|
||||||
|
--bg: linear-gradient(to bottom, #07122a, #03101a);
|
||||||
|
--panel: #071027;
|
||||||
|
--text: #696868ff;
|
||||||
|
--muted: #94a3b8;a
|
||||||
|
--glass: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.glass { background: var(--glass); backdrop-filter: blur(6px); }
|
||||||
|
.panel { background: var(--panel); color: var(--text); }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
/* aspect helpers (if Tailwind plugin not present) */
|
||||||
|
.aspect-w-4 { position: relative; }
|
||||||
|
.aspect-h-5 { }
|
||||||
|
.aspect-w-4 .object-cover { width:100%; height:100%; }
|
||||||
|
.aspect-w-4.aspect-h-5 { padding-top: calc(5 / 4 * 100%); position: relative; }
|
||||||
|
.aspect-w-4.aspect-h-5 > img, .aspect-w-4.aspect-h-5 > div > img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
/*
|
||||||
|
Apple-like CSV Card Viewer — v5
|
||||||
|
|
||||||
|
Changes in this update (per your request):
|
||||||
|
- Removed the **Clear** button from the header.
|
||||||
|
- Fixed long-value overlap (e.g. obtainedTimestamp) in the Details panel by:
|
||||||
|
• forcing word-break on long continuous strings,
|
||||||
|
• increasing the clamp to 6 lines and enforcing a max-height,
|
||||||
|
• making the details grid vertically scrollable when content exceeds the area.
|
||||||
|
- Uses a darker default theme (no toggle) so the UI is darker by default.
|
||||||
|
|
||||||
|
Drop this into your React + Tailwind project (replace previous App.jsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// Minimal CSV parser (handles quoted values)
|
||||||
|
function parseCSV(text) {
|
||||||
|
const rows = [];
|
||||||
|
let row = [];
|
||||||
|
let cur = "";
|
||||||
|
let i = 0;
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||||
|
cur += '"';
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cur += ch;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ch === ',') {
|
||||||
|
row.push(cur);
|
||||||
|
cur = '';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\r') { i++; continue; }
|
||||||
|
if (ch === '\n') {
|
||||||
|
row.push(cur);
|
||||||
|
rows.push(row);
|
||||||
|
row = [];
|
||||||
|
cur = '';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur !== '' || row.length > 0) {
|
||||||
|
row.push(cur);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
const maxLen = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||||
|
return rows.map(r => { while (r.length < maxLen) r.push(''); return r; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s) {
|
||||||
|
if (!s && s !== 0) return '';
|
||||||
|
return String(s)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-\s]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App(){
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [headers, setHeaders] = useState([]);
|
||||||
|
const [charCol, setCharCol] = useState(null);
|
||||||
|
const [edCol, setEdCol] = useState(null);
|
||||||
|
const [imgCol, setImgCol] = useState(null);
|
||||||
|
const [baseTemplate, setBaseTemplate] = useState('http://d2l56h9h5tj8ue.cloudfront.net/images/cards/{slug(character)}-{edition}.jpg');
|
||||||
|
const [useSlug, setUseSlug] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
|
const [filteredIdx, setFilteredIdx] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [imageCache, setImageCache] = useState([]); // precomputed image URLs per row
|
||||||
|
const fileInputRef = useRef();
|
||||||
|
|
||||||
|
// Build filtered index whenever rows or filter change
|
||||||
|
useEffect(() => {
|
||||||
|
const idx = [];
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const r = rows[i];
|
||||||
|
const joined = r.join(' ').toLowerCase();
|
||||||
|
if (!filterText || joined.includes(filterText.toLowerCase())) idx.push(i);
|
||||||
|
}
|
||||||
|
setFilteredIdx(idx);
|
||||||
|
if (idx.length === 0) setCurrentIndex(0);
|
||||||
|
else if (!idx.includes(currentIndex)) setCurrentIndex(idx[0]);
|
||||||
|
}, [rows, filterText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e){
|
||||||
|
if (!rows.length) return;
|
||||||
|
if (e.key === 'ArrowRight') next();
|
||||||
|
if (e.key === 'ArrowLeft') prev();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [rows, filteredIdx, currentIndex]);
|
||||||
|
|
||||||
|
function prev(){
|
||||||
|
if (!filteredIdx.length) return;
|
||||||
|
const p = filteredIdx.indexOf(currentIndex);
|
||||||
|
const newIndex = filteredIdx[(p - 1 + filteredIdx.length) % filteredIdx.length];
|
||||||
|
setCurrentIndex(newIndex);
|
||||||
|
}
|
||||||
|
function next(){
|
||||||
|
if (!filteredIdx.length) return;
|
||||||
|
const p = filteredIdx.indexOf(currentIndex);
|
||||||
|
const newIndex = filteredIdx[(p + 1) % filteredIdx.length];
|
||||||
|
setCurrentIndex(newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute image URLs for every row to avoid doing string replacements repeatedly during render
|
||||||
|
useEffect(()=>{
|
||||||
|
if (!rows || rows.length === 0 || headers.length === 0) { setImageCache([]); return; }
|
||||||
|
const cache = new Array(rows.length);
|
||||||
|
for (let r = 0; r < rows.length; r++){
|
||||||
|
const row = rows[r];
|
||||||
|
// If user selected an image column, use it
|
||||||
|
if (imgCol !== null && headers[imgCol]){
|
||||||
|
const v = row[imgCol];
|
||||||
|
if (v && v.trim()) { cache[r] = v.trim(); continue; }
|
||||||
|
}
|
||||||
|
// Build from template (one pass)
|
||||||
|
let url = baseTemplate;
|
||||||
|
const ctx = {};
|
||||||
|
headers.forEach((h, i) => { ctx[h] = row[i]; });
|
||||||
|
const charVal = (charCol !== null && headers[charCol]) ? row[charCol] : '';
|
||||||
|
const edVal = (edCol !== null && headers[edCol]) ? row[edCol] : '';
|
||||||
|
// slug replacements
|
||||||
|
url = url.replace(/\{slug\(([^}]+)\)\}/g, (_, inner) => {
|
||||||
|
const key = inner.trim();
|
||||||
|
const v = key === 'character' ? charVal : (ctx[key] ?? '');
|
||||||
|
return slugify(v);
|
||||||
|
});
|
||||||
|
// basic placeholders
|
||||||
|
url = url.replace(/\{character\}/g, charVal);
|
||||||
|
url = url.replace(/\{edition\}/g, edVal);
|
||||||
|
// fallback replace any {field}
|
||||||
|
url = url.replace(/\{([^}]+)\}/g, (_, f) => {
|
||||||
|
return (ctx[f] ?? '');
|
||||||
|
});
|
||||||
|
if (useSlug) {
|
||||||
|
url = url.replace(/\{character\}/g, slugify(charVal));
|
||||||
|
}
|
||||||
|
cache[r] = url;
|
||||||
|
}
|
||||||
|
setImageCache(cache);
|
||||||
|
}, [rows, headers, baseTemplate, useSlug, imgCol, charCol, edCol]);
|
||||||
|
|
||||||
|
function onFile(e){
|
||||||
|
setError('');
|
||||||
|
const f = e.target.files ? e.target.files[0] : e;
|
||||||
|
if (!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = ev => {
|
||||||
|
try{
|
||||||
|
const txt = ev.target.result;
|
||||||
|
const parsed = parseCSV(txt);
|
||||||
|
if (parsed.length < 1) { setError('Empty CSV'); return; }
|
||||||
|
const hdr = parsed[0].map(h => h.trim() || 'col' );
|
||||||
|
const data = parsed.slice(1).map(r => r.map(c => c.trim()));
|
||||||
|
setHeaders(hdr);
|
||||||
|
setRows(data);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
// guess columns
|
||||||
|
const lower = hdr.map(h => h.toLowerCase());
|
||||||
|
// character guess
|
||||||
|
let charGuess = lower.findIndex(h => h.includes('character') || h.includes('name'));
|
||||||
|
if (charGuess === -1) charGuess = lower.findIndex(h=>h.includes('hero'));
|
||||||
|
if (charGuess === -1) charGuess = 0;
|
||||||
|
// edition guess: prefer 'edition' before 'number'
|
||||||
|
let edGuess = lower.findIndex(h => h.includes('edition'));
|
||||||
|
if (edGuess === -1) edGuess = lower.findIndex(h => h.includes('number') || h.includes('no') || h.includes('num') || h.includes('issue'));
|
||||||
|
if (edGuess === -1) edGuess = 1;
|
||||||
|
let imageGuess = lower.findIndex(h => h.includes('image') || h.includes('img') || h.includes('url'));
|
||||||
|
if (imageGuess === -1) imageGuess = null;
|
||||||
|
setCharCol(charGuess >=0 ? charGuess : null);
|
||||||
|
setEdCol(edGuess >=0 ? edGuess : null);
|
||||||
|
setImgCol(imageGuess);
|
||||||
|
} catch(err){
|
||||||
|
setError('Failed to parse CSV: '+String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept drag and drop
|
||||||
|
useEffect(()=>{
|
||||||
|
function onDrop(e){
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) onFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
function prevent(e){ e.preventDefault(); }
|
||||||
|
window.addEventListener('drop', onDrop);
|
||||||
|
window.addEventListener('dragover', prevent);
|
||||||
|
return () => { window.removeEventListener('drop', onDrop); window.removeEventListener('dragover', prevent); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentRow = rows[currentIndex] || null;
|
||||||
|
const currentImage = imageCache[currentIndex] || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ background: 'var(--bg)', color: 'var(--text)' }} className="min-h-screen font-sans p-6">
|
||||||
|
|
||||||
|
<header className="max-w-7xl mx-auto flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Karuta Card Viewer</h1>
|
||||||
|
<p className="text-sm muted">Upload a CSV and browse cards with images.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="bg-white/6 backdrop-blur rounded-full px-4 py-2 shadow-sm cursor-pointer hover:shadow panel" style={{borderRadius:999}}>
|
||||||
|
<input ref={fileInputRef} onChange={onFile} type="file" accept=".csv,text/csv" className="hidden" />
|
||||||
|
Upload CSV
|
||||||
|
</label>
|
||||||
|
{/* Clear button removed intentionally */}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left: Controls & list */}
|
||||||
|
<section>
|
||||||
|
<div className="p-4 rounded-2xl shadow glass panel">
|
||||||
|
<h2 className="font-semibold">Settings & Mapping</h2>
|
||||||
|
<p className="text-sm muted mb-3">Map CSV columns and set the image template.</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Character column</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={charCol ?? ''} onChange={e=> setCharCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Edition column (preferred)</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={edCol ?? ''} onChange={e=> setEdCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Image column (optional, use if CSV has direct URLs)</label>
|
||||||
|
<select className="w-full mt-1 rounded-md p-2 border panel" value={imgCol ?? ''} onChange={e=> setImgCol(e.target.value === '' ? null : Number(e.target.value))}>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
{headers.map((h,i) => <option key={i} value={i}>{h} ({i})</option> )}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Image URL template</label>
|
||||||
|
<input value={baseTemplate} onChange={e=> setBaseTemplate(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
|
||||||
|
<p className="text-xs muted mt-1">Use placeholders: <code>{'{character}'}</code>, <code>{'{edition}'}</code>, or <code>{'{slug(character)}'}</code>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input id="slug" type="checkbox" checked={useSlug} onChange={e=> setUseSlug(e.target.checked)} />
|
||||||
|
<label htmlFor="slug" className="text-sm muted">Treat character as slug (lowercase, spaces → '-') when not using <code>{'{slug(...) }'}</code></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs muted">Filter / search</label>
|
||||||
|
<input placeholder="search across all columns" value={filterText} onChange={e=> setFilterText(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 rounded-2xl shadow glass panel">
|
||||||
|
<h3 className="font-semibold">Thumbnail gallery</h3>
|
||||||
|
<p className="text-xs muted">Click a thumbnail to jump to that card. Keyboard: ← →</p>
|
||||||
|
<div className="mt-3 grid grid-cols-4 gap-2 max-h-96 overflow-auto">
|
||||||
|
{filteredIdx.length === 0 && <div className="text-sm muted">No items (upload CSV first)</div>}
|
||||||
|
{filteredIdx.map(i => (
|
||||||
|
<button key={i} onClick={()=> setCurrentIndex(i)} className={`rounded-lg p-1 overflow-hidden border ${i=== currentIndex ? 'ring-2 ring-offset-2 ring-indigo-300' : 'hover:shadow-sm'} panel`}>
|
||||||
|
<div className="w-full h-20 rounded-md overflow-hidden bg-gray-100">
|
||||||
|
<img alt={rows[i][charCol] || rows[i][0] || ''} src={imageCache[i] || ''} loading="lazy" decoding="async" fetchpriority="low" className="w-full h-full object-cover" onError={(e)=> e.currentTarget.style.opacity=0.5} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main: Large image + details */}
|
||||||
|
<section>
|
||||||
|
<div className="p-6 rounded-3xl shadow-lg flex flex-col items-center justify-center panel">
|
||||||
|
{currentRow ? (
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-full max-w-2xl rounded-2xl overflow-hidden shadow-xl panel">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="w-full aspect-w-4 aspect-h-5 bg-gray-100 rounded-md overflow-hidden">
|
||||||
|
{/* main image: high priority */}
|
||||||
|
<img alt={currentRow[charCol] || ''} src={currentImage} loading="eager" decoding="async" fetchpriority="high" onError={(e)=> { e.currentTarget.src = ''; e.currentTarget.alt = 'Image not available'; }} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{ headers[charCol] ? currentRow[charCol] : currentRow[0] }</div>
|
||||||
|
<div className="text-sm muted">{ headers[edCol] ? `${currentRow[edCol]}` : '' }</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={prev} className="px-3 py-2 rounded-full border">Prev</button>
|
||||||
|
<button onClick={next} className="px-3 py-2 rounded-full bg-black text-white">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="p-4 rounded-xl border h-full panel">
|
||||||
|
<h3 className="font-semibold">Details</h3>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2 text-sm details-grid">
|
||||||
|
{headers.map((h, idx) => (
|
||||||
|
<div key={idx} className="p-2 rounded-md detail-cell">
|
||||||
|
<div className="text-xs muted">{h}</div>
|
||||||
|
<div className="text-sm font-medium detail-value">{ currentRow[idx] }</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center muted">
|
||||||
|
<p className="mb-3">No card selected.</p>
|
||||||
|
<p className="text-sm">Upload a CSV file to begin. Drag & drop CSV anywhere on the window too.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{error && <div className="fixed bottom-6 right-6 bg-red-50 border border-red-200 text-red-800 p-3 rounded-lg">{error}</div>}
|
||||||
|
|
||||||
|
<style>{`\n :root{\n --bg: linear-gradient(to bottom, #07122a, #03101a);\n --panel: #071027;\n --text: #e6eef8;\n --muted: #94a3b8;\n --glass: rgba(255,255,255,0.04);\n }\n .glass { background: var(--glass); backdrop-filter: blur(6px); }\n .panel { background: var(--panel); color: var(--text); }\n .muted { color: var(--muted); }\n\n /* details grid: prevent overlap and clamp long text */\n .details-grid { max-height: 36vh; overflow:auto; padding-right:6px; }\n .detail-cell { background: rgba(255,255,255,0.02); display:flex; flex-direction:column; gap:6px; min-height:48px; padding:8px; border-radius:8px; }\n .detail-value { white-space: normal; overflow-wrap: anywhere; word-break: break-all; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; line-height: 1.15rem; max-height: calc(1.15rem * 6); }\n\n /* aspect helpers (if Tailwind plugin not present) */\n .aspect-w-4 { position: relative; }\n .aspect-h-5 { }\n .aspect-w-4 .object-cover { width:100%; height:100%; }\n .aspect-w-4.aspect-h-5 { padding-top: calc(5 / 4 * 100%); position: relative; }\n .aspect-w-4.aspect-h-5 > img, .aspect-w-4.aspect-h-5 > div > img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }\n `}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import '../index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />)
|
||||||
Reference in New Issue
Block a user