/**
* 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 = `
`;
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 = `
`;
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;
}