<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Photo intake</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--paper: #F1EEE6;
--paper-dim: #E6E2D6;
--ink: #23241F;
--ink-soft: #66655C;
--accent: #B8860B;
--accent-dim: #DDCB94;
--line: #C9C4B4;
--danger: #A3402F;
--radius: 3px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: 'IBM Plex Mono', monospace;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 48px 20px;
}
main {
width: 100%;
max-width: 620px;
}
.eyebrow {
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0 0 6px;
}
h1 {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 30px;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
.sub {
color: var(--ink-soft);
font-size: 14px;
line-height: 1.6;
margin: 0 0 36px;
max-width: 46ch;
}
.dropzone {
border: 1.5px dashed var(--line);
border-radius: var(--radius);
padding: 40px 24px;
text-align: center;
cursor: pointer;
background: var(--paper-dim);
transition: border-color 0.15s ease, background 0.15s ease;
}
.dropzone:hover,
.dropzone.drag {
border-color: var(--accent);
background: var(--accent-dim);
}
.dropzone-title {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 16px;
margin: 0 0 6px;
}
.dropzone-hint {
font-size: 12px;
color: var(--ink-soft);
margin: 0;
}
input[type="file"] { display: none; }
.manifest {
margin-top: 28px;
}
.manifest-head {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
margin-bottom: 4px;
}
.frame-list {
max-height: 260px;
overflow-y: auto;
}
.frame {
display: grid;
grid-template-columns: 44px 1fr auto auto;
gap: 12px;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid var(--paper-dim);
font-size: 13px;
}
.frame-num {
color: var(--accent);
font-weight: 500;
}
.frame-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.frame-size {
color: var(--ink-soft);
font-size: 12px;
}
.frame-flag {
font-size: 11px;
padding: 2px 6px;
border-radius: var(--radius);
background: var(--danger);
color: var(--paper);
letter-spacing: 0.04em;
}
.frame-ok { color: var(--ink-soft); font-size: 11px; }
.summary {
display: flex;
justify-content: space-between;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--line);
font-size: 13px;
}
.summary strong {
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
}
button#submit {
margin-top: 24px;
width: 100%;
padding: 14px;
background: var(--ink);
color: var(--paper);
border: none;
border-radius: var(--radius);
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.02em;
cursor: pointer;
transition: background 0.15s ease;
}
button#submit:disabled {
background: var(--line);
color: var(--ink-soft);
cursor: not-allowed;
}
button#submit:not(:disabled):hover {
background: var(--accent);
}
#status {
margin-top: 14px;
font-size: 13px;
min-height: 18px;
}
#status.error { color: var(--danger); }
#status.ok { color: #3F6B3B; }
.config-note {
margin-top: 40px;
padding: 12px 14px;
background: var(--paper-dim);
border-left: 2px solid var(--accent);
font-size: 12px;
color: var(--ink-soft);
line-height: 1.6;
}
.config-note code {
color: var(--ink);
}
</style>
</head>
<body>
<main>
<p class="eyebrow">Frame intake</p>
<h1>Submit photographs</h1>
<p class="sub">JPEG files only. Every file selected, valid or not, is included in your itemized total before you're asked to pay.</p>
<div class="dropzone" id="dropzone">
<p class="dropzone-title">Drop files here or click to browse</p>
<p class="dropzone-hint">Up to 1,000 files per submission</p>
<input type="file" id="fileInput" multiple accept="image/*">
</div>
<div class="manifest" id="manifest" style="display:none;">
<div class="manifest-head">
<span>Manifest</span>
<span id="frameCount">0 files</span>
</div>
<div class="frame-list" id="frameList"></div>
<div class="summary">
<span>Valid JPEGs: <strong id="validCount">0</strong></span>
<span>Flagged: <strong id="flaggedCount">0</strong></span>
</div>
</div>
<button id="submit" disabled>Select files to continue</button>
<p id="status"></p>
<div class="config-note">
Webhook target: set <code>WEBHOOK_URL</code> near the bottom of this file's script to your n8n production
URL from step 6 (e.g. <code>http://192.168.0.130:30109/webhook/photo-intake</code>) before this page can submit.
</div>
</main>
<script>
const WEBHOOK_URL = "PASTE_YOUR_N8N_PRODUCTION_WEBHOOK_URL_HERE";
const MAX_FILES = 1000;
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('fileInput');
const manifest = document.getElementById('manifest');
const frameList = document.getElementById('frameList');
const frameCount = document.getElementById('frameCount');
const validCount = document.getElementById('validCount');
const flaggedCount = document.getElementById('flaggedCount');
const submitBtn = document.getElementById('submit');
const status = document.getElementById('status');
let selectedFiles = [];
dropzone.addEventListener('click', () => fileInput.click());
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.classList.add('drag'); });
dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag'));
dropzone.addEventListener('drop', e => {
e.preventDefault();
dropzone.classList.remove('drag');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', e => handleFiles(e.target.files));
function handleFiles(fileListRaw) {
selectedFiles = Array.from(fileListRaw).slice(0, MAX_FILES);
renderManifest();
}
function isJpeg(file) {
return file.type === 'image/jpeg' || /\.jpe?g$/i.test(file.name);
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function renderManifest() {
if (selectedFiles.length === 0) {
manifest.style.display = 'none';
submitBtn.disabled = true;
submitBtn.textContent = 'Select files to continue';
status.textContent = '';
return;
}
manifest.style.display = 'block';
frameList.innerHTML = '';
let valid = 0;
selectedFiles.forEach((file, i) => {
const ok = isJpeg(file);
if (ok) valid++;
const row = document.createElement('div');
row.className = 'frame';
row.innerHTML = `
<span class="frame-num">${String(i + 1).padStart(3, '0')}</span>
<span class="frame-name">${file.name}</span>
<span class="frame-size">${formatSize(file.size)}</span>
${ok ? '<span class="frame-ok">jpeg</span>' : '<span class="frame-flag">not jpeg</span>'}
`;
frameList.appendChild(row);
});
frameCount.textContent = `${selectedFiles.length} file${selectedFiles.length === 1 ? '' : 's'}`;
validCount.textContent = valid;
flaggedCount.textContent = selectedFiles.length - valid;
if (selectedFiles.length >= MAX_FILES) {
status.textContent = `Capped at ${MAX_FILES} files. Submit this batch, then start a new one for the rest.`;
status.className = '';
} else {
status.textContent = '';
}
submitBtn.disabled = false;
submitBtn.textContent = `Continue with ${selectedFiles.length} file${selectedFiles.length === 1 ? '' : 's'}`;
}
submitBtn.addEventListener('click', async () => {
if (WEBHOOK_URL.startsWith('PASTE_YOUR')) {
status.textContent = 'Webhook URL not configured yet — see the note below the form.';
status.className = 'error';
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Uploading…';
status.textContent = '';
status.className = '';
const formData = new FormData();
selectedFiles.forEach(file => formData.append('files', file, file.name));
try {
const res = await fetch(WEBHOOK_URL, { method: 'POST', body: formData });
if (!res.ok) throw new Error(`Server responded ${res.status}`);
const data = await res.json().catch(() => ({}));
status.textContent = 'Upload received. You will get an email when your CSV is ready.';
status.className = 'ok';
submitBtn.textContent = 'Submitted';
} catch (err) {
status.textContent = `Upload failed: ${err.message}`;
status.className = 'error';
submitBtn.disabled = false;
submitBtn.textContent = `Continue with ${selectedFiles.length} file${selectedFiles.length === 1 ? '' : 's'}`;
}
});
</script>
</body>
</html>