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 ``;
}
return ``;
}
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.calendarHint')}
`);
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 `
`;
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: `
`,
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();
}