1133 lines
40 KiB
JavaScript
1133 lines
40 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
// --- APPLICATION STATE ---
|
|
let state = {
|
|
friends: [],
|
|
meetings: [],
|
|
selectedFriend: null,
|
|
activeTab: 'dashboard'
|
|
};
|
|
|
|
// --- SELECT DOM ELEMENTS ---
|
|
const DOM = {
|
|
// Navigation & Tabs
|
|
navItems: document.querySelectorAll('.nav-item'),
|
|
tabPanes: document.querySelectorAll('.tab-pane'),
|
|
pageTitle: document.getElementById('page-title'),
|
|
|
|
// Dashboard Stats
|
|
statTotalFriends: document.getElementById('stat-total-friends'),
|
|
statMeetingsMonth: document.getElementById('stat-meetings-month'),
|
|
statAvgHonor: document.getElementById('stat-avg-honor'),
|
|
statUpcomingBirthdays: document.getElementById('stat-upcoming-birthdays'),
|
|
|
|
// Dashboard Panels
|
|
urgentContactsList: document.getElementById('urgent-contacts-list'),
|
|
upcomingBirthdaysList: document.getElementById('upcoming-birthdays-list'),
|
|
quickLogForm: document.getElementById('quick-log-form'),
|
|
quickFriendSelect: document.getElementById('quick-friend-select'),
|
|
quickDate: document.getElementById('quick-date'),
|
|
quickActivity: document.getElementById('quick-activity'),
|
|
quickMood: document.getElementById('quick-mood'),
|
|
quickDetails: document.getElementById('quick-details'),
|
|
|
|
// Friends Directory
|
|
friendsSearch: document.getElementById('friends-search'),
|
|
btnOpenAddModal: document.getElementById('btn-add-friend-modal'),
|
|
friendsGrid: document.getElementById('friends-grid'),
|
|
|
|
// Add Friend Modal
|
|
addFriendModal: document.getElementById('add-friend-modal'),
|
|
addFriendForm: document.getElementById('add-friend-form'),
|
|
btnCloseAddModal: document.getElementById('btn-close-add-modal'),
|
|
btnCancelAddModal: document.getElementById('btn-cancel-add-modal'),
|
|
|
|
// Friend Detail Modal
|
|
detailModal: document.getElementById('friend-detail-modal'),
|
|
btnCloseDetailModal: document.getElementById('btn-close-detail-modal'),
|
|
btnDeleteFriend: document.getElementById('btn-delete-friend'),
|
|
btnEditFriendTrigger: document.getElementById('btn-edit-friend-trigger'),
|
|
detailAvatar: document.getElementById('detail-avatar'),
|
|
detailName: document.getElementById('detail-name'),
|
|
detailRelationshipBadge: document.getElementById('detail-badge-relationship'),
|
|
detailHonorValue: document.getElementById('detail-honor-value'),
|
|
btnHonorPlus: document.getElementById('btn-honor-plus'),
|
|
btnHonorMinus: document.getElementById('btn-honor-minus'),
|
|
detailBirthday: document.getElementById('detail-birthday'),
|
|
detailContact: document.getElementById('detail-contact'),
|
|
detailAddress: document.getElementById('detail-address'),
|
|
detailFamily: document.getElementById('detail-family'),
|
|
detailLife: document.getElementById('detail-life'),
|
|
detailHobbies: document.getElementById('detail-hobbies'),
|
|
detailMilestones: document.getElementById('detail-milestones'),
|
|
detailFood: document.getElementById('detail-food'),
|
|
detailNotes: document.getElementById('detail-notes'),
|
|
detailTopicsList: document.getElementById('detail-topics-list'),
|
|
detailMeetingsTimeline: document.getElementById('detail-meetings-timeline'),
|
|
addTopicForm: document.getElementById('add-topic-form'),
|
|
newTopicInput: document.getElementById('new-topic-input'),
|
|
profileLogMeetingForm: document.getElementById('profile-log-meeting-form'),
|
|
profileMeetingDate: document.getElementById('profile-meeting-date'),
|
|
profileMeetingMood: document.getElementById('profile-meeting-mood'),
|
|
profileMeetingActivity: document.getElementById('profile-meeting-activity'),
|
|
profileMeetingDetails: document.getElementById('profile-meeting-details'),
|
|
|
|
// Edit Friend Modal
|
|
editFriendModal: document.getElementById('edit-friend-modal'),
|
|
editFriendForm: document.getElementById('edit-friend-form'),
|
|
btnCloseEditModal: document.getElementById('btn-close-edit-modal'),
|
|
btnCancelEditModal: document.getElementById('btn-cancel-edit-modal'),
|
|
editId: document.getElementById('edit-id'),
|
|
editName: document.getElementById('edit-name'),
|
|
editBirthday: document.getElementById('edit-birthday'),
|
|
editContact: document.getElementById('edit-contact'),
|
|
editRelationship: document.getElementById('edit-relationship'),
|
|
editFamily: document.getElementById('edit-family'),
|
|
editAddress: document.getElementById('edit-address'),
|
|
editHonor: document.getElementById('edit-honor'),
|
|
editJob: document.getElementById('edit-job'),
|
|
editLife: document.getElementById('edit-life'),
|
|
editHobbies: document.getElementById('edit-hobbies'),
|
|
editMilestones: document.getElementById('edit-milestones'),
|
|
editFood: document.getElementById('edit-food'),
|
|
editNotes: document.getElementById('edit-notes'),
|
|
|
|
// Obsidian Import Tab
|
|
obsidianImportForm: document.getElementById('obsidian-import-form'),
|
|
importFilename: document.getElementById('import-filename'),
|
|
importContent: document.getElementById('import-content'),
|
|
importResult: document.getElementById('import-result')
|
|
};
|
|
|
|
// --- INITIALIZATION ---
|
|
async function init() {
|
|
setupTabNavigation();
|
|
setupEventListeners();
|
|
setFormDefaultDates();
|
|
|
|
// Initial fetch of data
|
|
await refreshAllData();
|
|
}
|
|
|
|
// --- DATA FETCHING & SYNC ---
|
|
async function refreshAllData() {
|
|
showGlobalLoaders();
|
|
await Promise.all([
|
|
fetchFriends(),
|
|
fetchMeetings()
|
|
]);
|
|
renderDashboard();
|
|
renderFriendsDirectory();
|
|
|
|
// If a friend modal is open, refresh it as well
|
|
if (state.selectedFriend) {
|
|
await refreshFriendDetails(state.selectedFriend.id);
|
|
}
|
|
}
|
|
|
|
async function fetchFriends() {
|
|
try {
|
|
const res = await fetch('/api/friends');
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Freunde');
|
|
state.friends = await res.json();
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Konnte Freunde nicht laden: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function fetchMeetings() {
|
|
try {
|
|
const res = await fetch('/api/meetings');
|
|
if (!res.ok) throw new Error('Fehler beim Laden der Treffen');
|
|
state.meetings = await res.json();
|
|
} catch (err) {
|
|
console.error(err);
|
|
// We fall back to empty meetings list gracefully if route doesn't work yet
|
|
state.meetings = [];
|
|
}
|
|
}
|
|
|
|
async function refreshFriendDetails(friendId) {
|
|
try {
|
|
const res = await fetch(`/api/friends/${friendId}`);
|
|
if (!res.ok) throw new Error('Konnte Details nicht laden');
|
|
state.selectedFriend = await res.json();
|
|
populateFriendDetails(state.selectedFriend);
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Fehler beim Aktualisieren der Details: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// --- SPA ROUTING / TAB NAVIGATION ---
|
|
function setupTabNavigation() {
|
|
// URL Hash handling
|
|
window.addEventListener('hashchange', handleHashRoute);
|
|
|
|
// Sidebar clicks
|
|
DOM.navItems.forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const tab = item.getAttribute('data-tab');
|
|
window.location.hash = tab;
|
|
});
|
|
});
|
|
|
|
// Handle initial load route
|
|
handleHashRoute();
|
|
}
|
|
|
|
function handleHashRoute() {
|
|
let tab = window.location.hash.replace('#', '') || 'dashboard';
|
|
|
|
// Validate tab
|
|
const validTabs = ['dashboard', 'friends', 'import'];
|
|
if (!validTabs.includes(tab)) tab = 'dashboard';
|
|
|
|
state.activeTab = tab;
|
|
|
|
// Toggle nav items
|
|
DOM.navItems.forEach(item => {
|
|
if (item.getAttribute('data-tab') === tab) {
|
|
item.classList.add('active');
|
|
} else {
|
|
item.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Toggle panes
|
|
DOM.tabPanes.forEach(pane => {
|
|
if (pane.id === `tab-${tab}`) {
|
|
pane.classList.add('active');
|
|
} else {
|
|
pane.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// Update Header Title
|
|
const titles = {
|
|
dashboard: 'Dashboard',
|
|
friends: 'Freunde-Verzeichnis',
|
|
import: 'Obsidian Markdown Import'
|
|
};
|
|
DOM.pageTitle.textContent = titles[tab] || 'Dashboard';
|
|
}
|
|
|
|
// --- EVENT LISTENERS ---
|
|
function setupEventListeners() {
|
|
// --- Modals Toggle ---
|
|
DOM.btnOpenAddModal.addEventListener('click', () => openModal(DOM.addFriendModal));
|
|
DOM.btnCloseAddModal.addEventListener('click', () => closeModal(DOM.addFriendModal));
|
|
DOM.btnCancelAddModal.addEventListener('click', () => closeModal(DOM.addFriendModal));
|
|
|
|
DOM.btnCloseDetailModal.addEventListener('click', () => {
|
|
closeModal(DOM.detailModal);
|
|
state.selectedFriend = null;
|
|
});
|
|
|
|
DOM.btnCloseEditModal.addEventListener('click', () => closeModal(DOM.editFriendModal));
|
|
DOM.btnCancelEditModal.addEventListener('click', () => closeModal(DOM.editFriendModal));
|
|
|
|
// Close modals on clicking outside container
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal-backdrop')) {
|
|
closeModal(e.target);
|
|
if (e.target.id === 'friend-detail-modal') {
|
|
state.selectedFriend = null;
|
|
}
|
|
}
|
|
});
|
|
|
|
// --- Search Filter ---
|
|
DOM.friendsSearch.addEventListener('input', (e) => {
|
|
renderFriendsDirectory(e.target.value);
|
|
});
|
|
|
|
// --- Add Friend Form Submit ---
|
|
DOM.addFriendForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const friendData = {
|
|
name: document.getElementById('add-name').value.trim(),
|
|
birthday: document.getElementById('add-birthday').value,
|
|
contact: document.getElementById('add-contact').value.trim(),
|
|
relationship_status: document.getElementById('add-relationship').value.trim(),
|
|
family: document.getElementById('add-family').value.trim(),
|
|
address: document.getElementById('add-address').value.trim(),
|
|
honor: parseInt(document.getElementById('add-honor').value, 10) || 0,
|
|
job: document.getElementById('add-job').value.trim(),
|
|
life_situation: document.getElementById('add-life').value.trim(),
|
|
hobbies: document.getElementById('add-hobbies').value.trim(),
|
|
milestones: document.getElementById('add-milestones').value.trim(),
|
|
food_preferences: document.getElementById('add-food').value.trim(),
|
|
random_notes: document.getElementById('add-notes').value.trim()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/friends', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(friendData)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Anlegen');
|
|
|
|
DOM.addFriendForm.reset();
|
|
setFormDefaultDates();
|
|
closeModal(DOM.addFriendModal);
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
alert('Konnte Freund nicht speichern: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Edit Friend Form Submit ---
|
|
DOM.editFriendForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const friendId = DOM.editId.value;
|
|
const friendData = {
|
|
name: DOM.editName.value.trim(),
|
|
birthday: DOM.editBirthday.value,
|
|
contact: DOM.editContact.value.trim(),
|
|
relationship_status: DOM.editRelationship.value.trim(),
|
|
family: DOM.editFamily.value.trim(),
|
|
address: DOM.editAddress.value.trim(),
|
|
honor: parseInt(DOM.editHonor.value, 10) || 0,
|
|
job: DOM.editJob.value.trim(),
|
|
life_situation: DOM.editLife.value.trim(),
|
|
hobbies: DOM.editHobbies.value.trim(),
|
|
milestones: DOM.editMilestones.value.trim(),
|
|
food_preferences: DOM.editFood.value.trim(),
|
|
random_notes: DOM.editNotes.value.trim()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(`/api/friends/${friendId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(friendData)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
|
|
|
closeModal(DOM.editFriendModal);
|
|
await refreshAllData();
|
|
if (state.selectedFriend) {
|
|
await refreshFriendDetails(friendId);
|
|
}
|
|
} catch (err) {
|
|
alert('Konnte Profildaten nicht aktualisieren: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Delete Friend Trigger ---
|
|
DOM.btnDeleteFriend.addEventListener('click', async () => {
|
|
if (!state.selectedFriend) return;
|
|
|
|
const confirmDelete = confirm(`Bist du sicher, dass du ${state.selectedFriend.name} aus dem CRM löschen willst? Alle Treffen und Notizen gehen verloren.`);
|
|
if (!confirmDelete) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/friends/${state.selectedFriend.id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Löschen fehlgeschlagen');
|
|
|
|
closeModal(DOM.detailModal);
|
|
state.selectedFriend = null;
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Edit Modal Prefill & Trigger ---
|
|
DOM.btnEditFriendTrigger.addEventListener('click', () => {
|
|
if (!state.selectedFriend) return;
|
|
const f = state.selectedFriend;
|
|
|
|
DOM.editId.value = f.id;
|
|
DOM.editName.value = f.name || '';
|
|
DOM.editBirthday.value = f.birthday || '';
|
|
DOM.editContact.value = f.contact || '';
|
|
DOM.editRelationship.value = f.relationship_status || '';
|
|
DOM.editFamily.value = f.family || '';
|
|
DOM.editAddress.value = f.address || '';
|
|
DOM.editHonor.value = f.honor || 0;
|
|
DOM.editJob.value = f.job || '';
|
|
DOM.editLife.value = f.life_situation || '';
|
|
DOM.editHobbies.value = f.hobbies || '';
|
|
DOM.editMilestones.value = f.milestones || '';
|
|
DOM.editFood.value = f.food_preferences || '';
|
|
DOM.editNotes.value = f.random_notes || '';
|
|
|
|
openModal(DOM.editFriendModal);
|
|
});
|
|
|
|
// --- Honor Score +/- Clickers ---
|
|
DOM.btnHonorPlus.addEventListener('click', () => adjustHonor(1));
|
|
DOM.btnHonorMinus.addEventListener('click', () => adjustHonor(-1));
|
|
|
|
// --- Log Meeting Form (Dashboard) Submit ---
|
|
DOM.quickLogForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const meetingData = {
|
|
friend_id: parseInt(DOM.quickFriendSelect.value, 10),
|
|
date: DOM.quickDate.value,
|
|
activity: DOM.quickActivity.value.trim(),
|
|
mood: DOM.quickMood.value.trim(),
|
|
details: DOM.quickDetails.value.trim()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/meetings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(meetingData)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Eintrag fehlgeschlagen');
|
|
|
|
DOM.quickLogForm.reset();
|
|
setFormDefaultDates();
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
alert('Konnte Treffen nicht eintragen: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Log Meeting Form (Profile Modal) Submit ---
|
|
DOM.profileLogMeetingForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!state.selectedFriend) return;
|
|
|
|
const meetingData = {
|
|
friend_id: state.selectedFriend.id,
|
|
date: DOM.profileMeetingDate.value,
|
|
activity: DOM.profileMeetingActivity.value.trim(),
|
|
mood: DOM.profileMeetingMood.value.trim(),
|
|
details: DOM.profileMeetingDetails.value.trim()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/meetings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(meetingData)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Eintrag fehlgeschlagen');
|
|
|
|
DOM.profileLogMeetingForm.reset();
|
|
setFormDefaultDates();
|
|
await refreshFriendDetails(state.selectedFriend.id);
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
alert('Konnte Treffen nicht eintragen: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Add Topic Form Submit ---
|
|
DOM.addTopicForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
if (!state.selectedFriend) return;
|
|
|
|
const topicData = {
|
|
friend_id: state.selectedFriend.id,
|
|
topic: DOM.newTopicInput.value.trim()
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/topics', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(topicData)
|
|
});
|
|
|
|
if (!res.ok) throw new Error('Hinzufügen fehlgeschlagen');
|
|
|
|
DOM.newTopicInput.value = '';
|
|
await refreshFriendDetails(state.selectedFriend.id);
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
alert('Konnte Thema nicht hinzufügen: ' + err.message);
|
|
}
|
|
});
|
|
|
|
// --- Obsidian Import Submit ---
|
|
DOM.obsidianImportForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const importData = {
|
|
filename: DOM.importFilename.value.trim() || 'Imported_Obsidian_Profile.md',
|
|
content: DOM.importContent.value
|
|
};
|
|
|
|
try {
|
|
DOM.importResult.classList.remove('hidden');
|
|
DOM.importResult.innerHTML = '<div class="loading-spinner"></div><p style="text-align:center;">Analysiere Markdown-Struktur...</p>';
|
|
|
|
const res = await fetch('/api/import-obsidian', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(importData)
|
|
});
|
|
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Import fehlgeschlagen');
|
|
|
|
DOM.importResult.innerHTML = `
|
|
<div class="import-success-alert">
|
|
<i data-lucide="check-circle" class="alert-icon success"></i>
|
|
<div class="alert-body">
|
|
<h4>${data.name} erfolgreich importiert!</h4>
|
|
<p>${data.message}</p>
|
|
<button class="btn btn-primary btn-sm" id="btn-view-imported" data-id="${data.id}">
|
|
<i data-lucide="external-link"></i> Profil von ${data.name} öffnen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
lucide.createIcons();
|
|
DOM.obsidianImportForm.reset();
|
|
|
|
// Bind quick view button
|
|
document.getElementById('btn-view-imported').addEventListener('click', (e) => {
|
|
const friendId = e.currentTarget.getAttribute('data-id');
|
|
openFriendDetail(friendId);
|
|
});
|
|
|
|
await refreshAllData();
|
|
} catch (err) {
|
|
DOM.importResult.innerHTML = `
|
|
<div class="import-error-alert">
|
|
<i data-lucide="alert-triangle" class="alert-icon error"></i>
|
|
<div class="alert-body">
|
|
<h4>Fehler beim Importieren</h4>
|
|
<p>${err.message}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
lucide.createIcons();
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- DYNAMIC RENDERING: DASHBOARD ---
|
|
function renderDashboard() {
|
|
const friends = state.friends;
|
|
const meetings = state.meetings;
|
|
|
|
// 1. STATS CALCULATIONS
|
|
// Stat: Total Friends
|
|
DOM.statTotalFriends.textContent = friends.length;
|
|
|
|
// Stat: Meetings this month
|
|
const today = new Date();
|
|
const currentYear = today.getFullYear();
|
|
const currentMonth = today.getMonth(); // 0-indexed
|
|
|
|
const meetingsThisMonth = meetings.filter(m => {
|
|
if (!m.date) return false;
|
|
const mDate = new Date(m.date);
|
|
return mDate.getFullYear() === currentYear && mDate.getMonth() === currentMonth;
|
|
}).length;
|
|
DOM.statMeetingsMonth.textContent = meetingsThisMonth;
|
|
|
|
// Stat: Average Honor
|
|
const totalHonor = friends.reduce((sum, f) => sum + (f.honor || 0), 0);
|
|
const avgHonor = friends.length > 0 ? Math.round(totalHonor / friends.length) : 0;
|
|
DOM.statAvgHonor.textContent = avgHonor;
|
|
|
|
// Stat: Upcoming Birthdays count
|
|
const upcomingBdays = friends.filter(f => {
|
|
if (!f.birthday) return false;
|
|
const days = getDaysUntilBirthday(f.birthday);
|
|
return days !== null && days >= 0 && days <= 30;
|
|
});
|
|
DOM.statUpcomingBirthdays.textContent = upcomingBdays.length;
|
|
|
|
// 2. RENDER URGENT CONTACTS ("Lange nicht gesehen")
|
|
DOM.urgentContactsList.innerHTML = '';
|
|
if (friends.length === 0) {
|
|
DOM.urgentContactsList.innerHTML = '<p class="empty-list-desc">Noch keine Freunde eingetragen.</p>';
|
|
} else {
|
|
// Sort friends: those never met first, then those met longest ago
|
|
const sortedUrgent = [...friends].sort((a, b) => {
|
|
if (!a.last_meeting_date && !b.last_meeting_date) return a.name.localeCompare(b.name);
|
|
if (!a.last_meeting_date) return -1; // a first
|
|
if (!b.last_meeting_date) return 1; // b first
|
|
return new Date(a.last_meeting_date) - new Date(b.last_meeting_date);
|
|
});
|
|
|
|
// Show top 5 urgent contacts
|
|
sortedUrgent.slice(0, 5).forEach(f => {
|
|
const item = document.createElement('div');
|
|
item.className = 'list-item-glass';
|
|
|
|
let subText = '';
|
|
let warningClass = '';
|
|
|
|
if (!f.last_meeting_date) {
|
|
subText = 'Noch nie getroffen';
|
|
warningClass = 'urgent';
|
|
} else {
|
|
const days = getDaysSince(f.last_meeting_date);
|
|
subText = `Zuletzt vor ${days} Tagen getroffen (${formatGermanDate(f.last_meeting_date)})`;
|
|
if (days > 60) warningClass = 'urgent';
|
|
else if (days > 30) warningClass = 'warning';
|
|
}
|
|
|
|
const initials = getInitials(f.name);
|
|
|
|
item.innerHTML = `
|
|
<div class="list-item-left">
|
|
<div class="list-avatar">${initials}</div>
|
|
<div class="list-meta">
|
|
<span class="list-item-name">${f.name}</span>
|
|
<span class="list-item-sub ${warningClass}"><i data-lucide="clock"></i> ${subText}</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm btn-view-profile" data-id="${f.id}">
|
|
<i data-lucide="eye"></i> Profil
|
|
</button>
|
|
`;
|
|
DOM.urgentContactsList.appendChild(item);
|
|
});
|
|
|
|
// Bind click triggers
|
|
DOM.urgentContactsList.querySelectorAll('.btn-view-profile').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const id = e.currentTarget.getAttribute('data-id');
|
|
openFriendDetail(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 3. RENDER UPCOMING BIRTHDAYS PANEL
|
|
DOM.upcomingBirthdaysList.innerHTML = '';
|
|
if (upcomingBdays.length === 0) {
|
|
DOM.upcomingBirthdaysList.innerHTML = '<p class="empty-list-desc">Keine Geburtstage in den nächsten 30 Tagen.</p>';
|
|
} else {
|
|
// Sort upcoming by closest day
|
|
upcomingBdays.sort((a, b) => getDaysUntilBirthday(a.birthday) - getDaysUntilBirthday(b.birthday));
|
|
|
|
upcomingBdays.forEach(f => {
|
|
const days = getDaysUntilBirthday(f.birthday);
|
|
const age = getAgeTurning(f.birthday);
|
|
const item = document.createElement('div');
|
|
item.className = 'list-item-glass';
|
|
|
|
let dayString = '';
|
|
if (days === 0) {
|
|
dayString = 'Heute! 🎉';
|
|
} else if (days === 1) {
|
|
dayString = 'Morgen!';
|
|
} else {
|
|
dayString = `in ${days} Tagen`;
|
|
}
|
|
|
|
const initials = getInitials(f.name);
|
|
const cleanBday = formatGermanDate(f.birthday, false); // format without year or with dots
|
|
|
|
item.innerHTML = `
|
|
<div class="list-item-left">
|
|
<div class="list-avatar birthday-avatar"><i data-lucide="cake"></i></div>
|
|
<div class="list-meta">
|
|
<span class="list-item-name">${f.name}</span>
|
|
<span class="list-item-sub birthday-text">${cleanBday} • wird <strong>${age}</strong> (${dayString})</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-secondary btn-sm btn-view-profile" data-id="${f.id}">
|
|
<i data-lucide="gift"></i> Profil
|
|
</button>
|
|
`;
|
|
DOM.upcomingBirthdaysList.appendChild(item);
|
|
});
|
|
|
|
// Bind click triggers
|
|
DOM.upcomingBirthdaysList.querySelectorAll('.btn-view-profile').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const id = e.currentTarget.getAttribute('data-id');
|
|
openFriendDetail(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
// 4. POPULATE QUICK LOG FRIEND SELECT
|
|
DOM.quickFriendSelect.innerHTML = '<option value="">Wähle einen Freund...</option>';
|
|
friends.forEach(f => {
|
|
const opt = document.createElement('option');
|
|
opt.value = f.id;
|
|
opt.textContent = f.name;
|
|
DOM.quickFriendSelect.appendChild(opt);
|
|
});
|
|
|
|
// Initialize Lucide Icons
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- DYNAMIC RENDERING: FRIENDS DIRECTORY ---
|
|
function renderFriendsDirectory(searchQuery = '') {
|
|
const grid = DOM.friendsGrid;
|
|
grid.innerHTML = '';
|
|
|
|
const query = searchQuery.trim().toLowerCase();
|
|
const filtered = state.friends.filter(f => {
|
|
if (!query) return true;
|
|
return (f.name || '').toLowerCase().includes(query) ||
|
|
(f.address || '').toLowerCase().includes(query) ||
|
|
(f.hobbies || '').toLowerCase().includes(query) ||
|
|
(f.job || '').toLowerCase().includes(query);
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
grid.innerHTML = `
|
|
<div class="empty-state glass-panel">
|
|
<i data-lucide="search-code" class="empty-icon"></i>
|
|
<h3>Keine Freunde gefunden</h3>
|
|
<p>Passe deinen Suchbegriff an oder füge einen neuen Freund hinzu.</p>
|
|
</div>
|
|
`;
|
|
lucide.createIcons();
|
|
return;
|
|
}
|
|
|
|
filtered.forEach(f => {
|
|
const card = document.createElement('div');
|
|
card.className = 'friend-card glass-panel';
|
|
card.setAttribute('data-id', f.id);
|
|
|
|
const initials = getInitials(f.name);
|
|
|
|
// Residential town extraction (last line of address or simply cut address)
|
|
let town = 'Unbekannt';
|
|
if (f.address) {
|
|
const addressLines = f.address.split('\n');
|
|
town = addressLines[addressLines.length - 1].trim();
|
|
}
|
|
|
|
// Format last meeting subtext
|
|
let lastMeetSub = 'Noch nie getroffen';
|
|
if (f.last_meeting_date) {
|
|
lastMeetSub = `Zuletzt: ${formatGermanDate(f.last_meeting_date)}`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="friend-card-header">
|
|
<div class="friend-avatar">${initials}</div>
|
|
<div class="friend-card-title">
|
|
<h3>${f.name}</h3>
|
|
<span class="friend-card-location"><i data-lucide="map-pin"></i> ${town}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="friend-card-body">
|
|
<div class="friend-badge-row">
|
|
<span class="card-badge info"><i data-lucide="award"></i> Ehre: <strong class="honor-card-val">${f.honor || 0}</strong></span>
|
|
${f.birthday ? `<span class="card-badge"><i data-lucide="cake"></i> ${formatGermanDate(f.birthday, false)}</span>` : ''}
|
|
</div>
|
|
<p class="last-meet-tag"><i data-lucide="calendar"></i> ${lastMeetSub}</p>
|
|
</div>
|
|
|
|
<div class="friend-card-footer">
|
|
<div class="quick-honor-card-buttons">
|
|
<button class="btn-honor-card card-minus" data-action="minus"><i data-lucide="minus"></i></button>
|
|
<button class="btn-honor-card card-plus" data-action="plus"><i data-lucide="plus"></i></button>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm btn-view-details"><i data-lucide="user"></i> Profil</button>
|
|
</div>
|
|
`;
|
|
|
|
// Event Listeners for Card Items
|
|
card.querySelector('.btn-view-details').addEventListener('click', () => openFriendDetail(f.id));
|
|
|
|
// Card Honor +/- button handlers
|
|
card.querySelectorAll('.btn-honor-card').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const action = btn.getAttribute('data-action');
|
|
const change = action === 'plus' ? 1 : -1;
|
|
const valDisplay = card.querySelector('.honor-card-val');
|
|
|
|
try {
|
|
const res = await fetch(`/api/friends/${f.id}/honor`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ change })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
// Update state locally
|
|
const friendIndex = state.friends.findIndex(x => x.id === f.id);
|
|
if (friendIndex !== -1) {
|
|
state.friends[friendIndex].honor = data.honor;
|
|
}
|
|
|
|
// Direct UI bump animation
|
|
valDisplay.textContent = data.honor;
|
|
valDisplay.classList.add('bump-animate');
|
|
setTimeout(() => valDisplay.classList.remove('bump-animate'), 300);
|
|
|
|
// Refresh dashboard in background without full render flashes
|
|
renderDashboard();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
});
|
|
});
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- DYNAMIC RENDERING: FRIEND PROFILE DETAIL ---
|
|
async function openFriendDetail(friendId) {
|
|
showModal(DOM.detailModal);
|
|
await refreshFriendDetails(friendId);
|
|
}
|
|
|
|
function populateFriendDetails(friend) {
|
|
DOM.detailAvatar.textContent = getInitials(friend.name);
|
|
DOM.detailName.textContent = friend.name;
|
|
|
|
// Relationship badge
|
|
DOM.detailRelationshipBadge.textContent = friend.relationship_status || 'Kein Beziehungsstatus';
|
|
if (friend.relationship_status && friend.relationship_status.toUpperCase().includes('ALONE')) {
|
|
DOM.detailRelationshipBadge.className = 'relationship-badge forever-alone';
|
|
} else {
|
|
DOM.detailRelationshipBadge.className = 'relationship-badge';
|
|
}
|
|
|
|
DOM.detailHonorValue.textContent = friend.honor || 0;
|
|
|
|
// Formatting General details
|
|
let bdayText = '-';
|
|
if (friend.birthday) {
|
|
const days = getDaysUntilBirthday(friend.birthday);
|
|
const age = getAgeTurning(friend.birthday);
|
|
let daysRemainingStr = '';
|
|
if (days === 0) daysRemainingStr = ' (Heute! 🎉)';
|
|
else if (days === 1) daysRemainingStr = ' (Morgen!)';
|
|
else daysRemainingStr = ` (in ${days} Tagen, wird ${age})`;
|
|
bdayText = `${formatGermanDate(friend.birthday)} ${daysRemainingStr}`;
|
|
}
|
|
DOM.detailBirthday.textContent = bdayText;
|
|
DOM.detailContact.textContent = friend.contact || '-';
|
|
DOM.detailAddress.textContent = friend.address || '-';
|
|
DOM.detailFamily.textContent = friend.family || '-';
|
|
|
|
// Job, Hobbies, Milestones
|
|
DOM.detailLife.innerHTML = formatMarkdownParagraphs(friend.life_situation || '-');
|
|
|
|
let hobbiesHtml = '-';
|
|
if (friend.hobbies) {
|
|
hobbiesHtml = friend.hobbies.split(',').map(h => `<span class="hobby-tag">${h.trim()}</span>`).join(' ');
|
|
} else if (friend.job) {
|
|
hobbiesHtml = `<span class="hobby-tag">${friend.job}</span>`;
|
|
}
|
|
DOM.detailHobbies.innerHTML = hobbiesHtml;
|
|
DOM.detailMilestones.innerHTML = formatMarkdownParagraphs(friend.milestones || '-');
|
|
|
|
// Food & Random Notes
|
|
DOM.detailFood.textContent = friend.food_preferences || '-';
|
|
DOM.detailNotes.innerHTML = formatMarkdownParagraphs(friend.random_notes || '-');
|
|
|
|
// RENDER TOPICS CHECKLIST
|
|
renderTopics(friend.topics || []);
|
|
|
|
// RENDER MEETINGS TIMELINE
|
|
renderTimeline(friend.meetings || []);
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderTopics(topics) {
|
|
const list = DOM.detailTopicsList;
|
|
list.innerHTML = '';
|
|
|
|
if (topics.length === 0) {
|
|
list.innerHTML = '<li class="empty-list-desc">Keine offenen Gesprächsthemen.</li>';
|
|
return;
|
|
}
|
|
|
|
topics.forEach(t => {
|
|
const li = document.createElement('li');
|
|
li.className = `topic-item ${t.completed ? 'completed' : ''}`;
|
|
|
|
li.innerHTML = `
|
|
<label class="topic-checkbox-label">
|
|
<input type="checkbox" class="topic-checkbox" data-id="${t.id}" ${t.completed ? 'checked' : ''}>
|
|
<span class="custom-checkbox"></span>
|
|
<span class="topic-text">${t.topic}</span>
|
|
</label>
|
|
<button class="btn-delete-topic" data-id="${t.id}"><i data-lucide="trash-2"></i></button>
|
|
`;
|
|
|
|
// Checkbox toggle logic
|
|
li.querySelector('.topic-checkbox').addEventListener('change', async (e) => {
|
|
const checked = e.target.checked;
|
|
try {
|
|
const res = await fetch(`/api/topics/${t.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ completed: checked ? 1 : 0 })
|
|
});
|
|
if (res.ok) {
|
|
li.classList.toggle('completed', checked);
|
|
await refreshAllData();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
// Delete topic logic
|
|
li.querySelector('.btn-delete-topic').addEventListener('click', async () => {
|
|
try {
|
|
const res = await fetch(`/api/topics/${t.id}`, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
li.remove();
|
|
await refreshFriendDetails(state.selectedFriend.id);
|
|
await refreshAllData();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
list.appendChild(li);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function renderTimeline(meetings) {
|
|
const timeline = DOM.detailMeetingsTimeline;
|
|
timeline.innerHTML = '';
|
|
|
|
if (meetings.length === 0) {
|
|
timeline.innerHTML = '<p class="empty-list-desc">Noch keine Treffen dokumentiert.</p>';
|
|
return;
|
|
}
|
|
|
|
meetings.forEach(m => {
|
|
const item = document.createElement('div');
|
|
item.className = 'timeline-item';
|
|
|
|
let moodBadge = '';
|
|
if (m.mood) {
|
|
let moodClass = 'mood-default';
|
|
const cleanedMood = m.mood.toLowerCase();
|
|
if (cleanedMood.includes('legendär') || cleanedMood.includes('super') || cleanedMood.includes('genial')) {
|
|
moodClass = 'mood-excellent';
|
|
} else if (cleanedMood.includes('entspannt') || cleanedMood.includes('gut') || cleanedMood.includes('zufrieden')) {
|
|
moodClass = 'mood-good';
|
|
}
|
|
moodBadge = `<span class="mood-badge ${moodClass}">${m.mood}</span>`;
|
|
}
|
|
|
|
item.innerHTML = `
|
|
<div class="timeline-dot"></div>
|
|
<div class="timeline-content glass-panel">
|
|
<div class="timeline-header-row">
|
|
<span class="timeline-date"><i data-lucide="calendar"></i> ${formatGermanDate(m.date)}</span>
|
|
<div class="timeline-actions">
|
|
${moodBadge}
|
|
<button class="btn-delete-meeting" data-id="${m.id}"><i data-lucide="trash-2"></i></button>
|
|
</div>
|
|
</div>
|
|
<h4 class="timeline-activity">${m.activity}</h4>
|
|
${m.details ? `<p class="timeline-details">${m.details.replace(/\n/g, '<br>')}</p>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// Delete meeting logic
|
|
item.querySelector('.btn-delete-meeting').addEventListener('click', async () => {
|
|
const confirmDel = confirm('Willst du diesen Treffen-Eintrag unwiderruflich löschen?');
|
|
if (!confirmDel) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/meetings/${m.id}`, { method: 'DELETE' });
|
|
if (res.ok) {
|
|
item.remove();
|
|
await refreshFriendDetails(state.selectedFriend.id);
|
|
await refreshAllData();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
});
|
|
|
|
timeline.appendChild(item);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
// --- EHRE COUNTER ADJUSTMENT ---
|
|
async function adjustHonor(change) {
|
|
if (!state.selectedFriend) return;
|
|
const f = state.selectedFriend;
|
|
const valDisplay = DOM.detailHonorValue;
|
|
|
|
try {
|
|
const res = await fetch(`/api/friends/${f.id}/honor`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ change })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (res.ok) {
|
|
f.honor = data.honor;
|
|
valDisplay.textContent = data.honor;
|
|
|
|
// Animate counter
|
|
valDisplay.classList.add('bump-animate');
|
|
setTimeout(() => valDisplay.classList.remove('bump-animate'), 300);
|
|
|
|
// Update main state and refresh background views
|
|
const fIndex = state.friends.findIndex(x => x.id === f.id);
|
|
if (fIndex !== -1) {
|
|
state.friends[fIndex].honor = data.honor;
|
|
}
|
|
|
|
renderDashboard();
|
|
renderFriendsDirectory();
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
// --- GENERAL HELPER FUNCTIONS ---
|
|
|
|
function openModal(modal) {
|
|
modal.classList.add('active');
|
|
document.body.style.overflow = 'hidden'; // prevent bg scroll
|
|
}
|
|
|
|
function closeModal(modal) {
|
|
modal.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
function showModal(modal) {
|
|
openModal(modal);
|
|
}
|
|
|
|
function setFormDefaultDates() {
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
DOM.quickDate.value = todayStr;
|
|
DOM.profileMeetingDate.value = todayStr;
|
|
}
|
|
|
|
function showGlobalLoaders() {
|
|
DOM.urgentContactsList.innerHTML = '<div class="loading-spinner"></div>';
|
|
DOM.upcomingBirthdaysList.innerHTML = '<div class="loading-spinner"></div>';
|
|
DOM.friendsGrid.innerHTML = '<div class="loading-spinner"></div>';
|
|
}
|
|
|
|
function getInitials(name) {
|
|
if (!name) return '?';
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
function getDaysSince(dateStr) {
|
|
const diff = new Date() - new Date(dateStr);
|
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function formatGermanDate(dateStr, includeYear = true) {
|
|
if (!dateStr) return '';
|
|
const parts = dateStr.split('-');
|
|
if (parts.length !== 3) return dateStr;
|
|
|
|
const day = parts[2];
|
|
const month = parts[1];
|
|
const year = parts[0];
|
|
|
|
const months = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
|
const monthIndex = parseInt(month, 10) - 1;
|
|
|
|
if (includeYear) {
|
|
return `${day}. ${months[monthIndex]} ${year}`;
|
|
} else {
|
|
return `${day}. ${months[monthIndex].substring(0, 3)}`;
|
|
}
|
|
}
|
|
|
|
function getDaysUntilBirthday(birthdayStr) {
|
|
if (!birthdayStr) return null;
|
|
const parts = birthdayStr.split('-');
|
|
if (parts.length !== 3) return null;
|
|
const birthMonth = parseInt(parts[1], 10) - 1;
|
|
const birthDay = parseInt(parts[2], 10);
|
|
|
|
const today = new Date();
|
|
// Midnight check for perfect calculation
|
|
today.setHours(0,0,0,0);
|
|
|
|
const currentYear = today.getFullYear();
|
|
let nextBday = new Date(currentYear, birthMonth, birthDay);
|
|
nextBday.setHours(0,0,0,0);
|
|
|
|
if (nextBday < today) {
|
|
nextBday.setFullYear(currentYear + 1);
|
|
}
|
|
|
|
const oneDay = 24 * 60 * 60 * 1000;
|
|
const diffDays = Math.round((nextBday.getTime() - today.getTime()) / oneDay);
|
|
return diffDays;
|
|
}
|
|
|
|
function getAgeTurning(birthdayStr) {
|
|
if (!birthdayStr) return null;
|
|
const parts = birthdayStr.split('-');
|
|
if (parts.length !== 3) return null;
|
|
const birthYear = parseInt(parts[0], 10);
|
|
const birthMonth = parseInt(parts[1], 10) - 1;
|
|
const birthDay = parseInt(parts[2], 10);
|
|
|
|
const today = new Date();
|
|
const currentYear = today.getFullYear();
|
|
|
|
let nextBday = new Date(currentYear, birthMonth, birthDay);
|
|
if (nextBday < today) {
|
|
return currentYear + 1 - birthYear;
|
|
}
|
|
return currentYear - birthYear;
|
|
}
|
|
|
|
function formatMarkdownParagraphs(text) {
|
|
if (!text) return '';
|
|
return text
|
|
.split('\n\n')
|
|
.map(p => {
|
|
let cleaned = p.trim();
|
|
if (!cleaned) return '';
|
|
// Basic list items formatting if it starts with hyphen
|
|
if (cleaned.startsWith('-')) {
|
|
const listItems = cleaned.split('\n').map(li => `<li>${li.replace(/^-/, '').trim()}</li>`).join('');
|
|
return `<ul>${listItems}</ul>`;
|
|
}
|
|
return `<p>${cleaned.replace(/\n/g, '<br>')}</p>`;
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
// --- BOOTSTRAP APP ---
|
|
init();
|
|
});
|