Implementiere Kundenportal mit zentraler Appwrite-Anbindung.
Express-Server für Appwrite-Auth, Session, Projekt-Dashboard und Gitea-Webhook; statisches Frontend und Schema-Dokumentation für woms-database. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
142
public/app.js
Normal file
142
public/app.js
Normal file
@@ -0,0 +1,142 @@
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
...options,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Fehler ${response.status}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function showError(el, message) {
|
||||
if (!el) return
|
||||
el.textContent = message
|
||||
el.classList.remove('hidden')
|
||||
}
|
||||
|
||||
function hideError(el) {
|
||||
if (el) el.classList.add('hidden')
|
||||
}
|
||||
|
||||
function featuresForProject(features, projectId) {
|
||||
return features.filter((f) => !f.projectId || f.projectId === projectId)
|
||||
}
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
li.className = 'project-card'
|
||||
const projectFeatures = featuresForProject(features, project.id)
|
||||
const featureHtml = projectFeatures.length
|
||||
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
|
||||
: '<p class="muted">Keine zusätzlichen Features freigeschaltet.</p>'
|
||||
|
||||
li.innerHTML = `
|
||||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||||
<dl>
|
||||
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
|
||||
<dt>Vorschau</dt><dd>${project.previewUrl ? `<a href="${escapeAttr(project.previewUrl)}" target="_blank" rel="noopener">${escapeHtml(project.previewUrl)}</a>` : '–'}</dd>
|
||||
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
|
||||
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
|
||||
<dt>Bereitstellung</dt><dd>${escapeHtml(project.provisioningStatus || '–')}</dd>
|
||||
</dl>
|
||||
${featureHtml}
|
||||
`
|
||||
return li
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return escapeHtml(str).replace(/'/g, ''')
|
||||
}
|
||||
|
||||
async function initLoginPage() {
|
||||
const form = document.getElementById('login-form')
|
||||
const errorEl = document.getElementById('login-error')
|
||||
const btn = document.getElementById('login-btn')
|
||||
|
||||
try {
|
||||
const me = await api('/api/auth/me')
|
||||
if (me.customer) {
|
||||
window.location.href = '/dashboard.html'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* not logged in */
|
||||
}
|
||||
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
errorEl.classList.add('hidden')
|
||||
btn.disabled = true
|
||||
try {
|
||||
await api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value,
|
||||
}),
|
||||
})
|
||||
window.location.href = '/dashboard.html'
|
||||
} catch (err) {
|
||||
showError(errorEl, err.message)
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function initDashboardPage() {
|
||||
const meta = document.getElementById('customer-meta')
|
||||
const list = document.getElementById('projects')
|
||||
const loading = document.getElementById('loading')
|
||||
const empty = document.getElementById('empty')
|
||||
const loadError = document.getElementById('load-error')
|
||||
const logoutBtn = document.getElementById('logout-btn')
|
||||
|
||||
logoutBtn?.addEventListener('click', async () => {
|
||||
await api('/api/auth/logout', { method: 'POST' })
|
||||
window.location.href = '/login.html'
|
||||
})
|
||||
|
||||
try {
|
||||
const [{ customer }, { projects }, { features }] = await Promise.all([
|
||||
api('/api/auth/me'),
|
||||
api('/api/projects'),
|
||||
api('/api/features'),
|
||||
])
|
||||
|
||||
meta.textContent = customer.name ? `${customer.name} (${customer.email})` : customer.email
|
||||
loading.classList.add('hidden')
|
||||
|
||||
if (!projects.length) {
|
||||
empty.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
list.classList.remove('hidden')
|
||||
list.innerHTML = ''
|
||||
for (const project of projects) {
|
||||
list.appendChild(renderProjectCard(project, features))
|
||||
}
|
||||
} catch (err) {
|
||||
loading.classList.add('hidden')
|
||||
if (err.message.includes('401') || err.message.includes('Nicht angemeldet')) {
|
||||
window.location.href = '/login.html'
|
||||
return
|
||||
}
|
||||
showError(loadError, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
window.initLoginPage = initLoginPage
|
||||
window.initDashboardPage = initDashboardPage
|
||||
28
public/dashboard.html
Normal file
28
public/dashboard.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Meine Projekte – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Meine Website-Projekte</h1>
|
||||
<div>
|
||||
<span class="meta" id="customer-meta"></span>
|
||||
<button type="button" class="link-btn" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<p id="load-error" class="error hidden"></p>
|
||||
<p id="loading" class="muted">Projekte werden geladen…</p>
|
||||
<ul id="projects" class="projects hidden"></ul>
|
||||
<p id="empty" class="card hidden">Noch keine Website-Projekte zugewiesen.</p>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
<script>
|
||||
initDashboardPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
public/login.html
Normal file
29
public/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – Webklar Kundenbereich</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Webklar Kundenbereich</h1></header>
|
||||
<main>
|
||||
<form id="login-form" class="card">
|
||||
<label>E-Mail
|
||||
<input type="email" name="email" id="email" required autocomplete="username">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input type="password" name="password" id="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<p id="login-error" class="error hidden"></p>
|
||||
<button type="submit" id="login-btn">Anmelden</button>
|
||||
</form>
|
||||
<p class="muted">Zugangsdaten erhältst du von Webklar. Passwortänderungen erfolgen über das Ticketsystem.</p>
|
||||
</main>
|
||||
<script src="/app.js"></script>
|
||||
<script>
|
||||
initLoginPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
public/style.css
Normal file
79
public/style.css
Normal file
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
--card: #1a2332;
|
||||
--text: #e7ecf3;
|
||||
--accent: #3b82f6;
|
||||
--muted: #94a3b8;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #2d3a4f;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 1.25rem; }
|
||||
header .meta { color: var(--muted); font-size: 0.9rem; }
|
||||
nav a, .link-btn {
|
||||
color: var(--accent);
|
||||
margin-left: 1rem;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
main { max-width: 900px; margin: 2rem auto; padding: 0 1.5rem; }
|
||||
.card, .project-card {
|
||||
background: var(--card);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
label { display: block; margin-bottom: 0.75rem; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: var(--text);
|
||||
}
|
||||
button, .btn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.error { color: #f87171; margin-top: 0.5rem; }
|
||||
.muted { color: var(--muted); font-size: 0.9rem; }
|
||||
.projects { list-style: none; padding: 0; }
|
||||
.project-card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
|
||||
.project-card dl { margin: 0; display: grid; grid-template-columns: 8rem 1fr; gap: 0.25rem 1rem; }
|
||||
.project-card dt { color: var(--muted); }
|
||||
.feature-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; }
|
||||
.feature-tag {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
Reference in New Issue
Block a user