Skip to content

Commit f311ef0

Browse files
Add CSV Row Viewer standalone tool (#120)
### Motivation - Provide a small standalone web tool to upload a CSV and inspect individual data rows one-at-a-time for easier browsing and debugging. ### Description - Add `csv-row-viewer.html`, a standalone HTML/JS tool that accepts a CSV file, treats the first row as the header, and renders each subsequent row as a key/value table with per-row pages. - Implement a client-side CSV parser supporting quoted fields and escaped quotes and simple CR/LF handling in `csv-row-viewer.html` (function `parseCsv`). - Add row navigation controls including `Previous row`, `Next row`, and a numeric `Jump to row` input with validation and disabled-state handling for navigation buttons. - Add `csv-row-viewer.docs.md` with a short description so the tool is picked up by the repository metadata/indexing. ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_69c3be7be9d8832593ab0572f2a1f44d)
1 parent 46d85b5 commit f311ef0

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

csv-row-viewer.docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Upload a CSV file and inspect one data row at a time. The first row is treated as the header, each following row is shown as a key/value table, and built-in navigation lets you go to the previous row, next row, or jump directly to any row number.

csv-row-viewer.html

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>CSV Row Viewer</title>
8+
<link rel="stylesheet" href="styles.css">
9+
<style>
10+
body {
11+
max-width: 960px;
12+
margin: 0 auto;
13+
padding: 24px 20px 48px;
14+
}
15+
16+
main {
17+
display: grid;
18+
gap: 1.5rem;
19+
}
20+
21+
.page-header {
22+
display: grid;
23+
gap: 0.5rem;
24+
}
25+
26+
.site-link {
27+
color: var(--foreground-subtle);
28+
font-weight: 600;
29+
text-decoration: none;
30+
}
31+
32+
.site-link:hover,
33+
.site-link:focus-visible {
34+
color: var(--foreground);
35+
}
36+
37+
.tool-card {
38+
padding: clamp(1.25rem, 3vw, 2rem);
39+
}
40+
41+
.status {
42+
margin-top: 0.75rem;
43+
color: var(--foreground-subtle);
44+
}
45+
46+
.status.error {
47+
color: var(--error-foreground, #b42318);
48+
}
49+
50+
.meta {
51+
display: flex;
52+
flex-wrap: wrap;
53+
gap: 1rem;
54+
margin-bottom: 1rem;
55+
color: var(--foreground-subtle);
56+
}
57+
58+
.row-nav {
59+
display: flex;
60+
flex-wrap: wrap;
61+
align-items: center;
62+
gap: 0.75rem;
63+
margin-top: 1.25rem;
64+
}
65+
66+
.jump-group {
67+
display: inline-flex;
68+
align-items: center;
69+
gap: 0.5rem;
70+
margin-left: auto;
71+
}
72+
73+
@media (max-width: 720px) {
74+
body {
75+
padding: 20px 16px 40px;
76+
}
77+
78+
.jump-group {
79+
margin-left: 0;
80+
}
81+
}
82+
</style>
83+
</head>
84+
85+
<body>
86+
<header class="page-header">
87+
<a class="site-link" href="https://tools.mathspp.com/" aria-label="Back to tools.mathspp.com">← tools.mathspp.com</a>
88+
<h1>CSV Row Viewer</h1>
89+
<p class="lead">Upload a CSV file and browse each data row as a separate page.</p>
90+
</header>
91+
92+
<main>
93+
<section class="surface tool-card">
94+
<label for="csv-file">Choose CSV file</label>
95+
<input id="csv-file" type="file" accept=".csv,text/csv">
96+
<p id="load-status" class="status" aria-live="polite">No file loaded.</p>
97+
</section>
98+
99+
<section id="viewer" class="surface tool-card" hidden>
100+
<div class="meta">
101+
<span id="row-indicator">Row 1 of 1</span>
102+
<span id="file-summary"></span>
103+
</div>
104+
105+
<div class="table-container" aria-live="polite">
106+
<table>
107+
<thead>
108+
<tr>
109+
<th>Column</th>
110+
<th>Value</th>
111+
</tr>
112+
</thead>
113+
<tbody id="row-table-body"></tbody>
114+
</table>
115+
</div>
116+
117+
<div class="row-nav">
118+
<button id="prev-row" type="button">Previous row</button>
119+
<button id="next-row" type="button">Next row</button>
120+
<div class="jump-group">
121+
<label for="jump-row">Jump to row</label>
122+
<input id="jump-row" type="number" min="1" step="1" inputmode="numeric" style="max-width: 110px;">
123+
<button id="jump-button" type="button">Go</button>
124+
</div>
125+
</div>
126+
</section>
127+
</main>
128+
129+
<footer class="page-footer">
130+
<p>Built with ❤️, 🤖, and 🐍, by <a href="https://mathspp.com/">Rodrigo Girão Serrão</a></p>
131+
</footer>
132+
133+
<script>
134+
(function () {
135+
const fileInput = document.getElementById('csv-file');
136+
const loadStatus = document.getElementById('load-status');
137+
const viewer = document.getElementById('viewer');
138+
const rowIndicator = document.getElementById('row-indicator');
139+
const fileSummary = document.getElementById('file-summary');
140+
const tableBody = document.getElementById('row-table-body');
141+
const prevButton = document.getElementById('prev-row');
142+
const nextButton = document.getElementById('next-row');
143+
const jumpInput = document.getElementById('jump-row');
144+
const jumpButton = document.getElementById('jump-button');
145+
146+
let headers = [];
147+
let rows = [];
148+
let currentIndex = 0;
149+
150+
const parseCsv = (text) => {
151+
const output = [];
152+
let row = [];
153+
let field = '';
154+
let i = 0;
155+
let inQuotes = false;
156+
157+
while (i < text.length) {
158+
const char = text[i];
159+
160+
if (inQuotes) {
161+
if (char === '"') {
162+
if (text[i + 1] === '"') {
163+
field += '"';
164+
i += 2;
165+
continue;
166+
}
167+
inQuotes = false;
168+
i += 1;
169+
continue;
170+
}
171+
172+
field += char;
173+
i += 1;
174+
continue;
175+
}
176+
177+
if (char === '"') {
178+
inQuotes = true;
179+
i += 1;
180+
continue;
181+
}
182+
183+
if (char === ',') {
184+
row.push(field);
185+
field = '';
186+
i += 1;
187+
continue;
188+
}
189+
190+
if (char === '\n') {
191+
row.push(field);
192+
output.push(row);
193+
row = [];
194+
field = '';
195+
i += 1;
196+
continue;
197+
}
198+
199+
if (char === '\r') {
200+
i += 1;
201+
continue;
202+
}
203+
204+
field += char;
205+
i += 1;
206+
}
207+
208+
if (field.length > 0 || row.length > 0) {
209+
row.push(field);
210+
output.push(row);
211+
}
212+
213+
return output.filter(parsedRow => parsedRow.some(cell => cell !== ''));
214+
};
215+
216+
const renderRow = () => {
217+
const row = rows[currentIndex] || [];
218+
const totalColumns = Math.max(headers.length, row.length);
219+
220+
const mergedHeaders = Array.from({ length: totalColumns }, (_, index) => {
221+
if (headers[index]) {
222+
return headers[index];
223+
}
224+
return `Extra column ${index - headers.length + 1}`;
225+
});
226+
227+
tableBody.innerHTML = mergedHeaders.map((header, index) => {
228+
const value = row[index] ?? '—';
229+
return `\n<tr><th scope="row">${escapeHtml(header || `Column ${index + 1}`)}</th><td>${escapeHtml(value)}</td></tr>`;
230+
}).join('');
231+
232+
rowIndicator.textContent = `Row ${currentIndex + 1} of ${rows.length}`;
233+
prevButton.disabled = currentIndex === 0;
234+
nextButton.disabled = currentIndex >= rows.length - 1;
235+
jumpInput.value = String(currentIndex + 1);
236+
};
237+
238+
const escapeHtml = (value) => value
239+
.replaceAll('&', '&amp;')
240+
.replaceAll('<', '&lt;')
241+
.replaceAll('>', '&gt;')
242+
.replaceAll('"', '&quot;')
243+
.replaceAll("'", '&#39;');
244+
245+
const loadCsv = async (file) => {
246+
try {
247+
const text = await file.text();
248+
const parsed = parseCsv(text);
249+
250+
if (parsed.length < 2) {
251+
throw new Error('CSV must include a header row and at least one data row.');
252+
}
253+
254+
headers = parsed[0];
255+
rows = parsed.slice(1);
256+
currentIndex = 0;
257+
258+
viewer.hidden = false;
259+
fileSummary.textContent = `${file.name}${rows.length} data row${rows.length === 1 ? '' : 's'}`;
260+
loadStatus.textContent = 'CSV loaded successfully.';
261+
loadStatus.classList.remove('error');
262+
263+
jumpInput.min = '1';
264+
jumpInput.max = String(rows.length);
265+
266+
renderRow();
267+
} catch (error) {
268+
viewer.hidden = true;
269+
headers = [];
270+
rows = [];
271+
loadStatus.textContent = error.message || 'Unable to parse this CSV file.';
272+
loadStatus.classList.add('error');
273+
}
274+
};
275+
276+
fileInput.addEventListener('change', (event) => {
277+
const file = event.target.files?.[0];
278+
if (!file) {
279+
return;
280+
}
281+
loadCsv(file);
282+
});
283+
284+
prevButton.addEventListener('click', () => {
285+
if (currentIndex > 0) {
286+
currentIndex -= 1;
287+
renderRow();
288+
}
289+
});
290+
291+
nextButton.addEventListener('click', () => {
292+
if (currentIndex < rows.length - 1) {
293+
currentIndex += 1;
294+
renderRow();
295+
}
296+
});
297+
298+
const jumpToRow = () => {
299+
const target = Number.parseInt(jumpInput.value, 10);
300+
if (Number.isNaN(target) || target < 1 || target > rows.length) {
301+
loadStatus.textContent = `Enter a row number between 1 and ${rows.length}.`;
302+
loadStatus.classList.add('error');
303+
return;
304+
}
305+
306+
loadStatus.textContent = 'CSV loaded successfully.';
307+
loadStatus.classList.remove('error');
308+
currentIndex = target - 1;
309+
renderRow();
310+
};
311+
312+
jumpButton.addEventListener('click', jumpToRow);
313+
jumpInput.addEventListener('keydown', (event) => {
314+
if (event.key === 'Enter') {
315+
event.preventDefault();
316+
jumpToRow();
317+
}
318+
});
319+
})();
320+
</script>
321+
</body>
322+
323+
</html>

0 commit comments

Comments
 (0)