Feat: Complete smart contact matching, CRM features integration, and premium glassmorphism UI
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
This commit is contained in:
173
public/app.js
173
public/app.js
@@ -4,6 +4,7 @@ const state = {
|
|||||||
contacts: [],
|
contacts: [],
|
||||||
recognition: null,
|
recognition: null,
|
||||||
listening: false,
|
listening: false,
|
||||||
|
selectedContactId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
@@ -29,8 +30,33 @@ const elements = {
|
|||||||
saveButton: document.querySelector("#saveButton"),
|
saveButton: document.querySelector("#saveButton"),
|
||||||
discardButton: document.querySelector("#discardButton"),
|
discardButton: document.querySelector("#discardButton"),
|
||||||
messageLog: document.querySelector("#messageLog"),
|
messageLog: document.querySelector("#messageLog"),
|
||||||
|
|
||||||
|
// Tab Elements
|
||||||
|
tabButtons: document.querySelectorAll(".tab-btn"),
|
||||||
|
tabViews: document.querySelectorAll(".tab-view"),
|
||||||
|
tabBtnContactInfo: document.querySelector("#tabBtnContactInfo"),
|
||||||
|
|
||||||
|
// Contact Info Elements
|
||||||
|
contactInfoPlaceholder: document.querySelector("#contact-info-placeholder"),
|
||||||
|
contactInfoContent: document.querySelector("#contact-info-content"),
|
||||||
|
contactDetailsAvatar: document.querySelector("#contactDetailsAvatar"),
|
||||||
|
contactDetailsName: document.querySelector("#contactDetailsName"),
|
||||||
|
contactDetailsDesc: document.querySelector("#contactDetailsDesc"),
|
||||||
|
contactDetailsEmail: document.querySelector("#contactDetailsEmail"),
|
||||||
|
contactDetailsPhone: document.querySelector("#contactDetailsPhone"),
|
||||||
|
contactDetailsAddress: document.querySelector("#contactDetailsAddress"),
|
||||||
|
|
||||||
|
// Create Contact Elements
|
||||||
|
createContactForm: document.querySelector("#createContactForm"),
|
||||||
|
newContactFirstName: document.querySelector("#newContactFirstName"),
|
||||||
|
newContactLastName: document.querySelector("#newContactLastName"),
|
||||||
|
newContactEmail: document.querySelector("#newContactEmail"),
|
||||||
|
newContactPhone: document.querySelector("#newContactPhone"),
|
||||||
|
newContactAddress: document.querySelector("#newContactAddress"),
|
||||||
|
newContactDesc: document.querySelector("#newContactDesc"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mode Buttons
|
||||||
document.querySelectorAll("[data-mode]").forEach((button) => {
|
document.querySelectorAll("[data-mode]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
state.mode = button.dataset.mode;
|
state.mode = button.dataset.mode;
|
||||||
@@ -38,6 +64,20 @@ document.querySelectorAll("[data-mode]").forEach((button) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tab Switch Listeners
|
||||||
|
elements.tabButtons.forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const tabName = btn.dataset.tab;
|
||||||
|
elements.tabButtons.forEach((b) => b.classList.toggle("is-active", b === btn));
|
||||||
|
elements.tabViews.forEach((v) => v.classList.toggle("is-active", v.id === `view-${tabName}`));
|
||||||
|
|
||||||
|
if (tabName === "contact-info") {
|
||||||
|
loadSelectedContactDetails();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dictate Control Listeners
|
||||||
elements.analyzeButton.addEventListener("click", analyzeInput);
|
elements.analyzeButton.addEventListener("click", analyzeInput);
|
||||||
elements.clearButton.addEventListener("click", () => {
|
elements.clearButton.addEventListener("click", () => {
|
||||||
elements.inputText.value = "";
|
elements.inputText.value = "";
|
||||||
@@ -53,12 +93,64 @@ elements.saveButton.addEventListener("click", saveEntry);
|
|||||||
elements.contactSearch.addEventListener("input", debounce(loadContacts, 240));
|
elements.contactSearch.addEventListener("input", debounce(loadContacts, 240));
|
||||||
elements.voiceButton.addEventListener("click", toggleSpeech);
|
elements.voiceButton.addEventListener("click", toggleSpeech);
|
||||||
|
|
||||||
|
// Select Contact Listener
|
||||||
|
elements.contactSelect.addEventListener("change", () => {
|
||||||
|
const val = elements.contactSelect.value;
|
||||||
|
state.selectedContactId = val ? Number(val) : null;
|
||||||
|
if (state.selectedContactId) {
|
||||||
|
loadSelectedContactDetails();
|
||||||
|
} else {
|
||||||
|
hideContactDetails();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Contact Form Listener
|
||||||
|
elements.createContactForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const contactData = {
|
||||||
|
first_name: elements.newContactFirstName.value.trim(),
|
||||||
|
last_name: elements.newContactLastName.value.trim(),
|
||||||
|
email: elements.newContactEmail.value.trim(),
|
||||||
|
phone: elements.newContactPhone.value.trim(),
|
||||||
|
address: elements.newContactAddress.value.trim(),
|
||||||
|
description: elements.newContactDesc.value.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
log("Erstelle neuen Kontakt in Monica...");
|
||||||
|
const res = await api("/api/contacts/create", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(contactData),
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`Kontakt ${res.data.name} wurde erfolgreich erstellt!`);
|
||||||
|
elements.createContactForm.reset();
|
||||||
|
|
||||||
|
// Reload contacts and auto-select new one
|
||||||
|
await loadContacts();
|
||||||
|
elements.contactSelect.value = res.data.id;
|
||||||
|
state.selectedContactId = res.data.id;
|
||||||
|
elements.contactSearch.value = res.data.name;
|
||||||
|
|
||||||
|
// Switch to dictate and load details
|
||||||
|
elements.tabButtons[0].click();
|
||||||
|
loadSelectedContactDetails();
|
||||||
|
} catch (error) {
|
||||||
|
log(`Kontakt konnte nicht erstellt werden: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await boot();
|
await boot();
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
await checkHealth();
|
await checkHealth();
|
||||||
await loadContacts();
|
await loadContacts();
|
||||||
setupSpeechRecognition();
|
setupSpeechRecognition();
|
||||||
|
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkHealth() {
|
async function checkHealth() {
|
||||||
@@ -91,14 +183,57 @@ function renderContacts(selectedName = "") {
|
|||||||
const empty = new Option("Kein Kontakt ausgewählt", "");
|
const empty = new Option("Kein Kontakt ausgewählt", "");
|
||||||
elements.contactSelect.append(empty);
|
elements.contactSelect.append(empty);
|
||||||
|
|
||||||
|
let selectedId = "";
|
||||||
for (const contact of state.contacts) {
|
for (const contact of state.contacts) {
|
||||||
const option = new Option(contact.name, contact.id);
|
const option = new Option(contact.name, contact.id);
|
||||||
option.dataset.name = contact.name;
|
option.dataset.name = contact.name;
|
||||||
|
|
||||||
if (selectedName && contact.name.toLowerCase().includes(selectedName.toLowerCase())) {
|
if (selectedName && contact.name.toLowerCase().includes(selectedName.toLowerCase())) {
|
||||||
option.selected = true;
|
option.selected = true;
|
||||||
|
selectedId = contact.id;
|
||||||
}
|
}
|
||||||
elements.contactSelect.append(option);
|
elements.contactSelect.append(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedId) {
|
||||||
|
state.selectedContactId = Number(selectedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedContactDetails() {
|
||||||
|
if (!state.selectedContactId) {
|
||||||
|
hideContactDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await api(`/api/contacts/${state.selectedContactId}`);
|
||||||
|
|
||||||
|
elements.contactDetailsName.textContent = details.name;
|
||||||
|
elements.contactDetailsDesc.textContent = details.description || "Keine Beschreibung";
|
||||||
|
elements.contactDetailsEmail.textContent = details.email || "-";
|
||||||
|
elements.contactDetailsPhone.textContent = details.phone || "-";
|
||||||
|
elements.contactDetailsAddress.textContent = details.address || "-";
|
||||||
|
|
||||||
|
const avatarLetter = details.first_name ? details.first_name.slice(0, 1).toUpperCase() : "?";
|
||||||
|
elements.contactDetailsAvatar.textContent = avatarLetter;
|
||||||
|
|
||||||
|
elements.contactInfoPlaceholder.hidden = true;
|
||||||
|
elements.contactInfoContent.removeAttribute("hidden");
|
||||||
|
elements.contactInfoContent.style.display = "block";
|
||||||
|
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Kontaktdetails konnten nicht geladen werden: ${error.message}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideContactDetails() {
|
||||||
|
elements.contactInfoPlaceholder.hidden = false;
|
||||||
|
elements.contactInfoContent.setAttribute("hidden", "true");
|
||||||
|
elements.contactInfoContent.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeInput() {
|
async function analyzeInput() {
|
||||||
@@ -112,10 +247,14 @@ async function analyzeInput() {
|
|||||||
try {
|
try {
|
||||||
const analysis = await api("/api/analyze", {
|
const analysis = await api("/api/analyze", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ text, mode: state.mode }),
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
mode: state.mode,
|
||||||
|
contacts: state.contacts
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
state.analysis = analysis;
|
state.analysis = analysis;
|
||||||
renderAnalysis(analysis);
|
await renderAnalysis(analysis);
|
||||||
log("Ich habe daraus einen Vorschlag gemacht. Bitte kurz prüfen.");
|
log("Ich habe daraus einen Vorschlag gemacht. Bitte kurz prüfen.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(error.message, "error");
|
log(error.message, "error");
|
||||||
@@ -124,7 +263,7 @@ async function analyzeInput() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAnalysis(analysis) {
|
async function renderAnalysis(analysis) {
|
||||||
elements.review.hidden = false;
|
elements.review.hidden = false;
|
||||||
elements.typeSelect.value = analysis.type;
|
elements.typeSelect.value = analysis.type;
|
||||||
elements.summaryInput.value = analysis.summary;
|
elements.summaryInput.value = analysis.summary;
|
||||||
@@ -135,10 +274,26 @@ function renderAnalysis(analysis) {
|
|||||||
elements.journalPost.value = analysis.journal?.post || "";
|
elements.journalPost.value = analysis.journal?.post || "";
|
||||||
elements.reminders.innerHTML = "";
|
elements.reminders.innerHTML = "";
|
||||||
|
|
||||||
const inferredContact = analysis.contact?.name || "";
|
const inferredContact = analysis.contact;
|
||||||
if (inferredContact) {
|
if (inferredContact && inferredContact.id) {
|
||||||
elements.contactSearch.value = inferredContact;
|
let match = state.contacts.find(c => c.id === inferredContact.id);
|
||||||
loadContacts().then(() => renderContacts(inferredContact));
|
if (!match) {
|
||||||
|
state.contacts.push({
|
||||||
|
id: inferredContact.id,
|
||||||
|
name: inferredContact.name,
|
||||||
|
description: inferredContact.description || ""
|
||||||
|
});
|
||||||
|
renderContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.contactSearch.value = "";
|
||||||
|
elements.contactSelect.value = inferredContact.id;
|
||||||
|
state.selectedContactId = inferredContact.id;
|
||||||
|
await loadSelectedContactDetails();
|
||||||
|
} else {
|
||||||
|
elements.contactSelect.value = "";
|
||||||
|
state.selectedContactId = null;
|
||||||
|
hideContactDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const reminder of analysis.reminders || []) {
|
for (const reminder of analysis.reminders || []) {
|
||||||
@@ -246,7 +401,7 @@ function setupSpeechRecognition() {
|
|||||||
recognition.addEventListener("end", () => {
|
recognition.addEventListener("end", () => {
|
||||||
state.listening = false;
|
state.listening = false;
|
||||||
elements.voiceButton.classList.remove("listening");
|
elements.voiceButton.classList.remove("listening");
|
||||||
elements.voiceButton.textContent = "Aufnehmen";
|
elements.voiceButton.innerHTML = '<span class="mic-pulse-circle"></span> Aufnehmen';
|
||||||
});
|
});
|
||||||
|
|
||||||
recognition.addEventListener("error", (event) => {
|
recognition.addEventListener("error", (event) => {
|
||||||
@@ -266,7 +421,7 @@ function toggleSpeech() {
|
|||||||
|
|
||||||
state.listening = true;
|
state.listening = true;
|
||||||
elements.voiceButton.classList.add("listening");
|
elements.voiceButton.classList.add("listening");
|
||||||
elements.voiceButton.textContent = "Stoppen";
|
elements.voiceButton.innerHTML = '<span class="mic-pulse-circle recording"></span> Stoppen';
|
||||||
state.recognition.start();
|
state.recognition.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<meta name="description" content="Voice-first relationship companion for MischLabs and Monica CRM." />
|
<meta name="description" content="Voice-first relationship companion for MischLabs and Monica CRM." />
|
||||||
<meta name="theme-color" content="#070914" />
|
<meta name="theme-color" content="#070914" />
|
||||||
<link rel="icon" type="image/png" href="/icons/app-icon-source.png" />
|
<link rel="icon" type="image/png" href="/icons/app-icon-source.png" />
|
||||||
|
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<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=Inter:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -32,7 +38,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="shell tab-nav">
|
||||||
|
<button class="tab-btn is-active" data-tab="dictate" type="button"><i data-lucide="mic"></i> Diktieren</button>
|
||||||
|
<button class="tab-btn" data-tab="contact-info" type="button" id="tabBtnContactInfo"><i data-lucide="user"></i> Kontakt-Info</button>
|
||||||
|
<button class="tab-btn" data-tab="new-contact" type="button"><i data-lucide="user-plus"></i> Neuer Kontakt</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
|
<!-- VIEW 1: DIKTIEREN -->
|
||||||
|
<div id="view-dictate" class="tab-view is-active">
|
||||||
<section class="control-bar" aria-label="Neue Erinnerung erfassen">
|
<section class="control-bar" aria-label="Neue Erinnerung erfassen">
|
||||||
<div class="mode-cluster">
|
<div class="mode-cluster">
|
||||||
<div class="segments" role="group" aria-label="Modus">
|
<div class="segments" role="group" aria-label="Modus">
|
||||||
@@ -41,7 +55,9 @@
|
|||||||
<button class="segment" data-mode="journal" type="button">Tagebuch</button>
|
<button class="segment" data-mode="journal" type="button">Tagebuch</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="voiceButton" class="icon-button voice" type="button">Aufnehmen</button>
|
<button id="voiceButton" class="icon-button voice" type="button">
|
||||||
|
<span class="mic-pulse-circle"></span> Aufnehmen
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="composer">
|
<section class="composer">
|
||||||
@@ -128,6 +144,84 @@
|
|||||||
<button id="discardButton" class="ghost" type="button">Verwerfen</button>
|
<button id="discardButton" class="ghost" type="button">Verwerfen</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW 2: KONTAKT-INFO -->
|
||||||
|
<div id="view-contact-info" class="tab-view">
|
||||||
|
<section class="panel glass-card contact-details-view">
|
||||||
|
<div id="contact-info-placeholder" class="info-placeholder">
|
||||||
|
<i data-lucide="info" class="placeholder-icon"></i>
|
||||||
|
<p>Wähle einen Kontakt im Diktieren-Tab aus, um Details anzuzeigen.</p>
|
||||||
|
</div>
|
||||||
|
<div id="contact-info-content" hidden>
|
||||||
|
<div class="contact-header">
|
||||||
|
<div class="contact-avatar" id="contactDetailsAvatar">A</div>
|
||||||
|
<div>
|
||||||
|
<h2 id="contactDetailsName">Kontaktname</h2>
|
||||||
|
<p class="eyebrow" id="contactDetailsDesc">Beschreibung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="contact-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label"><i data-lucide="mail"></i> E-Mail</span>
|
||||||
|
<span class="info-value" id="contactDetailsEmail">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label"><i data-lucide="phone"></i> Telefon</span>
|
||||||
|
<span class="info-value" id="contactDetailsPhone">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label"><i data-lucide="map-pin"></i> Adresse</span>
|
||||||
|
<span class="info-value" id="contactDetailsAddress">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VIEW 3: NEUER KONTAKT -->
|
||||||
|
<div id="view-new-contact" class="tab-view">
|
||||||
|
<section class="composer new-contact-form">
|
||||||
|
<p class="section-kicker">Hinzufügen</p>
|
||||||
|
<h2>Neuen Kontakt erstellen</h2>
|
||||||
|
<p class="subtitle">Lege eine neue Person direkt in deinem CRM an.</p>
|
||||||
|
|
||||||
|
<form id="createContactForm">
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
Vorname *
|
||||||
|
<input id="newContactFirstName" type="text" required placeholder="z. B. Aaron" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Nachname
|
||||||
|
<input id="newContactLastName" type="text" placeholder="z. B. Lingel" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input id="newContactEmail" type="email" placeholder="aaron.lingel@example.com" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Telefon
|
||||||
|
<input id="newContactPhone" type="tel" placeholder="+49 176 ..." />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Adresse
|
||||||
|
<input id="newContactAddress" type="text" placeholder="Straße, PLZ Ort" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Beschreibung / Notiz
|
||||||
|
<textarea id="newContactDesc" rows="4" placeholder="z. B. Kumpel aus der Uni, studiert Informatik..."></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" type="submit"><i data-lucide="check"></i> Kontakt anlegen</button>
|
||||||
|
<button class="ghost" type="reset" id="newContactResetBtn">Leeren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="log" id="messageLog" aria-live="polite"></section>
|
<section class="log" id="messageLog" aria-live="polite"></section>
|
||||||
</main>
|
</main>
|
||||||
@@ -145,6 +239,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Lucide Icons & App Script -->
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
<script src="/app.js" type="module"></script>
|
<script src="/app.js" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
--down: #fb7185;
|
--down: #fb7185;
|
||||||
--cyan: #67e8f9;
|
--cyan: #67e8f9;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@@ -406,3 +406,244 @@ input::placeholder {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography: Outfit for headers */
|
||||||
|
h1, h2, h3, .brand h1, .section-kicker, .eyebrow, .tab-btn {
|
||||||
|
font-family: 'Outfit', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs & Navigation */
|
||||||
|
.tab-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(17, 22, 36, 0.65);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn i, .tab-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.is-active {
|
||||||
|
color: #062027;
|
||||||
|
background: linear-gradient(135deg, #67e8f9 0%, #c084fc 100%);
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 8px 24px rgba(103, 232, 249, 0.22);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Views Show/Hide */
|
||||||
|
.tab-view {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-view.is-active {
|
||||||
|
display: block !important;
|
||||||
|
animation: fadeIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contact Details View Styles */
|
||||||
|
.contact-details-view {
|
||||||
|
background: rgba(17, 22, 36, 0.74);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #c084fc 100%);
|
||||||
|
color: #070914;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 8px 24px rgba(192, 132, 252, 0.25);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-header h2 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-header .eyebrow {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(7, 9, 20, 0.38);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label i, .info-label svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New Contact Form Styles */
|
||||||
|
.new-contact-form {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-contact-form form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-contact-form label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-contact-form label input,
|
||||||
|
.new-contact-form label textarea {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice Microphone Pulsing Recording Animation */
|
||||||
|
.mic-pulse-circle {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-pulse-circle.recording {
|
||||||
|
background: var(--down);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.7);
|
||||||
|
animation: mic-pulse 1.5s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mic-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.8);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 0 10px rgba(251, 113, 133, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(0.9);
|
||||||
|
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
194
server.js
194
server.js
@@ -37,7 +37,8 @@ app.get("/api/contacts", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const suffix = query ? `?query=${encodeURIComponent(query)}` : "";
|
const limit = 150;
|
||||||
|
const suffix = query ? `?query=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`;
|
||||||
const payload = await monicaFetch(`/api/contacts${suffix}`);
|
const payload = await monicaFetch(`/api/contacts${suffix}`);
|
||||||
const contacts = Array.isArray(payload.data) ? payload.data : [];
|
const contacts = Array.isArray(payload.data) ? payload.data : [];
|
||||||
res.json({
|
res.json({
|
||||||
@@ -53,16 +54,160 @@ app.get("/api/contacts", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/contacts/:id", async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!monicaApiToken) {
|
||||||
|
res.json(demoContactDetails(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contactPayload = await monicaFetch(`/api/contacts/${id}`);
|
||||||
|
const contact = contactPayload.data || {};
|
||||||
|
|
||||||
|
let emails = [];
|
||||||
|
let phones = [];
|
||||||
|
try {
|
||||||
|
const fieldsPayload = await monicaFetch(`/api/contacts/${id}/contactfields`);
|
||||||
|
const fields = Array.isArray(fieldsPayload.data) ? fieldsPayload.data : [];
|
||||||
|
emails = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("email") || f.contact_field_type_id === 1).map(f => f.data);
|
||||||
|
phones = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("phone") || f.contact_field_type_id === 2 || f.contact_field_type_id === 3 || f.contact_field_type?.name?.toLowerCase().includes("telefon") || f.contact_field_type?.name?.toLowerCase().includes("mobil")).map(f => f.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Could not load contact fields:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let addressStr = "";
|
||||||
|
try {
|
||||||
|
const addressPayload = await monicaFetch(`/api/contacts/${id}/addresses`);
|
||||||
|
const addresses = Array.isArray(addressPayload.data) ? addressPayload.data : [];
|
||||||
|
if (addresses.length > 0) {
|
||||||
|
const addr = addresses[0];
|
||||||
|
addressStr = [addr.street, addr.postal_code, addr.city].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Could not load addresses:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: contact.id,
|
||||||
|
first_name: contact.first_name || "",
|
||||||
|
last_name: contact.last_name || "",
|
||||||
|
name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "),
|
||||||
|
description: contact.description || "",
|
||||||
|
birthdate: contact.birthdate || "",
|
||||||
|
email: emails[0] || "",
|
||||||
|
phone: phones[0] || "",
|
||||||
|
address: addressStr || "",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(502).json({ error: "Monica contact details could not be loaded.", detail: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/contacts/create", async (req, res) => {
|
||||||
|
const { first_name, last_name, description, email, phone, address } = req.body || {};
|
||||||
|
if (!first_name) {
|
||||||
|
res.status(400).json({ error: "First name is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!monicaApiToken) {
|
||||||
|
const newId = Math.floor(Math.random() * 1000) + 10;
|
||||||
|
const newContact = {
|
||||||
|
id: newId,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
name: [first_name, last_name].filter(Boolean).join(" "),
|
||||||
|
description: description || "Neu angelegter Demo-Kontakt",
|
||||||
|
email: email || "",
|
||||||
|
phone: phone || "",
|
||||||
|
address: address || "",
|
||||||
|
};
|
||||||
|
res.json({ ok: true, mode: "demo", data: newContact });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contactPayload = await monicaFetch("/api/contacts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name,
|
||||||
|
last_name: last_name || "",
|
||||||
|
description: description || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const contact = contactPayload.data || {};
|
||||||
|
const contactId = contact.id;
|
||||||
|
|
||||||
|
if (contactId && email) {
|
||||||
|
try {
|
||||||
|
await monicaFetch("/api/contactfields", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
contact_id: contactId,
|
||||||
|
contact_field_type_id: 1, // Email
|
||||||
|
data: email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not save email:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactId && phone) {
|
||||||
|
try {
|
||||||
|
await monicaFetch("/api/contactfields", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
contact_id: contactId,
|
||||||
|
contact_field_type_id: 3, // Cell phone
|
||||||
|
data: phone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not save phone:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactId && address) {
|
||||||
|
try {
|
||||||
|
await monicaFetch("/api/addresses", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
contact_id: contactId,
|
||||||
|
name: "Hauptadresse",
|
||||||
|
street: address,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not save address:", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
mode: "monica",
|
||||||
|
data: {
|
||||||
|
id: contactId,
|
||||||
|
name: contact.complete_name || [first_name, last_name].filter(Boolean).join(" "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(502).json({ error: "Contact could not be created in Monica.", detail: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post("/api/analyze", async (req, res) => {
|
app.post("/api/analyze", async (req, res) => {
|
||||||
const text = String(req.body.text || "").trim();
|
const text = String(req.body.text || "").trim();
|
||||||
const mode = String(req.body.mode || "auto");
|
const mode = String(req.body.mode || "auto");
|
||||||
|
const contactsList = req.body.contacts || [];
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
res.status(400).json({ error: "Text is required." });
|
res.status(400).json({ error: "Text is required." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(analyzeText(text, mode));
|
res.json(analyzeText(text, mode, contactsList));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/save", async (req, res) => {
|
app.post("/api/save", async (req, res) => {
|
||||||
@@ -152,9 +297,9 @@ async function monicaWrite(type, apiPath, body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeText(text, mode = "auto") {
|
function analyzeText(text, mode = "auto", contactsList = []) {
|
||||||
const normalized = text.replace(/\s+/g, " ").trim();
|
const normalized = text.replace(/\s+/g, " ").trim();
|
||||||
const contacts = mode === "journal" ? [] : inferContacts(normalized);
|
const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList);
|
||||||
const reminder = inferReminder(normalized);
|
const reminder = inferReminder(normalized);
|
||||||
const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode;
|
const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode;
|
||||||
const summary = summarize(normalized);
|
const summary = summarize(normalized);
|
||||||
@@ -194,8 +339,37 @@ function inferType(text, contacts, reminder) {
|
|||||||
return "journal";
|
return "journal";
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferContacts(text) {
|
function inferContacts(text, contactsList = []) {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
|
|
||||||
|
// 1. Scan for echten Kontakten (from DB/Monica)
|
||||||
|
if (Array.isArray(contactsList) && contactsList.length > 0) {
|
||||||
|
for (const contact of contactsList) {
|
||||||
|
if (!contact.name) continue;
|
||||||
|
const nameParts = contact.name.trim().split(/\s+/);
|
||||||
|
const firstName = nameParts[0];
|
||||||
|
const fullName = contact.name.trim();
|
||||||
|
|
||||||
|
const escFullName = fullName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
const escFirstName = firstName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
|
||||||
|
const regexFull = new RegExp(`\\b${escFullName}\\b`, 'i');
|
||||||
|
const regexFirst = new RegExp(`\\b${escFirstName}\\b`, 'i');
|
||||||
|
|
||||||
|
if (regexFull.test(text) || (firstName.length > 2 && regexFirst.test(text))) {
|
||||||
|
if (!candidates.some((item) => item.id === contact.id)) {
|
||||||
|
candidates.push({
|
||||||
|
id: contact.id,
|
||||||
|
name: contact.name,
|
||||||
|
description: contact.description || ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback / Ergänzung durch klassische Regexes (wenn keine echten Kontakte gematcht wurden)
|
||||||
|
if (candidates.length === 0) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g,
|
/(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g,
|
||||||
/([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g,
|
/([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g,
|
||||||
@@ -211,10 +385,20 @@ function inferContacts(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return candidates.slice(0, 3);
|
return candidates.slice(0, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function demoContactDetails(id) {
|
||||||
|
const details = {
|
||||||
|
1: { id: 1, first_name: "Aaron", last_name: "Lingel", name: "Aaron Lingel", description: "Bester Kumpel auf dem Campus", email: "aaron.lingel@example.com", phone: "+49 176 898989", address: "Fischbrötchenallee 42, 22767 Hamburg" },
|
||||||
|
2: { id: 2, first_name: "Tom", last_name: "", name: "Tom", description: "Vibecoding-Kollege", email: "tom@example.com", phone: "+49 152 111222", address: "Mischweg 7, 20357 Hamburg" },
|
||||||
|
3: { id: 3, first_name: "MrDiderot", last_name: "", name: "MrDiderot", description: "Admin und CRM Host", email: "mrdiderot@mischlabs.de", phone: "+49 170 333444", address: "NAS-Zentrale 1, 20095 Hamburg" },
|
||||||
|
};
|
||||||
|
return details[id] || { id, first_name: "Unbekannt", last_name: "", name: "Unbekannter Kontakt", description: "", email: "", phone: "", address: "" };
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupName(name) {
|
function cleanupName(name) {
|
||||||
const cleaned = name
|
const cleaned = name
|
||||||
.replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "")
|
.replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user