CardSync/frontend/index.html

766 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CardSync — Synology · Microsoft 365</title>
<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=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0e0f14;
--surface: #16181f;
--surface2: #1e2029;
--border: #2a2d38;
--accent: #4f8ef7;
--accent2: #7c5cfc;
--success: #3ecf8e;
--error: #f76c6c;
--text: #e8eaf0;
--muted: #7a7f96;
--ms-blue: #0078d4;
}
html { height: 100%; }
body {
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 15px;
line-height: 1.6;
display: flex;
flex-direction: column;
}
/* Background grid */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(79,142,247,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(79,142,247,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Glow blobs */
.blob {
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.12;
pointer-events: none;
z-index: 0;
}
.blob-1 {
width: 500px; height: 500px;
background: var(--accent);
top: -150px; left: -100px;
animation: drift 12s ease-in-out infinite alternate;
}
.blob-2 {
width: 400px; height: 400px;
background: var(--accent2);
bottom: -100px; right: -100px;
animation: drift 15s ease-in-out infinite alternate-reverse;
}
@keyframes drift {
from { transform: translate(0, 0); }
to { transform: translate(30px, 20px); }
}
/* Layout */
.page {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
padding: 24px 40px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.logo-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.logo-text {
font-family: 'DM Serif Display', serif;
font-size: 20px;
color: var(--text);
letter-spacing: -0.3px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-ghost {
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--muted);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-ghost:hover {
border-color: var(--accent);
color: var(--text);
}
/* Hero */
main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: rgba(79,142,247,0.08);
border: 1px solid rgba(79,142,247,0.2);
border-radius: 100px;
font-size: 12px;
font-weight: 500;
color: var(--accent);
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 32px;
}
h1 {
font-family: 'DM Serif Display', serif;
font-size: clamp(36px, 6vw, 64px);
line-height: 1.1;
text-align: center;
letter-spacing: -1px;
max-width: 800px;
margin-bottom: 20px;
}
h1 em {
font-style: italic;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: var(--muted);
font-size: 17px;
max-width: 520px;
margin-bottom: 48px;
font-weight: 300;
}
/* Connect Card */
.connect-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 40px;
width: 100%;
max-width: 460px;
position: relative;
overflow: hidden;
}
.connect-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2));
}
.card-title {
font-family: 'DM Serif Display', serif;
font-size: 22px;
margin-bottom: 8px;
}
.card-desc {
color: var(--muted);
font-size: 14px;
margin-bottom: 28px;
}
.flow-steps {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 28px;
}
.flow-step {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
color: var(--muted);
}
.step-num {
width: 22px; height: 22px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.btn-ms {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 24px;
background: var(--ms-blue);
color: white;
border: none;
border-radius: 10px;
font-family: 'DM Sans', sans-serif;
font-size: 15px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
box-shadow: 0 4px 20px rgba(0,120,212,0.3);
}
.btn-ms:hover {
background: #106ebe;
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(0,120,212,0.4);
}
.btn-ms:active { transform: translateY(0); }
.ms-logo {
width: 20px; height: 20px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
}
.ms-logo span {
display: block;
border-radius: 1px;
}
.ms-logo span:nth-child(1) { background: #f25022; }
.ms-logo span:nth-child(2) { background: #7fba00; }
.ms-logo span:nth-child(3) { background: #00a4ef; }
.ms-logo span:nth-child(4) { background: #ffb900; }
.privacy-note {
text-align: center;
font-size: 12px;
color: var(--muted);
margin-top: 16px;
}
/* Status (logged in view) */
#status-view { display: none; }
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.status-bar.success { background: rgba(62,207,142,0.1); color: var(--success); border: 1px solid rgba(62,207,142,0.2); }
.status-bar.error { background: rgba(247,108,108,0.1); color: var(--error); border: 1px solid rgba(247,108,108,0.2); }
.status-bar.info { background: rgba(79,142,247,0.1); color: var(--accent); border: 1px solid rgba(79,142,247,0.2); }
.status-bar.running { background: rgba(255,185,0,0.1); color: #ffb900; border: 1px solid rgba(255,185,0,0.2); }
.select-field, .input-field {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'DM Sans', sans-serif;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.select-field:focus, .input-field:focus { border-color: var(--accent); }
.select-field option { background: var(--surface2); }
.field-label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.field-group { margin-bottom: 16px; }
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: white;
border: none;
border-radius: 8px;
font-family: 'DM Sans', sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
font-family: 'DM Sans', sans-serif;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover { border-color: var(--accent); }
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.toggle-wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
margin-bottom: 16px;
}
.toggle-label { font-size: 14px; font-weight: 500; }
.toggle-sub { font-size: 12px; color: var(--muted); }
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border);
border-radius: 12px;
cursor: pointer;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 18px; height: 18px;
left: 3px; top: 3px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider { background: var(--accent); }
.toggle input:checked + .toggle-slider::before { transform: translateX(18px); }
/* Features strip */
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
max-width: 700px;
width: 100%;
margin-top: 60px;
}
.feature {
text-align: center;
padding: 24px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
}
.feature-icon {
font-size: 28px;
margin-bottom: 10px;
}
.feature-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.feature-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
/* Footer */
footer {
padding: 20px 40px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
position: relative;
z-index: 1;
}
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); display: inline-block; margin-right: 6px; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.3} }
@media (max-width: 600px) {
header { padding: 16px 20px; }
.features { grid-template-columns: 1fr; }
.connect-card { padding: 28px 20px; }
footer { flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<div class="page">
<!-- Blobs -->
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<header>
<a class="logo" href="/">
<div class="logo-icon"></div>
<span class="logo-text">CardSync</span>
</a>
<div class="header-right">
<a id="admin-link" href="/auth/login?mode=admin" class="btn-ghost" style="display:none">Admin</a>
<button id="logout-btn" class="btn-ghost" style="display:none" onclick="logout()">Abmelden</button>
</div>
</header>
<main>
<div class="badge">🔄 Synology → Microsoft 365</div>
<h1>Kontakte <em>automatisch</em><br>synchronisieren</h1>
<p class="subtitle">Verbinde dein Synology Adressbuch mit Microsoft 365 — einmalig autorisieren, ab dann läuft alles automatisch.</p>
<!-- Login View -->
<div id="login-view" class="connect-card">
<h2 class="card-title">Jetzt verbinden</h2>
<p class="card-desc">Autorisiere den Zugriff auf deine Microsoft-Kontakte — einmalig, sicher per OAuth.</p>
<div class="flow-steps">
<div class="flow-step">
<div class="step-num">1</div>
<span>Mit Microsoft-Konto anmelden</span>
</div>
<div class="flow-step">
<div class="step-num">2</div>
<span>Kontakte-Zugriff erlauben</span>
</div>
<div class="flow-step">
<div class="step-num">3</div>
<span>Adressbuch auswählen & Sync startet</span>
</div>
</div>
<a href="/auth/login" class="btn-ms">
<div class="ms-logo">
<span></span><span></span><span></span><span></span>
</div>
Mit Microsoft anmelden
</a>
<p class="privacy-note">🔒 Nur Kontakte werden synchronisiert. Keine Daten werden gespeichert oder weitergegeben.</p>
</div>
<!-- Logged-in View -->
<div id="status-view" class="connect-card" style="max-width:500px">
<h2 class="card-title">Dein Sync</h2>
<p class="card-desc">Angemeldet als <strong id="user-email"></strong></p>
<div id="sync-status-bar" class="status-bar info" style="margin-bottom:20px">
<span id="sync-status-text">Lade Status…</span>
</div>
<div class="field-group">
<div class="field-label">Adressbuch auswählen</div>
<select id="folder-select" class="select-field">
<option value="">Wird geladen…</option>
</select>
</div>
<div class="field-group">
<div class="field-label">Sync-Intervall</div>
<select id="interval-select" class="select-field">
<option value="15">Alle 15 Minuten</option>
<option value="30">Alle 30 Minuten</option>
<option value="60" selected>Stündlich</option>
<option value="360">Alle 6 Stunden</option>
<option value="720">Alle 12 Stunden</option>
<option value="1440">Täglich</option>
</select>
</div>
<div class="toggle-wrap">
<div>
<div class="toggle-label">Automatischer Sync</div>
<div class="toggle-sub">Kontakte werden regelmäßig synchronisiert</div>
</div>
<label class="toggle">
<input type="checkbox" id="sync-toggle" onchange="toggleSync()">
<span class="toggle-slider"></span>
</label>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="saveSettings()">💾 Einstellungen speichern</button>
<button class="btn-secondary" onclick="syncNow()" id="sync-btn">⚡ Jetzt synchronisieren</button>
</div>
</div>
<div class="features">
<div class="feature">
<div class="feature-icon">🔒</div>
<div class="feature-title">Sicher</div>
<div class="feature-desc">OAuth 2.0 — keine Passwörter gespeichert</div>
</div>
<div class="feature">
<div class="feature-icon"></div>
<div class="feature-title">Automatisch</div>
<div class="feature-desc">Konfigurierbare Intervalle ab 15 Minuten</div>
</div>
<div class="feature">
<div class="feature-icon">📋</div>
<div class="feature-title">Einseitig</div>
<div class="feature-desc">Nur Synology → MS365, kein Datenverlust</div>
</div>
</div>
</main>
<footer>
<span><span class="dot"></span>System läuft</span>
<span>CardSync &mdash; Self-hosted</span>
</footer>
</div>
<script>
const API = '';
async function init() {
try {
const resp = await fetch(`${API}/auth/me`, { credentials: 'include' });
if (!resp.ok) throw new Error('not logged in');
const me = await resp.json();
document.getElementById('login-view').style.display = 'none';
document.getElementById('status-view').style.display = 'block';
document.getElementById('user-email').textContent = me.email;
document.getElementById('logout-btn').style.display = 'inline-flex';
if (me.is_admin) {
document.getElementById('admin-link').style.display = 'inline-flex';
}
// Sync-Status anzeigen
updateStatusBar(me.last_sync_status, me.last_sync_message, me.last_sync_at);
// Einstellungen laden
if (me.sync_interval_minutes) {
document.getElementById('interval-select').value = me.sync_interval_minutes;
}
document.getElementById('sync-toggle').checked = me.sync_enabled;
// Ordner laden
await loadFolders(me.carddav_folder);
} catch (e) {
document.getElementById('login-view').style.display = 'block';
document.getElementById('status-view').style.display = 'none';
}
}
function updateStatusBar(status, message, lastSyncAt) {
const bar = document.getElementById('sync-status-bar');
const text = document.getElementById('sync-status-text');
bar.className = 'status-bar';
if (!status) {
bar.classList.add('info');
text.textContent = 'Noch kein Sync durchgeführt';
return;
}
const timeStr = lastSyncAt ? ` · ${new Date(lastSyncAt).toLocaleString('de-DE')}` : '';
if (status === 'success') {
bar.classList.add('success');
text.textContent = `${message || 'Erfolgreich synchronisiert'}${timeStr}`;
} else if (status === 'error') {
bar.classList.add('error');
text.textContent = `✗ Fehler: ${message}${timeStr}`;
} else if (status === 'running') {
bar.classList.add('running');
text.textContent = `⟳ Sync läuft…`;
} else {
bar.classList.add('info');
text.textContent = message || status;
}
}
async function loadFolders(currentFolder) {
const sel = document.getElementById('folder-select');
try {
const resp = await fetch(`${API}/api/carddav/folders`, { credentials: 'include' });
if (!resp.ok) {
sel.innerHTML = '<option value="">Synology nicht konfiguriert</option>';
return;
}
const data = await resp.json();
sel.innerHTML = '<option value="">Adressbuch wählen…</option>';
data.folders.forEach(f => {
const opt = document.createElement('option');
opt.value = f.url;
opt.textContent = f.name;
if (f.url === currentFolder) opt.selected = true;
sel.appendChild(opt);
});
} catch {
sel.innerHTML = '<option value="">Fehler beim Laden</option>';
}
}
async function saveSettings() {
const folder = document.getElementById('folder-select').value;
const interval = parseInt(document.getElementById('interval-select').value);
const enabled = document.getElementById('sync-toggle').checked;
const resp = await fetch(`${API}/api/user/settings`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ carddav_folder: folder, sync_interval_minutes: interval, sync_enabled: enabled }),
});
if (resp.ok) {
showToast('Einstellungen gespeichert ✓', 'success');
} else {
showToast('Fehler beim Speichern', 'error');
}
}
async function toggleSync() {
await saveSettings();
}
async function syncNow() {
const btn = document.getElementById('sync-btn');
btn.disabled = true;
btn.textContent = '⟳ Läuft…';
try {
const resp = await fetch(`${API}/api/user/sync-now`, {
method: 'POST',
credentials: 'include',
});
const data = await resp.json();
updateStatusBar(data.status, data.message, new Date().toISOString());
showToast(data.status === 'success' ? `Sync abgeschlossen: ${data.message}` : `Fehler: ${data.message}`, data.status);
} catch {
showToast('Verbindungsfehler', 'error');
}
btn.disabled = false;
btn.textContent = '⚡ Jetzt synchronisieren';
}
async function logout() {
await fetch(`${API}/auth/logout`, { method: 'POST', credentials: 'include' });
window.location.reload();
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.style.cssText = `
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
padding: 12px 20px; border-radius: 10px; font-size: 14px; font-weight: 500;
background: ${type === 'success' ? 'rgba(62,207,142,0.15)' : type === 'error' ? 'rgba(247,108,108,0.15)' : 'rgba(79,142,247,0.15)'};
border: 1px solid ${type === 'success' ? 'rgba(62,207,142,0.3)' : type === 'error' ? 'rgba(247,108,108,0.3)' : 'rgba(79,142,247,0.3)'};
color: ${type === 'success' ? '#3ecf8e' : type === 'error' ? '#f76c6c' : '#4f8ef7'};
animation: slideIn 0.3s ease; font-family: 'DM Sans', sans-serif;
`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 4000);
}
init();
</script>
</body>
</html>