import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; let state = { birthdays: [], upcoming: [], query: '', }; let _container = null; function initials(name) { return String(name || '') .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() || '') .join('') || '?'; } function ageNote(birthday) { if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age }); if (birthday.days_until === 1) return t('birthdays.ageNoteTomorrow', { age: birthday.next_age }); return t('birthdays.ageNoteDays', { age: birthday.next_age, days: birthday.days_until }); } function photoAvatar(birthday, extraClass = '') { if (birthday.photo_data) { return `${esc(birthday.name)}`; } return `${esc(initials(birthday.name))}`; } function filteredBirthdays() { const q = state.query.trim().toLowerCase(); const list = !q ? state.birthdays : state.birthdays.filter((birthday) => birthday.name.toLowerCase().includes(q) || (birthday.notes || '').toLowerCase().includes(q) ); return [...list].sort((a, b) => a.name.localeCompare(b.name)); } function suggestions() { const q = state.query.trim().toLowerCase(); if (!q) return []; return state.birthdays .filter((birthday) => birthday.name.toLowerCase().includes(q)) .slice(0, 6); } async function loadData() { const [allRes, upcomingRes] = await Promise.all([ api.get('/birthdays'), api.get('/birthdays/upcoming?limit=4'), ]); state.birthdays = allRes.data ?? []; state.upcoming = upcomingRes.data ?? []; } function renderSuggestions() { const dropdown = _container.querySelector('#birthdays-autocomplete'); if (!dropdown) return; const items = suggestions(); if (!items.length) { dropdown.hidden = true; dropdown.replaceChildren(); return; } dropdown.hidden = false; dropdown.replaceChildren(); dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => ` `).join('')); } function renderUpcoming() { const host = _container.querySelector('#birthdays-upcoming'); if (!host) return; if (!state.upcoming.length) { host.replaceChildren(); host.insertAdjacentHTML('beforeend', `
${t('birthdays.emptyTitle')}
${t('birthdays.emptyDescription')}
`); return; } host.replaceChildren(); host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
${photoAvatar(birthday)}
${esc(birthday.name)}
${esc(formatDate(birthday.next_birthday))}
${birthday.days_until === 0 ? esc(t('common.today')) : birthday.days_until === 1 ? esc(t('common.tomorrow')) : esc(`${birthday.days_until}d`)}
${esc(ageNote(birthday))}
`).join('')); } function renderList() { const host = _container.querySelector('#birthdays-list'); if (!host) return; const list = filteredBirthdays(); if (!list.length) { host.replaceChildren(); host.insertAdjacentHTML('beforeend', `
${t('birthdays.emptyTitle')}
${t('birthdays.emptyDescription')}
`); return; } host.replaceChildren(); host.insertAdjacentHTML('beforeend', list.map((birthday) => `
${photoAvatar(birthday)}
${esc(birthday.name)} ${esc(formatDate(birthday.next_birthday))}
${esc(formatDate(birthday.birth_date))}
${esc(ageNote(birthday))}
${birthday.notes ? `
${esc(birthday.notes)}
` : ''}
`).join('')); if (window.lucide) window.lucide.createIcons(); stagger(host.querySelectorAll('.birthday-item')); } function renderPage() { _container.replaceChildren(); _container.insertAdjacentHTML('beforeend', `

${t('birthdays.title')}

${t('birthdays.title')}

${t('birthdays.calendarHint')}

${t('birthdays.peopleTitle')}

${t('birthdays.peopleHint')}

`); renderUpcoming(); renderList(); renderSuggestions(); if (window.lucide) window.lucide.createIcons(); } function bindEvents() { const openCreate = () => openBirthdayModal({ mode: 'create' }); _container.querySelector('#birthdays-add-btn').addEventListener('click', openCreate); _container.querySelector('#fab-new-birthday').addEventListener('click', openCreate); const search = _container.querySelector('#birthdays-search'); search.addEventListener('input', (e) => { state.query = e.target.value; renderSuggestions(); renderList(); }); search.addEventListener('focus', renderSuggestions); search.addEventListener('blur', () => { setTimeout(() => { const dropdown = _container.querySelector('#birthdays-autocomplete'); if (dropdown) dropdown.hidden = true; }, 100); }); _container.querySelector('#birthdays-autocomplete').addEventListener('click', (e) => { const btn = e.target.closest('.birthday-suggestion'); if (!btn) return; state.query = btn.dataset.name; search.value = state.query; renderList(); renderSuggestions(); }); _container.querySelector('#birthdays-list').addEventListener('click', async (e) => { const action = e.target.closest('[data-action]'); if (!action) return; const id = Number(action.dataset.id); const birthday = state.birthdays.find((item) => item.id === id); if (!birthday) return; if (action.dataset.action === 'edit') { openBirthdayModal({ mode: 'edit', birthday }); return; } if (action.dataset.action === 'delete') { await deleteBirthday(id, birthday.name); } }); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(new Error('Failed to read image.')); reader.readAsDataURL(file); }); } function birthdayPreviewHtml(name, photoData) { if (photoData) return `${esc(name || '')}`; return `${esc(initials(name))}`; } function openBirthdayModal({ mode, birthday = null }) { const isEdit = mode === 'edit'; let photoData = birthday?.photo_data || null; openSharedModal({ title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'), content: `
${birthdayPreviewHtml(birthday?.name || '', photoData)}
${t('birthdays.photoOptional')}
${t('birthdays.calendarHint')}
`, size: 'md', onSave(panel) { const nameInput = panel.querySelector('#bd-name'); const preview = panel.querySelector('#birthday-preview'); const renderPreview = () => { preview.replaceChildren(); preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); }; nameInput.addEventListener('input', renderPreview); panel.querySelector('#bd-photo').addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; try { photoData = await readFileAsDataUrl(file); renderPreview(); } catch (err) { window.oikos?.showToast(err.message, 'danger'); } }); panel.querySelector('#bd-remove-photo').addEventListener('click', () => { photoData = null; panel.querySelector('#bd-photo').value = ''; renderPreview(); }); panel.querySelector('#bd-cancel').addEventListener('click', closeModal); panel.querySelector('#bd-delete')?.addEventListener('click', async () => { closeModal(); await deleteBirthday(birthday.id, birthday.name); }); panel.querySelector('#bd-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#bd-save'); const body = { name: panel.querySelector('#bd-name').value.trim(), birth_date: panel.querySelector('#bd-birth-date').value, notes: panel.querySelector('#bd-notes').value.trim(), photo_data: photoData, }; if (!body.name || !body.birth_date) { window.oikos?.showToast(t('birthdays.requiredFields'), 'warning'); return; } saveBtn.disabled = true; try { if (isEdit) { const res = await api.put(`/birthdays/${birthday.id}`, body); const idx = state.birthdays.findIndex((item) => item.id === birthday.id); if (idx !== -1) state.birthdays[idx] = res.data; window.oikos?.showToast(t('birthdays.updatedToast'), 'success'); } else { const res = await api.post('/birthdays', body); state.birthdays.push(res.data); window.oikos?.showToast(t('birthdays.createdToast'), 'success'); } state.birthdays.sort((a, b) => a.name.localeCompare(b.name)); const upcomingRes = await api.get('/birthdays/upcoming?limit=4'); state.upcoming = upcomingRes.data ?? []; renderUpcoming(); renderSuggestions(); renderList(); closeModal({ force: true }); } catch (err) { window.oikos?.showToast(err.message, 'danger'); saveBtn.disabled = false; } }); }, }); } async function deleteBirthday(id, name) { if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return; await api.delete(`/birthdays/${id}`); state.birthdays = state.birthdays .filter((birthday) => birthday.id !== id) .sort((a, b) => a.name.localeCompare(b.name)); state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id); renderUpcoming(); renderSuggestions(); renderList(); window.oikos?.showToast(t('birthdays.deletedToast'), 'success'); } export async function render(container) { _container = container; await loadData(); renderPage(); bindEvents(); }