/** * Modul: Pinnwand / Notizen (Notes) * Zweck: Masonry-Grid mit farbigen Sticky Notes, Pin-Toggle, CRUD * Abhängigkeiten: /api.js, /router.js (window.oikos) */ import { api } from '/api.js'; import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const NOTE_COLORS = [ '#FFEB3B', '#FFD54F', '#A5D6A7', '#80DEEA', '#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF', ]; const NOTE_COLOR_NAMES = () => ({ '#FFEB3B': t('notes.colorYellow'), '#FFD54F': t('notes.colorAmber'), '#A5D6A7': t('notes.colorGreen'), '#80DEEA': t('notes.colorTeal'), '#90CAF9': t('notes.colorBlue'), '#CE93D8': t('notes.colorPurple'), '#FFAB91': t('notes.colorOrange'), '#FFFFFF': t('notes.colorWhite'), }); // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { notes: [], user: null, filterQuery: '' }; let _container = null; // -------------------------------------------------------- // Markdown-Light Renderer // -------------------------------------------------------- function renderMarkdownLight(text) { if (!text) return ''; return esc(text) .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*(.+?)\*/g, '$1') .replace(/^- (.+)$/gm, '• $1') .replace(/\n/g, '
'); } // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { _container = container; state.user = user; container.innerHTML = `

${t('notes.title')}

`; if (window.lucide) lucide.createIcons(); try { const res = await api.get('/notes'); state.notes = res.data; } catch (err) { console.error('[Notes] Laden fehlgeschlagen:', err); state.notes = []; window.oikos?.showToast(t('notes.loadError'), 'danger'); } const grid = container.querySelector('#notes-grid'); grid.addEventListener('click', async (e) => { const pinBtn = e.target.closest('[data-action="pin"]'); if (pinBtn) { e.stopPropagation(); await togglePin(parseInt(pinBtn.dataset.id, 10)); return; } const delBtn = e.target.closest('[data-action="delete"]'); if (delBtn) { e.stopPropagation(); await deleteNote(parseInt(delBtn.dataset.id, 10)); return; } const card = e.target.closest('.note-card[data-id]'); if (card) { const note = state.notes.find((n) => n.id === parseInt(card.dataset.id, 10)); if (note) openNoteModal({ mode: 'edit', note }); } }); renderGrid(); const addHandler = () => openNoteModal({ mode: 'create' }); _container.querySelector('#notes-add-btn').addEventListener('click', addHandler); _container.querySelector('#fab-new-note').addEventListener('click', addHandler); _container.querySelector('#notes-search').addEventListener('input', (e) => { state.filterQuery = e.target.value; renderGrid(); }); } // -------------------------------------------------------- // Grid // -------------------------------------------------------- function renderGrid() { const grid = _container.querySelector('#notes-grid'); if (!grid) return; const q = state.filterQuery.trim().toLowerCase(); const visible = q ? state.notes.filter((n) => (n.title || '').toLowerCase().includes(q) || (n.content || '').toLowerCase().includes(q) ) : state.notes; if (!visible.length) { const isFiltered = q.length > 0; grid.innerHTML = `
${isFiltered ? t('notes.noResultsTitle') : t('notes.emptyTitle')}
${isFiltered ? t('notes.noResultsDescription', { query: state.filterQuery }) : t('notes.emptyDescription')}
`; if (window.lucide) lucide.createIcons(); return; } grid.innerHTML = visible.map((n) => renderNoteCard(n)).join(''); if (window.lucide) lucide.createIcons(); stagger(grid.querySelectorAll('.note-card')); } function renderNoteCard(note) { const initials = note.creator_name ? note.creator_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : '?'; const textColor = isLightColor(note.color) ? 'rgba(0,0,0,0.8)' : '#ffffff'; return `
${note.title ? `
${esc(note.title)}
` : ''}
${renderMarkdownLight(note.content)}
`; } // -------------------------------------------------------- // Formatierungs-Helfer // -------------------------------------------------------- function applyFormat(textarea, format) { const start = textarea.selectionStart; const end = textarea.selectionEnd; const text = textarea.value; const sel = text.slice(start, end); let before, after, insert; switch (format) { case 'bold': before = '**'; after = '**'; insert = sel || 'Text'; break; case 'italic': before = '*'; after = '*'; insert = sel || 'Text'; break; case 'underline': before = ''; after = ''; insert = sel || 'Text'; break; case 'strikethrough': before = '~~'; after = '~~'; insert = sel || 'Text'; break; case 'code': before = '`'; after = '`'; insert = sel || 'Code'; break; case 'link': if (sel) { textarea.setRangeText(`[${sel}](url)`, start, end, 'select'); textarea.selectionStart = start + sel.length + 3; textarea.selectionEnd = start + sel.length + 6; } else { textarea.setRangeText('[Linktext](url)', start, end, 'select'); textarea.selectionStart = start + 1; textarea.selectionEnd = start + 9; } return; case 'heading': { const lineStart = text.lastIndexOf('\n', start - 1) + 1; const lineEnd = text.indexOf('\n', start); const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd); const match = line.match(/^(#{1,3})\s/); if (match && match[1].length < 3) { textarea.setRangeText('#' + line, lineStart, lineEnd === -1 ? text.length : lineEnd, 'end'); } else if (match && match[1].length >= 3) { textarea.setRangeText(line.replace(/^#{1,3}\s/, ''), lineStart, lineEnd === -1 ? text.length : lineEnd, 'end'); } else { textarea.setRangeText('## ' + line, lineStart, lineEnd === -1 ? text.length : lineEnd, 'end'); } return; } case 'list': { if (sel) { const lines = sel.split('\n').map((l) => l.startsWith('- ') ? l : `- ${l}`); textarea.setRangeText(lines.join('\n'), start, end, 'end'); return; } const lineStart = text.lastIndexOf('\n', start - 1) + 1; const currentLine = text.slice(lineStart, start); if (currentLine.trim() === '') { textarea.setRangeText('- ', start, start, 'end'); } else { textarea.setRangeText('\n- ', start, start, 'end'); } return; } case 'ordered-list': { if (sel) { const lines = sel.split('\n').map((l, i) => `${i + 1}. ${l.replace(/^\d+\.\s/, '')}`); textarea.setRangeText(lines.join('\n'), start, end, 'end'); return; } const lineStart = text.lastIndexOf('\n', start - 1) + 1; const currentLine = text.slice(lineStart, start); if (currentLine.trim() === '') { textarea.setRangeText('1. ', start, start, 'end'); } else { textarea.setRangeText('\n1. ', start, start, 'end'); } return; } case 'checklist': { if (sel) { const lines = sel.split('\n').map((l) => l.startsWith('- [ ] ') ? l : `- [ ] ${l}`); textarea.setRangeText(lines.join('\n'), start, end, 'end'); return; } const lineStart = text.lastIndexOf('\n', start - 1) + 1; const currentLine = text.slice(lineStart, start); if (currentLine.trim() === '') { textarea.setRangeText('- [ ] ', start, start, 'end'); } else { textarea.setRangeText('\n- [ ] ', start, start, 'end'); } return; } case 'quote': { if (sel) { const lines = sel.split('\n').map((l) => l.startsWith('> ') ? l : `> ${l}`); textarea.setRangeText(lines.join('\n'), start, end, 'end'); return; } const lineStart = text.lastIndexOf('\n', start - 1) + 1; const currentLine = text.slice(lineStart, start); if (currentLine.trim() === '') { textarea.setRangeText('> ', start, start, 'end'); } else { textarea.setRangeText('\n> ', start, start, 'end'); } return; } case 'divider': textarea.setRangeText('\n\n---\n\n', start, end, 'end'); return; default: return; } const replacement = `${before}${insert}${after}`; textarea.setRangeText(replacement, start, end, 'select'); // Selektion auf den eingefügten Text setzen (ohne Marker) textarea.selectionStart = start + before.length; textarea.selectionEnd = start + before.length + insert.length; } // -------------------------------------------------------- // Modal // -------------------------------------------------------- function openNoteModal({ mode, note = null }) { const isEdit = mode === 'edit'; const selColor = isEdit ? note.color : NOTE_COLORS[0]; const content = `
${NOTE_COLORS.map((c) => ` `).join('')}
`; openSharedModal({ title: isEdit ? t('notes.editNote') : t('notes.newNote'), content, size: 'md', onSave(panel) { // Farb-Swatch: Auswahl + ARIA + Keyboard (Roving Tabindex) function selectSwatch(target) { panel.querySelectorAll('.note-color-swatch').forEach((s) => { s.classList.remove('note-color-swatch--active'); s.setAttribute('aria-checked', 'false'); s.setAttribute('tabindex', '-1'); }); target.classList.add('note-color-swatch--active'); target.setAttribute('aria-checked', 'true'); target.setAttribute('tabindex', '0'); } panel.querySelectorAll('.note-color-swatch').forEach((sw) => { sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); }); sw.addEventListener('keydown', (e) => { const swatches = [...panel.querySelectorAll('.note-color-swatch')]; const idx = swatches.indexOf(sw); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); const next = swatches[(idx + 1) % swatches.length]; selectSwatch(next); next.focus(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); const prev = swatches[(idx - 1 + swatches.length) % swatches.length]; selectSwatch(prev); prev.focus(); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectSwatch(sw); } }); }); // Formatierungs-Toolbar const textarea = panel.querySelector('#note-content'); panel.querySelectorAll('.note-format-btn[data-format]').forEach((btn) => { btn.addEventListener('click', () => { applyFormat(textarea, btn.dataset.format); textarea.focus(); }); }); textarea.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { if (e.key === 'b') { e.preventDefault(); applyFormat(textarea, 'bold'); } if (e.key === 'i') { e.preventDefault(); applyFormat(textarea, 'italic'); } if (e.key === 'u') { e.preventDefault(); applyFormat(textarea, 'underline'); } } }); panel.querySelector('#note-modal-cancel').addEventListener('click', closeModal); panel.querySelector('#note-modal-save').addEventListener('click', async () => { const saveBtn = panel.querySelector('#note-modal-save'); const title = panel.querySelector('#note-title').value.trim() || null; const cnt = panel.querySelector('#note-content').value.trim(); const color = panel.querySelector('.note-color-swatch--active')?.dataset.color || NOTE_COLORS[0]; const pinned = panel.querySelector('#note-pinned').checked ? 1 : 0; if (!cnt) { window.oikos?.showToast(t('common.contentRequired'), 'error'); return; } saveBtn.disabled = true; saveBtn.textContent = '…'; try { if (mode === 'create') { const res = await api.post('/notes', { title, content: cnt, color, pinned }); state.notes.unshift(res.data); } else { const res = await api.put(`/notes/${note.id}`, { title, content: cnt, color, pinned }); const idx = state.notes.findIndex((n) => n.id === note.id); if (idx !== -1) state.notes[idx] = res.data; state.notes.sort((a, b) => b.pinned - a.pinned); } closeModal({ force: true }); renderGrid(); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); btnError(saveBtn); saveBtn.disabled = false; saveBtn.textContent = isEdit ? t('common.save') : t('common.create'); } }); }, }); } // -------------------------------------------------------- // Aktionen // -------------------------------------------------------- async function togglePin(id) { try { const res = await api.patch(`/notes/${id}/pin`, {}); const note = state.notes.find((n) => n.id === id); if (note) note.pinned = res.data.pinned; state.notes.sort((a, b) => b.pinned - a.pinned); renderGrid(); } catch (err) { window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); } } async function deleteNote(id) { closeModal({ force: true }); const note = state.notes.find((n) => n.id === id); state.notes = state.notes.filter((n) => n.id !== id); renderGrid(); vibrate([30, 50, 30]); let undone = false; window.oikos?.showToast(t('notes.deletedToast'), 'default', 5000, () => { undone = true; if (note) { state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned); renderGrid(); } }); setTimeout(async () => { if (undone) return; try { await api.delete(`/notes/${id}`); } catch (err) { if (note) { state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned); renderGrid(); } window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); } }, 5000); } // -------------------------------------------------------- // Hilfsfunktionen // -------------------------------------------------------- function isLightColor(hex) { if (!hex) return true; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return (r * 299 + g * 587 + b * 114) / 1000 > 150; }