Back to Body Journal
SOURCE / PINNED RELEASE

Made of little things.

Body Journal

Release
e8049f8da4e2…
Author-recorded commit
61c04bf0e8aa…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-6c4554844c7

Archive hash verified: 4369d778da046a8b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/photo-controls.ts
import { escape as esc, localDate, type Concern } from './case.js';
import { runtimeHasDomain } from './domain-availability.js';
import { importPhoto } from './photo-import.js';
import { MAX_PHOTOS, type CasePhoto } from './photo-record.js';

export function photoGallery(photos: CasePhoto[], concernId?: number): string {
  const items = concernId === undefined ? photos : photos.filter(p => p.concernId === concernId);
  return `<div class="photo-gallery">${items.map(p => `<button class="photo-tile" data-photo-open="${p.id}" aria-label="Edit photo: ${esc(p.name)}"><img src="${p.dataUrl}" alt="User-selected photo: ${esc(p.caption || p.name)}" /><span><strong>${esc(p.caption || p.name)}</strong><small>${p.width} × ${p.height} · ${p.includeInAi ? 'Included in AI review' : 'Not shared with AI'}</small></span></button>`).join('')}</div>`;
}
export class PhotoControls {
  private busy = false;
  private generation = 0;
  private stopped = false;
  private opened = '';
  constructor(private photos: CasePhoto[], private concerns: Concern[], private ui: {
    modal: (html: string) => void; toast: (message: string) => void;
    changed: () => void; validate: (photos: CasePhoto[]) => void;
  }) {
    document.addEventListener('click', this.click); document.addEventListener('input', this.input);
  }
  reset(): void { this.generation++; this.opened = ''; }
  async add(concernId: number | null, replaceId?: string): Promise<void> {
    if (this.busy) return;
    if (!runtimeHasDomain('fs')) { this.ui.toast('Photo import is unavailable in this host. Your written observations still work.'); return; }
    if (!replaceId && this.photos.length >= MAX_PHOTOS) { this.ui.toast('Up to four saved photos fit in this prototype. Remove or replace one first.'); return; }
    const generation = this.generation; this.busy = true;
    this.ui.modal('<h2>Choose a photo</h2><p role="status">Waiting for the host file picker, then preparing a saved copy…</p><p class="field-hint">JPEG, PNG or WebP, up to 10 MiB. Original files stay on your device. Copies are resized and compressed to fit host storage.</p>');
    try {
      const image = await importPhoto();
      if (this.stopped || generation !== this.generation) return;
      const previous = replaceId ? this.photos.find(p => p.id === replaceId) : undefined;
      if (replaceId && !previous) throw new Error('That photo was removed. Add it again to the current case.');
      if (concernId !== null && !this.concerns.some(c => c.id === concernId)) throw new Error('That observation was removed. Add the photo to the current case.');
      const photo: CasePhoto = { id: previous?.id || `photo-${Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('')}`, caption: previous?.caption || '', takenOn: previous?.takenOn || '', concernId, includeInAi: false, ...image };
      const next = previous ? this.photos.map(p => p === previous ? photo : p) : [...this.photos, photo];
      this.ui.validate(next);
      this.photos.splice(0, this.photos.length, ...next); this.ui.changed(); this.open(photo.id);
    } catch (error) {
      if (this.stopped || generation !== this.generation) return;
      // Host errors can contain paths. Only show our own bounded validation messages.
      const message = error instanceof Error && /^(Choose |This (file|photo|session)|Photo (could|processing)|That (photo|observation))/.test(error.message) ? error.message : 'No photo was added. The picker may have been cancelled, denied, or unavailable. You can try again.';
      this.ui.modal(`<h2>Photo not added</h2><p role="status">${esc(message)}</p><p class="field-hint">Your existing case and photos are unchanged.</p>`);
    } finally { this.busy = false; }
  }
  open(id: string): void {
    const p = this.photos.find(p => p.id === id); if (!p) return; this.opened = id;
    this.ui.modal(`<div class="photo-editor" data-photo-editor="${p.id}"><span class="section-caption">YOUR PHOTO · SAVED COPY</span><h2>Describe what it shows.</h2><img class="photo-preview" src="${p.dataUrl}" alt="${esc(p.caption || p.name)}" /><p class="field-hint">${esc(p.name)} · ${p.width} × ${p.height} pixels. Resized, JPEG-compressed; embedded metadata removed. Fine details may be lost. Keep the original for a clinician.</p><label class="field">Caption<textarea data-photo-field="caption" maxlength="600" rows="3" placeholder="What is shown, where, and what changed?">${esc(p.caption)}</textarea></label><label class="field">Date taken, if known<input data-photo-field="takenOn" type="date" max="${localDate()}" value="${esc(p.takenOn)}" /></label><label class="field">Attach to<select data-photo-field="concernId"><option value="" ${p.concernId === null ? 'selected' : ''}>Whole case</option>${this.concerns.map(c => `<option value="${c.id}" ${p.concernId === c.id ? 'selected' : ''}>${esc(c.location)}</option>`).join('')}</select></label><label class="check-field"><input data-photo-field="includeInAi" type="checkbox" ${p.includeInAi ? 'checked' : ''} /><span>Include this photo in AI request review</span></label><p class="field-hint">Nothing is sent until you review and send the request. Photo transmission requires a verified vision connection.</p><p class="field-hint">${runtimeHasDomain('storage') ? 'Saved with the case in host browser storage, without an encryption guarantee. Wait for “Session saved” in the header before reloading.' : 'Storage unavailable: this photo will be lost on reload.'}</p><div class="dialog-actions"><button class="outline-button" data-photo-replace="${p.id}">Replace photo</button><button class="text-button" data-photo-remove="${p.id}">Remove photo</button><button class="primary-button" data-photo-done>Done</button></div></div>`);
  }
  private input = (event: Event): void => {
    const input = event.target as HTMLInputElement, key = input.dataset.photoField;
    const p = this.photos.find(p => p.id === this.opened);
    if (!p || !key || !input.closest('[data-photo-editor]')) return;
    if (key === 'caption') p.caption = input.value.slice(0, 600);
    if (key === 'takenOn') { if (!input.validity.valid) return; p.takenOn = input.value; }
    if (key === 'concernId') p.concernId = input.value ? Number(input.value) : null;
    if (key === 'includeInAi') p.includeInAi = input.checked;
    this.ui.changed();
  };
  private click = (event: Event): void => {
    const el = (event.target as Element).closest<HTMLElement>('button'); if (!el) return;
    if (el.hasAttribute('data-photo-done')) document.querySelector<HTMLDialogElement>('#modal')?.close();
    if (el.dataset.photoOpen) this.open(el.dataset.photoOpen);
    if (el.dataset.photoRemove) {
      const index = this.photos.findIndex(p => p.id === el.dataset.photoRemove);
      if (index < 0) return;
      this.photos.splice(index, 1); document.querySelector<HTMLDialogElement>('#modal')?.close(); this.ui.changed(); this.ui.toast('Photo removed from this case.');
    }
    if (el.dataset.photoReplace) { const p = this.photos.find(p => p.id === el.dataset.photoReplace); if (p) void this.add(p.concernId, p.id); }
  };
  dispose(): void { this.stopped = true; this.reset(); document.removeEventListener('click', this.click); document.removeEventListener('input', this.input); }
}