feat: initial commit for MischCRM - full private custom personal relationship manager
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
32
Dockerfile
Normal 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
110
README.md
Normal 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
17
docker-compose.yml
Normal 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
2301
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
1132
public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
506
public/index.html
Normal file
506
public/index.html
Normal 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 • 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
1350
public/style.css
Normal file
File diff suppressed because it is too large
Load Diff
541
server.js
Normal file
541
server.js
Normal 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user