From 255c57e23676ea3e010038beb1edca0bbb5a3f0d Mon Sep 17 00:00:00 2001 From: phillip Date: Fri, 8 Aug 2025 19:55:23 +0200 Subject: [PATCH] Dateien nach "src" hochladen --- src/App copy.jsx | 400 +++++++++++++++++++++++++++++++++++++++++++++++ src/App.jsx | 369 +++++++++++++++++++++++++++++++++++++++++++ src/main.jsx | 6 + 3 files changed, 775 insertions(+) create mode 100644 src/App copy.jsx create mode 100644 src/App.jsx create mode 100644 src/main.jsx 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] { 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 ( +
+
+ +
+
+

Karuta Card Viewer

+

Upload a CSV and browse cards with images.

+
+ +
+ + {/* Clear button removed intentionally */} +
+
+ +
+ {/* 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] { 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()