// 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]; } }