worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 5840 bytes

// pins.js — pin search and checks (pure) + the Pins panel (list, add by clicking the map, edit, delete)
import { api, el, toast } from './ui.js';
import { fmtLatLon } from './units.js';

export function filterPins(pins, query) {
  const q = query.trim().toLowerCase();
  const hit = p => [p.name, p.lore, p.note].some(v => v && v.toLowerCase().includes(q));
  return (q ? pins.filter(hit) : [...pins]).sort((a, b) => a.name.localeCompare(b.name));
}

export const loreWarning = (lore, loreIds) =>
  (lore && !loreIds.includes(lore) ? `“${lore}” is not a known lore id — saved anyway` : null);

export const pinPayload = f => ({ name: f.name.trim(), lat: Number(f.lat), lon: Number(f.lon), lore: f.lore.trim() || null,
  note: f.note.trim(), epoch: (f.epoch ?? '').trim() || null });
export const epochIds = loreIds => loreIds.filter(id => /^epoch-[a-z0-9-]+$/.test(id));

export class PinsPanel {
  constructor(box, loreIds, { onPins, onFly, onPlacing, onDraft = () => {} }) {
    Object.assign(this, { box, loreIds, onPins, onFly, onPlacing, onDraft });
    this.pins = [];
    this.query = '';
    this.form = null;       // {id?, name, lat, lon, lore, note} while adding or editing
    this.placing = false;
  }

  async load() {
    try {
      this.pins = (await api('api/pins')).pins;
    } catch (e) {
      toast(`Pins: ${e.message}`);
    }
    this.onPins(this.pins);
    this.render();
  }

  setPlacing(on) {
    this.placing = on;
    this.onPlacing(on);
    this.render();
  }

  placeAt(p) {
    this.placing = false;
    this.onPlacing(false);
    this.form = { name: '', lat: p.lat, lon: p.lon, lore: '', note: '', epoch: '' };
    this.onDraft({ lat: p.lat, lon: p.lon });
    this.render();
  }

  closeForm() {
    this.form = null;
    this.onDraft(null);
    this.render();
  }

  edit(pin) {
    this.form = { ...pin, lore: pin.lore || '', note: pin.note || '', epoch: pin.epoch || '' };
    this.render();
  }

  async save() {
    const f = this.form;
    try {
      if (f.id) await api(`api/pins/${f.id}`, { method: 'PUT', body: pinPayload(f) });
      else await api('api/pins', { method: 'POST', body: pinPayload(f) });
      this.closeForm();
      await this.load();
    } catch (e) {
      toast(`Could not save the pin: ${e.message}`);
    }
  }

  async remove(pin) {
    if (!confirm(`Delete pin “${pin.name}”?`)) return;
    try {
      await api(`api/pins/${pin.id}`, { method: 'DELETE' });
      await this.load();
    } catch (e) {
      toast(`Could not delete the pin: ${e.message}`);
    }
  }

  render() {
    this.box.replaceChildren(...(this.form ? this.#formView() : this.#listView()));
  }

  #listView() {
    const list = el('div');
    const search = el('input', { type: 'search', placeholder: 'Search pins', value: this.query });
    search.addEventListener('input', () => { this.query = search.value; this.#fill(list); });
    this.#fill(list);
    return [
      el('div', {}, el('button', { class: this.placing ? 'on' : null, onclick: () => this.setPlacing(!this.placing) },
        this.placing ? 'Click the map… (Esc cancels)' : '+ Add pin'), ' ', search),
      list,
    ];
  }

  #fill(list) {
    const item = p => el('div', { class: 'pin-item', onclick: () => this.onFly(p) },
      el('b', {}, p.name), p.lore ? el('span', { class: 'muted' }, ` · ${p.lore}`) : null,
      p.epoch ? el('span', { class: 'muted' }, ` · ${p.epoch.replace(/^epoch-/, '')}`) : null, el('br'),
      el('span', { class: 'muted' }, fmtLatLon(p.lat, p.lon)), ' ',
      el('button', { onclick: e => { e.stopPropagation(); this.edit(p); } }, 'Edit'), ' ',
      el('button', { onclick: e => { e.stopPropagation(); this.remove(p); } }, 'Delete'),
      loreWarning(p.lore, this.loreIds) ? el('div', { class: 'warn' }, loreWarning(p.lore, this.loreIds)) : null);
    const shown = filterPins(this.pins, this.query);
    list.replaceChildren(...(shown.length ? shown.map(item)
      : [el('p', { class: 'muted' }, this.pins.length ? 'No pins match.' : 'No pins yet.')]));
  }

  #formView() {
    const f = this.form;
    const warn = el('div', { class: 'warn' }, loreWarning(f.lore.trim(), this.loreIds) ?? '');
    const field = (key, tag, attrs, ...kids) => {
      const i = el(tag, attrs, ...kids);
      i.value = f[key];
      i.addEventListener('input', () => {
        f[key] = i.value;
        if (key === 'lore') warn.textContent = loreWarning(f.lore.trim(), this.loreIds) ?? '';
      });
      return i;
    };
    const name = field('name', 'input', { required: true, maxlength: 120 });
    queueMicrotask(() => name.focus());
    const form = el('form', { class: 'pin' },
      el('p', { class: 'muted' }, f.id ? `Edit ${f.id}` : 'New pin', ' · ', fmtLatLon(f.lat, f.lon)),
      el('label', {}, 'Name', name),
      el('label', {}, 'Lore id (optional)', field('lore', 'input', { list: 'lore-ids', placeholder: 'kebab-case id from lore/' })),
      warn,
      el('datalist', { id: 'lore-ids' }, this.loreIds.map(id => el('option', { value: id }))),
      el('label', {}, 'Note (optional)', field('note', 'textarea', { rows: 4, maxlength: 2000 })),
      el('label', {}, 'Epoch (optional)', field('epoch', 'select', {},
        el('option', { value: '' }, '— any —'), ...epochIds(this.loreIds).map(id => el('option', { value: id }, id.replace(/^epoch-/, ''))))),
      el('div', { class: 'coords' }, el('label', {}, 'Lat', field('lat', 'input', { type: 'number', step: 'any', min: -90, max: 90 })),
        el('label', {}, 'Lon', field('lon', 'input', { type: 'number', step: 'any', min: -180, max: 180 }))),
      el('div', {}, el('button', { type: 'submit' }, 'Save'), ' ',
        el('button', { type: 'button', onclick: () => this.closeForm() }, 'Cancel')));
    form.addEventListener('submit', e => { e.preventDefault(); this.save(); });
    return [form];
  }
}