diff --git a/src/App copy.jsx b/src/App copy.jsx
new file mode 100644
index 0000000..e58aa69
--- /dev/null
+++ b/src/App copy.jsx
@@ -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 (
+
+
+
+
+
+
CSV Card Viewer
+
Upload a CSV and browse cards with images.
+
+
+
+
+
+
+
+
+
+
+ {/* Left: Controls & list */}
+
+
+
Settings & Mapping
+
Map CSV columns and set the image template.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setBaseTemplate(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
+
Use placeholders: {'{character}'}, {'{edition}'}, or {'{slug(character)}'}.
+
+
+
+ setUseSlug(e.target.checked)} />
+
+
+
+
+
+ setFilterText(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
+
+
+
+
+
+
+
Thumbnail gallery
+
Click a thumbnail to jump to that card. Keyboard: ← →
+
+ {filteredIdx.length === 0 &&
No items (upload CSV first)
}
+ {filteredIdx.map(i => (
+
+ ))}
+
+
+
+
+ {/* Main: Large image + details */}
+
+
+ {currentRow ? (
+
+
+
+
+
+
+ {/* main image: high priority */}
+
![{currentRow[charCol]]({currentImage})
{ e.currentTarget.src = ''; e.currentTarget.alt = 'Image not available'; }} className="w-full h-full object-cover" />
+
+
+
+
+
{ headers[charCol] ? currentRow[charCol] : currentRow[0] }
+
{ headers[edCol] ? `${currentRow[edCol]}` : '' }
+
+
+
+
+
+
+
+
+
+
+
+
Details
+
+ {headers.map((h, idx) => (
+
+
{h}
+
{ currentRow[idx] }
+
+ ))}
+
+
+
+
+
+ ) : (
+
+
No card selected.
+
Upload a CSV file to begin. Drag & drop CSV anywhere on the window too.
+
+ )}
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..3e95079
--- /dev/null
+++ b/src/App.jsx
@@ -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 (
+
+
+
+
+
+
+ {/* Left: Controls & list */}
+
+
+
Settings & Mapping
+
Map CSV columns and set the image template.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setBaseTemplate(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
+
Use placeholders: {'{character}'}, {'{edition}'}, or {'{slug(character)}'}.
+
+
+
+ setUseSlug(e.target.checked)} />
+
+
+
+
+
+ setFilterText(e.target.value)} className="w-full mt-1 rounded-md p-2 border panel" />
+
+
+
+
+
+
+
Thumbnail gallery
+
Click a thumbnail to jump to that card. Keyboard: ← →
+
+ {filteredIdx.length === 0 &&
No items (upload CSV first)
}
+ {filteredIdx.map(i => (
+
+ ))}
+
+
+
+
+ {/* Main: Large image + details */}
+
+
+ {currentRow ? (
+
+
+
+
+
+
+ {/* main image: high priority */}
+
![{currentRow[charCol]]({currentImage})
{ e.currentTarget.src = ''; e.currentTarget.alt = 'Image not available'; }} className="w-full h-full object-cover" />
+
+
+
+
+
{ headers[charCol] ? currentRow[charCol] : currentRow[0] }
+
{ headers[edCol] ? `${currentRow[edCol]}` : '' }
+
+
+
+
+
+
+
+
+
+
+
+
Details
+
+ {headers.map((h, idx) => (
+
+
{h}
+
{ currentRow[idx] }
+
+ ))}
+
+
+
+
+
+ ) : (
+
+
No card selected.
+
Upload a CSV file to begin. Drag & drop CSV anywhere on the window too.
+
+ )}
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..f41e099
--- /dev/null
+++ b/src/main.jsx
@@ -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()