<!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>
Scroll to Top