src/App copy.jsx gelöscht
This commit is contained in:
@@ -1,400 +0,0 @@
|
||||
/*
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user