diff options
Diffstat (limited to 'viewer/js/pins.js')
| -rw-r--r-- | viewer/js/pins.js | 143 |
1 files changed, 143 insertions, 0 deletions
diff --git a/viewer/js/pins.js b/viewer/js/pins.js new file mode 100644 index 0000000..4afc16b --- /dev/null +++ b/viewer/js/pins.js @@ -0,0 +1,143 @@ +// 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]; + } +} |
