feat: initial commit for MischCRM - full private custom personal relationship manager

This commit is contained in:
Kroonk
2026-05-22 15:51:06 +02:00
commit 16e59b1532
10 changed files with 6041 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Dependency directories
node_modules/
jspm_packages/
web_modules/
# SQLite databases
*.db
*.db-journal
*.db-shm
*.db-wal
crm.db
data/
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
Thumbs.db

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Base Image: Lightweight Node.js Alpine
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package descriptors first to leverage Docker layer caching
COPY package.json ./
# Install production dependencies only
RUN npm install --omit=dev
# Copy server code and public assets
COPY server.js ./
COPY public/ ./public/
# Create persistent storage folder for SQLite database
RUN mkdir -p /app/data
# Environment configuration variables
ENV PORT=8085
ENV DATABASE_PATH=/app/data/crm.db
ENV NODE_ENV=production
# Expose internal Express port
EXPOSE 8085
# Define persistent volume directory for crm.db
VOLUME ["/app/data"]
# Run server
CMD ["npm", "start"]

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# 🤝 MischCRM - Personal Relationship Manager
Willkommen bei **MischCRM**, deinem eleganten, privaten und vollautomatisierten Beziehungsmanager (Personal CRM). Diese Anwendung wurde entwickelt, um lose Textnotizen (z. B. aus Obsidian) durch eine intuitive, saubere Web-Oberfläche zu ersetzen, mit der du deine Freundschaften, gemeinsamen Treffen, offenen Gesprächsthemen und Meilensteine mühelos verwalten kannst.
Das gesamte System läuft **vollständig lokal und privat** ohne externe Tracker oder Cloud-Dienste auf deinem Server (z. B. MischNAS).
---
## ✨ Features
* **Premium Glassmorphism Dark Theme**: Ein luxuriöses, modernes Interface mit Outfits-Schriftart, Radial-Glows, Unschärfe-Effekten (`backdrop-filter`) und flüssigen Mikro-Animationen.
* **Interaktives Ehre-Rating**: Ein dynamisches Ehre-Zähler-Modul (+/- Buttons) direkt auf den Profilkarten und im Detailbereich mit spürbarem UI-Feedback.
* **Schnell-Log für Treffen**: Trage mit wenigen Klicks Treffen direkt vom Dashboard oder aus einem Freundesprofil ein (Aktivität, Stimmung/Vibe, detaillierte Gesprächsnotizen).
* **"Lange nicht gesehen"-Warner**: Eine automatische Liste auf dem Dashboard, die Freunde nach dem Datum des letzten Treffens sortiert und dich warnt, wenn du dich länger nicht gemeldet hast.
* **Geburtstags-Tabelle**: Zeigt anstehende Geburtstage in den nächsten 30 Tagen an, berechnet die Tage bis zum Geburtstag und das neue Lebensalter.
* **Dinge zum Ansprechen (Gesprächsthemen-Checkliste)**: Verwalte offene Fragen, D&D-Ideen oder Themen für das nächste Treffen direkt auf dem Profil des Freundes.
* **Obsidian Markdown Import**: Ein intelligenter Parser, der Obsidian `.md`-Notizen einliest, YAML-Frontmatter und Überschriften analysiert und die Profildaten direkt in die SQLite-Datenbank überführt.
* **Dockerized Deployment**: Perfekt vorbereitet für das einfache Hosting auf deinem `MischNAS` mittels Docker und Docker Compose.
---
## 🚀 Lokale Installation & Entwicklung
### Voraussetzungen
* **Node.js** (v18 oder neuer)
* **npm** oder **pnpm**
### Setup
1. Installiere die Abhängigkeiten im CRM-Verzeichnis:
```bash
npm install
```
2. Starte den Server im Entwicklungsmodus (mit automatischem Reload bei Codeänderungen):
```bash
npm run dev
```
Der Server läuft nun auf [http://localhost:8085](http://localhost:8085).
---
## 🐳 Bereitstellung mit Docker (MischNAS)
MischCRM lässt sich mit einem einzigen Befehl per Docker Compose deployen.
### Starten
Führe folgenden Befehl im Verzeichnis aus, in dem sich die `docker-compose.yml` befindet:
```bash
docker compose up -d
```
Die Anwendung ist anschließend unter **[http://localhost:38090](http://localhost:38090)** erreichbar (Port `38090` auf dem Host wird auf Port `8085` im Container weitergeleitet).
### 💾 Daten-Persistenz & Backups
Das CRM verwendet **SQLite** zur Datenhaltung. Die Datenbankdatei `crm.db` wird im Host-Ordner `./data/` abgelegt, welcher in den Container gemountet wird (`/app/data`).
* **Kein Datenverlust**: Selbst wenn der Container gelöscht, geupdatet oder neu gebaut wird, bleiben alle deine Freunde, Treffen und Notizen in `./data/crm.db` sicher auf deinem Host-System gespeichert.
* **Einfache Backups**: Sichere einfach die Datei `./data/crm.db`, um ein vollständiges Backup deines CRMs zu erstellen.
---
## 📂 Obsidian Import Guide
Du kannst bestehende Markdown-Notizen aus deinem Obsidian-Vault direkt in MischCRM importieren. Kopiere dazu einfach den gesamten Text der Notiz in das Feld unter dem Tab **Obsidian Import**.
### Unterstützte Markdown-Struktur:
Der Parser ist optimiert auf folgende Struktur (siehe z. B. dein Notizenformat für *Aaron Lingel*):
```markdown
---
tags:
- Freund
---
## Allgemeines
- Name: Aaron Lingel
- Geburtstag: 13.08.1999
- Kontakt: +49 172 1821612
- Wohnort: Am schlaggraben 18, 71272 renningen
- Beziehungsstatus: FOREVER ALONE (aber hat mich)
- Familie: Mama Lingel und Papa Africano
- Ehre: +32
## Aktuelle Lebenssituation
- Arbeit/Studium: Studium / Arbeit
- Hobbys/Interessen: Ehrenbruder sein, D&D, Gaming
Auf der Suche nach neuen Abenteuern.
## Persönliche Meilensteine
- Hat ein stabiles Freundschaftsprofil bekommen
- Neue Wohnung bezogen
## Random Infos
- Lieblingsessen/-getränk: Pizza, Kaltgetränke
- Sonstige Infos: Bester Kumpel. Immer am Start.
```
### So funktioniert der Import:
1. Der Parser extrahiert den Namen aus dem Dateinamen oder dem Feld `Name:` unter `## Allgemeines`.
2. Geburtstage im Format `DD.MM.YYYY` werden automatisch in das Standardformat `YYYY-MM-DD` konvertiert.
3. Der Zähler für **Ehre** (z. B. `+32`) wird extrahiert und als Startwert festgelegt.
4. Alle Unterabschnitte werden den entsprechenden Datenbankspalten zugeordnet.
5. Nach dem Import kannst du direkt per Klick auf das neu erstellte CRM-Profil springen!
---
## 🛠️ Technologien
* **Backend**: Node.js, Express.js, SQLite (`sqlite3`)
* **Frontend**: Vanilla HTML5, CSS3 (luxuriöse HSL-Variablen, Backdrop-Filter), Vanilla JavaScript (SPA, state-driven rendering)
* **Icons**: Lucide Icons (per CDN geladen)

17
docker-compose.yml Normal file
View File

@@ -0,0 +1,17 @@
version: '3.8'
services:
mischcrm:
container_name: mischcrm
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- '38090:8085'
volumes:
- './data:/app/data'
environment:
- PORT=8085
- DATABASE_PATH=/app/data/crm.db
- NODE_ENV=production

2301
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "mischcrm",
"version": "1.0.0",
"description": "Sleek, glassmorphic Personal CRM to track relationships, meetings, and friend details",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"keywords": [
"crm",
"personal-crm",
"relationship-manager",
"self-hosted",
"sqlite"
],
"author": "Mischlabs",
"license": "MIT",
"dependencies": {
"express": "^4.19.2",
"sqlite3": "^5.1.7"
}
}

1132
public/app.js Normal file

File diff suppressed because it is too large Load Diff

506
public/index.html Normal file
View File

@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MischCRM - Personal Relationship Manager</title>
<!-- Google Fonts: Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons for premium modern look -->
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app-container">
<!-- Premium Sidebar -->
<aside class="sidebar">
<div class="logo-area">
<div class="logo-glow"></div>
<i data-lucide="heart-handshake" class="logo-icon"></i>
<span class="logo-text">Misch<span>CRM</span></span>
</div>
<nav class="nav-menu">
<a href="#dashboard" class="nav-item active" data-tab="dashboard">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</a>
<a href="#friends" class="nav-item" data-tab="friends">
<i data-lucide="users"></i>
<span>Freunde</span>
</a>
<a href="#import" class="nav-item" data-tab="import">
<i data-lucide="file-up"></i>
<span>Obsidian Import</span>
</a>
</nav>
<div class="sidebar-footer">
<span class="version">v1.0.0 &bull; Mischlabs</span>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<!-- Top header bar -->
<header class="top-bar">
<h1 id="page-title">Dashboard</h1>
<div class="user-profile">
<span class="welcome-text">Willkommen, <span class="username">Ehrenmann</span></span>
<div class="avatar-ring">
<div class="avatar-fallback">M</div>
</div>
</div>
</header>
<!-- TAB 1: DASHBOARD -->
<section id="tab-dashboard" class="tab-pane active">
<!-- Stats Panel -->
<div class="stats-grid">
<div class="stat-card glass-panel">
<div class="stat-icon-wrapper blue">
<i data-lucide="users"></i>
</div>
<div class="stat-data">
<span class="stat-label">Gesamte Freunde</span>
<span class="stat-value" id="stat-total-friends">0</span>
</div>
</div>
<div class="stat-card glass-panel">
<div class="stat-icon-wrapper purple">
<i data-lucide="calendar"></i>
</div>
<div class="stat-data">
<span class="stat-label">Treffen diesen Monat</span>
<span class="stat-value" id="stat-meetings-month">0</span>
</div>
</div>
<div class="stat-card glass-panel">
<div class="stat-icon-wrapper gold">
<i data-lucide="award"></i>
</div>
<div class="stat-data">
<span class="stat-label">Durchschnittliche Ehre</span>
<span class="stat-value" id="stat-avg-honor">0</span>
</div>
</div>
<div class="stat-card glass-panel">
<div class="stat-icon-wrapper pink">
<i data-lucide="cake"></i>
</div>
<div class="stat-data">
<span class="stat-label">Geburtstage (30 Tage)</span>
<span class="stat-value" id="stat-upcoming-birthdays">0</span>
</div>
</div>
</div>
<div class="dashboard-columns">
<!-- Left Column: Quick Actions & Log Meeting -->
<div class="dashboard-left">
<!-- Quick Log Meeting -->
<div class="glass-panel card-section">
<div class="section-header">
<h2><i data-lucide="check-square" class="header-icon"></i>Schnell-Log Treffen</h2>
</div>
<form id="quick-log-form" class="premium-form">
<div class="form-row">
<div class="form-group">
<label for="quick-friend-select">Freund auswählen</label>
<select id="quick-friend-select" required>
<option value="">Wähle einen Freund...</option>
</select>
</div>
<div class="form-group">
<label for="quick-date">Datum</label>
<input type="date" id="quick-date" required>
</div>
</div>
<div class="form-group">
<label for="quick-activity">Was habt ihr gemacht?</label>
<input type="text" id="quick-activity" placeholder="z. B. Gemütliches Kaltgetränk gezischt, D&D gespielt" required>
</div>
<div class="form-group">
<label for="quick-mood">Stimmung / Vibe</label>
<input type="text" id="quick-mood" placeholder="z. B. Legendär, Entspannt, Super motiviert">
</div>
<div class="form-group">
<label for="quick-details">Details / Notizen</label>
<textarea id="quick-details" rows="3" placeholder="Gibt es wichtige Lebensupdates oder Dinge, an die du dich erinnern willst?"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-full">
<i data-lucide="plus-circle"></i> Treffen speichern
</button>
</form>
</div>
</div>
<!-- Right Column: Urgent Contacts & Upcoming Birthdays -->
<div class="dashboard-right">
<!-- Urgent Contacts (Nicht lange gesehen) -->
<div class="glass-panel card-section scrollable-list-container">
<div class="section-header">
<h2><i data-lucide="bell" class="header-icon"></i>Lange nicht gesehen</h2>
</div>
<div id="urgent-contacts-list" class="list-container">
<!-- Loaded dynamically -->
<div class="loading-spinner"></div>
</div>
</div>
<!-- Upcoming Birthdays -->
<div class="glass-panel card-section scrollable-list-container">
<div class="section-header">
<h2><i data-lucide="cake" class="header-icon"></i>Anstehende Geburtstage</h2>
</div>
<div id="upcoming-birthdays-list" class="list-container">
<!-- Loaded dynamically -->
</div>
</div>
</div>
</div>
</section>
<!-- TAB 2: FRIENDS DIRECTORY -->
<section id="tab-friends" class="tab-pane">
<div class="directory-actions">
<div class="search-bar-wrapper glass-panel">
<i data-lucide="search" class="search-icon"></i>
<input type="text" id="friends-search" placeholder="Suche nach Freunden, Hobbys, Wohnorten...">
</div>
<button class="btn btn-primary" id="btn-add-friend-modal">
<i data-lucide="plus"></i> Freund hinzufügen
</button>
</div>
<div class="friends-grid" id="friends-grid">
<!-- Loaded dynamically -->
</div>
</section>
<!-- TAB 3: OBSIDIAN IMPORT -->
<section id="tab-import" class="tab-pane">
<div class="glass-panel card-section import-container">
<div class="section-header">
<h2><i data-lucide="file-up" class="header-icon"></i>Obsidian Markdown Import</h2>
<p class="section-desc">Kopiere den Inhalt deiner Obsidian Freundesnotiz (inklusive YAML Frontmatter) hier hinein, um die Daten vollautomatisch in dein CRM zu übertragen.</p>
</div>
<form id="obsidian-import-form" class="premium-form">
<div class="form-group">
<label for="import-filename">Name der Datei (optional, z. B. Aaron Lingel.md)</label>
<input type="text" id="import-filename" placeholder="Aaron Lingel.md">
</div>
<div class="form-group">
<label for="import-content">Markdown Inhalt</label>
<textarea id="import-content" rows="16" placeholder="Kopiere hier den gesamten Inhalt der Obsidian-Datei hinein..." required></textarea>
</div>
<button type="submit" class="btn btn-primary btn-full">
<i data-lucide="cpu"></i> Datei analysieren & importieren
</button>
</form>
<div id="import-result" class="import-result-panel glass-panel hidden">
<!-- Loaded dynamically -->
</div>
</div>
</section>
</main>
</div>
<!-- MODAL: ADD FRIEND -->
<div class="modal-backdrop" id="add-friend-modal">
<div class="modal-container glass-panel">
<div class="modal-header">
<h2><i data-lucide="user-plus"></i> Neuer Ehrenbruder / Freund</h2>
<button class="modal-close" id="btn-close-add-modal"><i data-lucide="x"></i></button>
</div>
<form id="add-friend-form" class="premium-form">
<div class="modal-scroll-area">
<div class="form-group">
<label for="add-name">Name *</label>
<input type="text" id="add-name" required placeholder="z. B. Aaron Lingel">
</div>
<div class="form-row">
<div class="form-group">
<label for="add-birthday">Geburtstag</label>
<input type="date" id="add-birthday">
</div>
<div class="form-group">
<label for="add-contact">Telefon / Kontakt</label>
<input type="text" id="add-contact" placeholder="+49 172 ...">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="add-relationship">Beziehungsstatus</label>
<input type="text" id="add-relationship" placeholder="z. B. FOREVER ALONE (aber hat mich)">
</div>
<div class="form-group">
<label for="add-family">Familie</label>
<input type="text" id="add-family" placeholder="Mama Lingel und Papa Africano">
</div>
</div>
<div class="form-group">
<label for="add-address">Wohnort / Adresse</label>
<textarea id="add-address" rows="2" placeholder="Am schlaggraben 18, 71272 renningen"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="add-honor">Ehre (Honor)</label>
<input type="number" id="add-honor" value="0">
</div>
<div class="form-group">
<label for="add-job">Arbeit / Studium</label>
<input type="text" id="add-job" placeholder="z. B. Software Engineer, BWL Studium">
</div>
</div>
<div class="form-group">
<label for="add-life">Aktuelle Lebenssituation</label>
<textarea id="add-life" rows="2" placeholder="Wie geht es ihm aktuell? Pläne?"></textarea>
</div>
<div class="form-group">
<label for="add-hobbies">Hobbys / Interessen</label>
<input type="text" id="add-hobbies" placeholder="D&D, Sport, Zocken">
</div>
<div class="form-group">
<label for="add-milestones">Persönliche Meilensteine</label>
<textarea id="add-milestones" rows="2" placeholder="Neue Wohnung, Abschluss etc."></textarea>
</div>
<div class="form-group">
<label for="add-food">Lieblingsessen/-getränk</label>
<input type="text" id="add-food" placeholder="Pizza, Kaltgetränke">
</div>
<div class="form-group">
<label for="add-notes">Zusätzliche Infos / Notizen</label>
<textarea id="add-notes" rows="2" placeholder="Random Infos, Eigenheiten..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="btn-cancel-add-modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<!-- MODAL: FRIEND DETAIL VIEW (ULTRA PREMIUM PROFILE) -->
<div class="modal-backdrop" id="friend-detail-modal">
<div class="modal-container glass-panel detail-modal-size">
<div class="modal-header">
<div class="modal-title-profile">
<div class="profile-avatar" id="detail-avatar">A</div>
<div class="profile-meta-title">
<h2 id="detail-name">Aaron Lingel</h2>
<span class="relationship-badge" id="detail-badge-relationship">FOREVER ALONE</span>
</div>
</div>
<button class="modal-close" id="btn-close-detail-modal"><i data-lucide="x"></i></button>
</div>
<div class="modal-scroll-area">
<div class="detail-columns">
<!-- Left Column: Core profile details -->
<div class="detail-col-info">
<!-- Ehre Meter (Counter) -->
<div class="glass-panel honor-badge-section">
<div class="honor-header">
<i data-lucide="award" class="honor-icon"></i>
<span>EHRENRATING</span>
</div>
<div class="honor-counter-row">
<button class="btn-honor btn-minus" id="btn-honor-minus"><i data-lucide="minus"></i></button>
<div class="honor-value-display">
<span id="detail-honor-value">0</span>
<span class="honor-label-sub">Ehre</span>
</div>
<button class="btn-honor btn-plus" id="btn-honor-plus"><i data-lucide="plus"></i></button>
</div>
</div>
<!-- Profile Info Cards -->
<div class="glass-panel detail-card">
<h3><i data-lucide="contact" class="card-icon-title"></i>Allgemeine Infos</h3>
<div class="info-list">
<div class="info-item">
<span class="info-label"><i data-lucide="cake"></i> Geburtstag</span>
<span class="info-val" id="detail-birthday">-</span>
</div>
<div class="info-item">
<span class="info-label"><i data-lucide="phone"></i> Kontakt</span>
<span class="info-val" id="detail-contact">-</span>
</div>
<div class="info-item">
<span class="info-label"><i data-lucide="map-pin"></i> Adresse</span>
<span class="info-val pre-wrap" id="detail-address">-</span>
</div>
<div class="info-item">
<span class="info-label"><i data-lucide="users-round"></i> Familie</span>
<span class="info-val" id="detail-family">-</span>
</div>
</div>
</div>
<div class="glass-panel detail-card">
<h3><i data-lucide="briefcase" class="card-icon-title"></i>Lebenssituation & Meilensteine</h3>
<div class="info-list">
<div class="info-item-block">
<span class="info-label">Aktuell</span>
<div class="info-block-text" id="detail-life">-</div>
</div>
<div class="info-item-block">
<span class="info-label">Hobbys & Interessen</span>
<div class="info-block-text" id="detail-hobbies">-</div>
</div>
<div class="info-item-block">
<span class="info-label">Meilensteine</span>
<div class="info-block-text" id="detail-milestones">-</div>
</div>
</div>
</div>
<div class="glass-panel detail-card">
<h3><i data-lucide="info" class="card-icon-title"></i>Lieblingskram & Sonstiges</h3>
<div class="info-list">
<div class="info-item-block">
<span class="info-label">Lieblingsessen/-getränk</span>
<div class="info-block-text" id="detail-food">-</div>
</div>
<div class="info-item-block">
<span class="info-label">Sonstiges & Eigenheiten</span>
<div class="info-block-text" id="detail-notes">-</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Interactive components (Timeline, Topics to discuss) -->
<div class="detail-col-interactive">
<!-- Topics to Discuss -->
<div class="glass-panel interactive-section">
<div class="section-title-row">
<h3><i data-lucide="message-square" class="card-icon-title"></i>Dinge zum Ansprechen</h3>
</div>
<form id="add-topic-form" class="inline-add-form">
<input type="text" id="new-topic-input" placeholder="Fragen oder Themen fürs nächste Treffen..." required>
<button type="submit" class="btn btn-primary btn-sm"><i data-lucide="plus"></i></button>
</form>
<ul class="topics-list" id="detail-topics-list">
<!-- Loaded dynamically -->
</ul>
</div>
<!-- Log Meeting on Profile -->
<div class="glass-panel interactive-section">
<h3><i data-lucide="plus-circle" class="card-icon-title"></i>Treffen eintragen</h3>
<form id="profile-log-meeting-form" class="inline-meeting-form">
<div class="form-row">
<input type="date" id="profile-meeting-date" required>
<input type="text" id="profile-meeting-mood" placeholder="Stimmung (z. B. Legendär)">
</div>
<input type="text" id="profile-meeting-activity" placeholder="Was habt ihr gemacht?" required>
<textarea id="profile-meeting-details" rows="2" placeholder="Updates / Details zum Gespräch..."></textarea>
<button type="submit" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> Treffen eintragen</button>
</form>
</div>
<!-- Timeline of past meetings -->
<div class="glass-panel interactive-section">
<h3><i data-lucide="history" class="card-icon-title"></i>Historie</h3>
<div class="timeline" id="detail-meetings-timeline">
<!-- Loaded dynamically -->
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-danger" id="btn-delete-friend"><i data-lucide="trash-2"></i> Freund löschen</button>
<button class="btn btn-primary" id="btn-edit-friend-trigger"><i data-lucide="edit-3"></i> Profil bearbeiten</button>
</div>
</div>
</div>
<!-- MODAL: EDIT FRIEND (REUSING ADD STYLE) -->
<div class="modal-backdrop" id="edit-friend-modal">
<div class="modal-container glass-panel">
<div class="modal-header">
<h2><i data-lucide="edit"></i> Profil bearbeiten</h2>
<button class="modal-close" id="btn-close-edit-modal"><i data-lucide="x"></i></button>
</div>
<form id="edit-friend-form" class="premium-form">
<input type="hidden" id="edit-id">
<div class="modal-scroll-area">
<div class="form-group">
<label for="edit-name">Name *</label>
<input type="text" id="edit-name" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-birthday">Geburtstag</label>
<input type="date" id="edit-birthday">
</div>
<div class="form-group">
<label for="edit-contact">Telefon / Kontakt</label>
<input type="text" id="edit-contact">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-relationship">Beziehungsstatus</label>
<input type="text" id="edit-relationship">
</div>
<div class="form-group">
<label for="edit-family">Familie</label>
<input type="text" id="edit-family">
</div>
</div>
<div class="form-group">
<label for="edit-address">Wohnort / Adresse</label>
<textarea id="edit-address" rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-honor">Ehre</label>
<input type="number" id="edit-honor">
</div>
<div class="form-group">
<label for="edit-job">Arbeit / Studium</label>
<input type="text" id="edit-job">
</div>
</div>
<div class="form-group">
<label for="edit-life">Aktuelle Lebenssituation</label>
<textarea id="edit-life" rows="2"></textarea>
</div>
<div class="form-group">
<label for="edit-hobbies">Hobbys / Interessen</label>
<input type="text" id="edit-hobbies">
</div>
<div class="form-group">
<label for="edit-milestones">Persönliche Meilensteine</label>
<textarea id="edit-milestones" rows="2"></textarea>
</div>
<div class="form-group">
<label for="edit-food">Lieblingsessen/-getränk</label>
<input type="text" id="edit-food">
</div>
<div class="form-group">
<label for="edit-notes">Zusätzliche Infos / Notizen</label>
<textarea id="edit-notes" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="btn-cancel-edit-modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Änderungen speichern</button>
</div>
</form>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

1350
public/style.css Normal file

File diff suppressed because it is too large Load Diff

541
server.js Normal file
View File

@@ -0,0 +1,541 @@
const express = require('express');
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const app = express();
const PORT = process.env.PORT || 8085;
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'crm.db');
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Connect to SQLite Database
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error connecting to SQLite database:', err.message);
} else {
console.log('Connected to SQLite database at:', DB_PATH);
initializeDatabase();
}
});
// Initialize Database Tables
function initializeDatabase() {
db.serialize(() => {
// Friends Table
db.run(`
CREATE TABLE IF NOT EXISTS friends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
birthday TEXT,
contact TEXT,
address TEXT,
relationship_status TEXT,
family TEXT,
honor INTEGER DEFAULT 0,
life_situation TEXT,
job TEXT,
hobbies TEXT,
milestones TEXT,
food_preferences TEXT,
random_notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Meetings Table
db.run(`
CREATE TABLE IF NOT EXISTS meetings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
friend_id INTEGER NOT NULL,
date TEXT NOT NULL,
activity TEXT,
mood TEXT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(friend_id) REFERENCES friends(id) ON DELETE CASCADE
)
`);
// Topics Table
db.run(`
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
friend_id INTEGER NOT NULL,
topic TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(friend_id) REFERENCES friends(id) ON DELETE CASCADE
)
`);
// Seed Initial Data (Aaron Lingel) if database is empty
db.get("SELECT COUNT(*) as count FROM friends", (err, row) => {
if (err) return console.error('Error checking friends count:', err.message);
if (row.count === 0) {
console.log('Seeding initial data...');
const stmt = db.prepare(`
INSERT INTO friends (
name, birthday, contact, address, relationship_status, family, honor, life_situation, job, hobbies, milestones, food_preferences, random_notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
"Aaron Lingel",
"1999-08-13",
"+49 172 1821612",
"Am schlaggraben 18\n71272 renningen",
"FOREVER ALONE (aber hat mich)",
"Mama Lingel und Papa Africano",
32,
"Auf der Suche nach neuen Abenteuern",
"Studium / Arbeit",
"Ehrenbruder sein, D&D",
"Hat ein stabiles Freundschaftsprofil bekommen",
"Gutes Essen",
"Bester Kumpel"
);
stmt.finalize();
// Seed an initial meeting log for Aaron
db.get("SELECT id FROM friends WHERE name = 'Aaron Lingel'", (err, row) => {
if (row) {
db.run(`
INSERT INTO meetings (friend_id, date, activity, mood, details)
VALUES (?, ?, ?, ?, ?)
`, [row.id, "2026-05-20", "Gemütliches Kaltgetränk gezischt", "Strahlt vor Freude", "Lustige Gespräche über Gott und die Welt geführt. Aaron ist hochmotiviert für neue Projekte."]);
db.run(`
INSERT INTO topics (friend_id, topic, completed)
VALUES (?, ?, ?)
`, [row.id, "Nächstes D&D Abenteuer planen", 0]);
db.run(`
INSERT INTO topics (friend_id, topic, completed)
VALUES (?, ?, ?)
`, [row.id, "Seinen Ehren-Counter im CRM feiern", 0]);
}
});
}
});
});
}
// API ENDPOINTS
// 1. Get all friends (with their last meeting date)
app.get('/api/friends', (req, res) => {
const query = `
SELECT f.*,
MAX(m.date) as last_meeting_date,
(SELECT COUNT(*) FROM topics t WHERE t.friend_id = f.id AND t.completed = 0) as pending_topics_count
FROM friends f
LEFT JOIN meetings m ON f.id = m.friend_id
GROUP BY f.id
ORDER BY f.name ASC
`;
db.all(query, [], (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json(rows);
});
});
// 2. Get single friend details (including meetings & topics)
app.get('/api/friends/:id', (req, res) => {
const friendId = req.params.id;
db.get("SELECT * FROM friends WHERE id = ?", [friendId], (err, friend) => {
if (err) return res.status(500).json({ error: err.message });
if (!friend) return res.status(404).json({ error: 'Friend not found' });
db.all("SELECT * FROM meetings WHERE friend_id = ? ORDER BY date DESC", [friendId], (err, meetings) => {
if (err) return res.status(500).json({ error: err.message });
db.all("SELECT * FROM topics WHERE friend_id = ? ORDER BY completed ASC, created_at DESC", [friendId], (err, topics) => {
if (err) return res.status(500).json({ error: err.message });
res.json({
...friend,
meetings: meetings || [],
topics: topics || []
});
});
});
});
});
// 3. Create a new friend
app.post('/api/friends', (req, res) => {
const {
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const query = `
INSERT INTO friends (
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
name, birthday || '', contact || '', address || '', relationship_status || '',
family || '', honor || 0, life_situation || '', job || '', hobbies || '',
milestones || '', food_preferences || '', random_notes || ''
];
db.run(query, params, function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ id: this.lastID, message: 'Friend created successfully' });
});
});
// 4. Update friend details
app.put('/api/friends/:id', (req, res) => {
const friendId = req.params.id;
const {
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes
} = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const query = `
UPDATE friends SET
name = ?, birthday = ?, contact = ?, address = ?, relationship_status = ?,
family = ?, honor = ?, life_situation = ?, job = ?, hobbies = ?,
milestones = ?, food_preferences = ?, random_notes = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`;
const params = [
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes,
friendId
];
db.run(query, params, function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Friend not found' });
}
res.json({ message: 'Friend updated successfully' });
});
});
// 5. Delete a friend
app.delete('/api/friends/:id', (req, res) => {
const friendId = req.params.id;
db.run("DELETE FROM friends WHERE id = ?", [friendId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Friend deleted successfully' });
});
});
// 6. Update friend's honor score (+/-)
app.post('/api/friends/:id/honor', (req, res) => {
const friendId = req.params.id;
const { change } = req.body; // should be +1 or -1
if (change !== 1 && change !== -1) {
return res.status(400).json({ error: 'Invalid honor change value. Must be 1 or -1.' });
}
db.run("UPDATE friends SET honor = honor + ? WHERE id = ?", [change, friendId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Friend not found' });
}
// Retrieve the updated honor value to send back
db.get("SELECT honor FROM friends WHERE id = ?", [friendId], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
res.json({ honor: row.honor, message: 'Honor updated successfully' });
});
});
});
// 6.5. Get all meetings (global list, sorted by date descending)
app.get('/api/meetings', (req, res) => {
const query = `
SELECT m.*, f.name as friend_name
FROM meetings m
JOIN friends f ON m.friend_id = f.id
ORDER BY m.date DESC
`;
db.all(query, [], (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json(rows);
});
});
// 7. Log a new meeting
app.post('/api/meetings', (req, res) => {
const { friend_id, date, activity, mood, details } = req.body;
if (!friend_id || !date) {
return res.status(400).json({ error: 'Friend ID and Date are required' });
}
const query = `
INSERT INTO meetings (friend_id, date, activity, mood, details)
VALUES (?, ?, ?, ?, ?)
`;
db.run(query, [friend_id, date, activity || '', mood || '', details || ''], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ id: this.lastID, message: 'Meeting logged successfully' });
});
});
// 8. Delete a meeting
app.delete('/api/meetings/:id', (req, res) => {
const meetingId = req.params.id;
db.run("DELETE FROM meetings WHERE id = ?", [meetingId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Meeting deleted successfully' });
});
});
// 9. Add a topic to discuss
app.post('/api/topics', (req, res) => {
const { friend_id, topic } = req.body;
if (!friend_id || !topic) {
return res.status(400).json({ error: 'Friend ID and Topic description are required' });
}
db.run("INSERT INTO topics (friend_id, topic) VALUES (?, ?)", [friend_id, topic], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.status(201).json({ id: this.lastID, message: 'Topic added successfully' });
});
});
// 10. Toggle topic completion status
app.put('/api/topics/:id', (req, res) => {
const topicId = req.params.id;
const { completed } = req.body; // 0 or 1
db.run("UPDATE topics SET completed = ? WHERE id = ?", [completed ? 1 : 0, topicId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Topic updated successfully' });
});
});
// 11. Delete a topic
app.delete('/api/topics/:id', (req, res) => {
const topicId = req.params.id;
db.run("DELETE FROM topics WHERE id = ?", [topicId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Topic deleted successfully' });
});
});
// 12. Smart Import from Obsidian Markdown Content
app.post('/api/import-obsidian', (req, res) => {
const { filename, content } = req.body;
if (!content) {
return res.status(400).json({ error: 'Markdown content is required' });
}
try {
// 1. Extract name from filename or title
let name = filename ? filename.replace(/\.md$/, '').trim() : '';
// 2. Parse YAML frontmatter if exists
const yamlRegex = /^---([\s\S]*?)---/;
const yamlMatch = content.match(yamlRegex);
let frontmatter = {};
let markdownBody = content;
if (yamlMatch) {
markdownBody = content.replace(yamlMatch[0], '');
const yamlContent = yamlMatch[1];
yamlContent.split('\n').forEach(line => {
const parts = line.split(':');
if (parts.length >= 2) {
const key = parts[0].trim();
const val = parts.slice(1).join(':').trim();
frontmatter[key] = val;
}
});
}
// 3. Simple parser for markdown sections
// E.g. we find sections like ## Allgemeines, ## Aktuelle Lebenssituation, etc.
const sections = {};
const headingRegex = /^##\s+(.+)$/gm;
let match;
const headings = [];
// Get all ## headings and their indexes
while ((match = headingRegex.exec(markdownBody)) !== null) {
headings.push({
title: match[1].trim().toLowerCase(),
index: match.index,
fullHeading: match[0]
});
}
for (let i = 0; i < headings.length; i++) {
const start = headings[i].index + headings[i].fullHeading.length;
const end = (i + 1 < headings.length) ? headings[i + 1].index : markdownBody.length;
const sectionText = markdownBody.slice(start, end).trim();
sections[headings[i].title] = sectionText;
}
// Parse 'allgemeines' list items
const generalText = sections['allgemeines'] || '';
let birthday = '';
let contact = '';
let address = '';
let relationship_status = '';
let family = '';
let honor = 0;
generalText.split('\n').forEach(line => {
const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim();
if (cleaned.startsWith('Name:')) {
const parsedName = cleaned.replace('Name:', '').trim();
if (parsedName) name = parsedName;
} else if (cleaned.startsWith('Geburtstag:')) {
let bday = cleaned.replace('Geburtstag:', '').trim();
// Convert DD.MM.YYYY to YYYY-MM-DD
const dateMatch = bday.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if (dateMatch) {
birthday = `${dateMatch[3]}-${dateMatch[2]}-${dateMatch[1]}`;
} else {
birthday = bday;
}
} else if (cleaned.startsWith('Kontakt:')) {
contact = cleaned.replace('Kontakt:', '').trim();
} else if (cleaned.startsWith('Wohnort:')) {
address = cleaned.replace('Wohnort:', '').trim();
} else if (cleaned.startsWith('Beziehungsstatus:')) {
relationship_status = cleaned.replace('Beziehungsstatus:', '').trim();
} else if (cleaned.startsWith('Familie:')) {
family = cleaned.replace('Familie:', '').trim();
} else if (cleaned.startsWith('Ehre:')) {
const honorStr = cleaned.replace('Ehre:', '').trim();
const parsedHonor = parseInt(honorStr.replace('+', ''), 10);
if (!isNaN(parsedHonor)) honor = parsedHonor;
}
});
if (!name) {
name = 'Unbekannter Freund';
}
// Parse other sections
const life_situation = sections['aktuelle lebenssituation'] || '';
// Split job, hobbies, milestones
let job = '';
let hobbies = '';
life_situation.split('\n').forEach(line => {
const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim();
if (cleaned.startsWith('Arbeit/Studium:')) {
job = cleaned.replace('Arbeit/Studium:', '').trim();
} else if (cleaned.startsWith('Hobbys/Interessen:')) {
hobbies = cleaned.replace('Hobbys/Interessen:', '').trim();
}
});
const milestonesText = sections['persönliche meilensteine'] || '';
const milestones = milestonesText.split('\n')
.map(line => line.replace(/^-/, '').trim())
.filter(line => line.length > 0)
.join('\n');
const randomText = sections['random infos'] || '';
let food_preferences = '';
randomText.split('\n').forEach(line => {
const cleaned = line.replace(/^-\s+\*\*/, '').replace(/^-/, '').trim();
if (cleaned.startsWith('Lieblingsessen/-getränk:')) {
food_preferences = cleaned.replace('Lieblingsessen/-getränk:', '').trim();
}
});
const random_notes = sections['random infos'] || '';
// Insert parsed friend into database
db.run(`
INSERT INTO friends (
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
name, birthday, contact, address, relationship_status, family, honor,
life_situation, job, hobbies, milestones, food_preferences, random_notes
], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
const newFriendId = this.lastID;
// Parse meetings and insert if any exist
// In the Aaron Lingel.md file, meetings are dataview queries, but let's see if we can parse some meetings
// Usually manual notes have a section like ## Treffen/Unterhaltungen
const meetingsText = sections['treffen/unterhaltungen'] || '';
if (meetingsText && !meetingsText.includes('Letztes Treffen am: ')) {
// Simple manual meeting parse if it has content
let date = new Date().toISOString().split('T')[0];
let activity = 'Treffen';
let details = meetingsText;
db.run(`
INSERT INTO meetings (friend_id, date, activity, mood, details)
VALUES (?, ?, ?, ?, ?)
`, [newFriendId, date, activity, 'Zufrieden', details]);
}
res.status(201).json({ id: newFriendId, name, message: 'Obsidian file successfully imported!' });
});
} catch (err) {
res.status(500).json({ error: 'Failed to parse Obsidian file: ' + err.message });
}
});
// Default static serving: fallback to index.html for SPA router
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Start Server
app.listen(PORT, () => {
console.log(`MischCRM server is running on http://localhost:${PORT}`);
});