473 lines
11 KiB
HTML
473 lines
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Skuska Guestbook</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
--bg: #eef3ee;
|
||
--panel: #fffdf8;
|
||
--text: #17202a;
|
||
--muted: #5c6a72;
|
||
--accent: #2f7d66;
|
||
--accent-dark: #205846;
|
||
--danger: #b24a3b;
|
||
--border: #d8dfd6;
|
||
--soft: #edf5f0;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
font-family: "Segoe UI", Arial, sans-serif;
|
||
color: var(--text);
|
||
background:
|
||
linear-gradient(135deg, rgba(47, 125, 102, 0.14), transparent 34%),
|
||
linear-gradient(180deg, #f8faf7 0%, var(--bg) 100%);
|
||
padding: 28px;
|
||
}
|
||
|
||
main {
|
||
width: min(920px, 100%);
|
||
margin: 0 auto;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
box-shadow: 0 18px 48px rgba(34, 56, 48, 0.12);
|
||
overflow: hidden;
|
||
}
|
||
|
||
header {
|
||
padding: 28px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 18px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
h1 {
|
||
margin: 0 0 8px;
|
||
font-size: clamp(2rem, 6vw, 3rem);
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.badges {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.badge {
|
||
border: 1px solid var(--border);
|
||
background: var(--soft);
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
color: var(--accent-dark);
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.badge.offline {
|
||
background: #fff0ed;
|
||
color: var(--danger);
|
||
}
|
||
|
||
.content {
|
||
padding: 28px;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
|
||
gap: 24px;
|
||
}
|
||
|
||
form,
|
||
.entries-panel {
|
||
min-width: 0;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: var(--muted);
|
||
font-weight: 700;
|
||
font-size: 0.92rem;
|
||
}
|
||
|
||
input,
|
||
textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 13px 14px;
|
||
font: inherit;
|
||
color: var(--text);
|
||
background: #fff;
|
||
}
|
||
|
||
textarea {
|
||
min-height: 132px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.field + .field {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
margin-top: 18px;
|
||
}
|
||
|
||
button {
|
||
border: 0;
|
||
border-radius: 10px;
|
||
padding: 12px 15px;
|
||
background: var(--accent);
|
||
color: white;
|
||
font: inherit;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, background 0.15s ease;
|
||
}
|
||
|
||
button:hover {
|
||
background: var(--accent-dark);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
button.secondary {
|
||
background: #dde9e3;
|
||
color: var(--accent-dark);
|
||
}
|
||
|
||
button.danger {
|
||
background: #f3d8d3;
|
||
color: var(--danger);
|
||
}
|
||
|
||
button.icon {
|
||
width: 38px;
|
||
height: 38px;
|
||
padding: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
.panel-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.counter {
|
||
color: var(--accent-dark);
|
||
font-weight: 800;
|
||
}
|
||
|
||
.status {
|
||
min-height: 24px;
|
||
margin-top: 16px;
|
||
color: var(--accent-dark);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.entries {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.entry {
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 14px;
|
||
background: #fff;
|
||
}
|
||
|
||
.entry-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.entry-name {
|
||
font-weight: 800;
|
||
}
|
||
|
||
.entry-time {
|
||
color: var(--muted);
|
||
font-size: 0.86rem;
|
||
}
|
||
|
||
.entry-message {
|
||
color: #2e3a42;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.empty {
|
||
border: 1px dashed var(--border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
color: var(--muted);
|
||
background: rgba(255, 255, 255, 0.55);
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
body {
|
||
padding: 14px;
|
||
}
|
||
|
||
header,
|
||
.content {
|
||
padding: 20px;
|
||
}
|
||
|
||
.content {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.badges {
|
||
justify-content: flex-start;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<header>
|
||
<div>
|
||
<h1>Skuska Guestbook</h1>
|
||
<p>Write a short entry and store it through the backend API in PostgreSQL.</p>
|
||
</div>
|
||
<div class="badges">
|
||
<span id="backendBadge" class="badge offline">Backend: checking</span>
|
||
<span id="databaseBadge" class="badge offline">Database: checking</span>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="content">
|
||
<form onsubmit="event.preventDefault(); saveEntry();">
|
||
<div class="field">
|
||
<label for="name">Name</label>
|
||
<input id="name" maxlength="80" placeholder="Enter your name" autocomplete="name">
|
||
</div>
|
||
<div class="field">
|
||
<label for="message">Message</label>
|
||
<textarea id="message" maxlength="500" placeholder="Leave a short message"></textarea>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="submit">Save entry</button>
|
||
<button class="secondary" type="button" onclick="loadEntries()">Refresh</button>
|
||
<button class="danger" type="button" onclick="clearEntries()">Clear all</button>
|
||
</div>
|
||
<div id="status" class="status"></div>
|
||
</form>
|
||
|
||
<section class="entries-panel">
|
||
<div class="panel-head">
|
||
<div class="counter" id="counter">Saved entries: 0</div>
|
||
<button class="secondary icon" type="button" onclick="loadEntries()" title="Refresh entries">↻</button>
|
||
</div>
|
||
<div id="entries" class="entries"></div>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
const configuredApiBaseUrl = "__API_BASE_URL__";
|
||
const apiBaseUrl = configuredApiBaseUrl.startsWith("__") ? "/api" : configuredApiBaseUrl;
|
||
|
||
function apiUrl(path) {
|
||
return `${apiBaseUrl}${path}`;
|
||
}
|
||
|
||
function setStatus(message) {
|
||
document.getElementById("status").textContent = message;
|
||
}
|
||
|
||
function setBadge(id, label, ok) {
|
||
const badge = document.getElementById(id);
|
||
badge.textContent = label;
|
||
badge.classList.toggle("offline", !ok);
|
||
}
|
||
|
||
function formatTime(value) {
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short"
|
||
}).format(new Date(value));
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const res = await fetch(apiUrl("/status"));
|
||
const data = await res.json();
|
||
setBadge("backendBadge", "Backend: online", data.backend === "online");
|
||
setBadge("databaseBadge", `Database: ${data.database}`, data.database === "ready");
|
||
} catch (error) {
|
||
console.error(error);
|
||
setBadge("backendBadge", "Backend: offline", false);
|
||
setBadge("databaseBadge", "Database: unknown", false);
|
||
}
|
||
}
|
||
|
||
async function loadEntries() {
|
||
const entriesEl = document.getElementById("entries");
|
||
const counter = document.getElementById("counter");
|
||
|
||
try {
|
||
setStatus("Loading entries...");
|
||
await loadStatus();
|
||
const res = await fetch(apiUrl("/entries"));
|
||
|
||
if (res.status === 503) {
|
||
entriesEl.innerHTML = '<div class="empty">Backend is starting. Please try again shortly.</div>';
|
||
setStatus("Backend is starting. Please try again shortly.");
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
counter.textContent = `Saved entries: ${data.length}`;
|
||
|
||
if (data.length === 0) {
|
||
entriesEl.innerHTML = '<div class="empty">No guestbook entries yet.</div>';
|
||
setStatus("Entries loaded successfully.");
|
||
return;
|
||
}
|
||
|
||
entriesEl.innerHTML = data.map((entry) => `
|
||
<article class="entry">
|
||
<div class="entry-head">
|
||
<div>
|
||
<div class="entry-name">${escapeHtml(entry.name)}</div>
|
||
<div class="entry-time">${escapeHtml(formatTime(entry.created_at))}</div>
|
||
</div>
|
||
<button class="danger icon" type="button" onclick="deleteEntry(${entry.id})" title="Delete entry">×</button>
|
||
</div>
|
||
<div class="entry-message">${escapeHtml(entry.message)}</div>
|
||
</article>
|
||
`).join("");
|
||
|
||
setStatus("Entries loaded successfully.");
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatus("Cannot connect to backend.");
|
||
}
|
||
}
|
||
|
||
async function saveEntry() {
|
||
const nameInput = document.getElementById("name");
|
||
const messageInput = document.getElementById("message");
|
||
const name = nameInput.value.trim();
|
||
const message = messageInput.value.trim();
|
||
|
||
if (!name || !message) {
|
||
setStatus("Please enter name and message.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setStatus("Saving entry...");
|
||
const res = await fetch(apiUrl("/save"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({ name, message })
|
||
});
|
||
|
||
if (res.status === 503) {
|
||
setStatus("Backend is starting. Please try again shortly.");
|
||
return;
|
||
}
|
||
|
||
if (!res.ok) {
|
||
setStatus("Saving failed.");
|
||
return;
|
||
}
|
||
|
||
nameInput.value = "";
|
||
messageInput.value = "";
|
||
await loadEntries();
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatus("Saving failed.");
|
||
}
|
||
}
|
||
|
||
async function deleteEntry(id) {
|
||
try {
|
||
setStatus("Deleting entry...");
|
||
const res = await fetch(apiUrl(`/entries/${id}`), { method: "DELETE" });
|
||
|
||
if (!res.ok) {
|
||
setStatus("Deleting failed.");
|
||
return;
|
||
}
|
||
|
||
await loadEntries();
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatus("Deleting failed.");
|
||
}
|
||
}
|
||
|
||
async function clearEntries() {
|
||
try {
|
||
setStatus("Clearing entries...");
|
||
const res = await fetch(apiUrl("/entries"), { method: "DELETE" });
|
||
|
||
if (!res.ok) {
|
||
setStatus("Clearing failed.");
|
||
return;
|
||
}
|
||
|
||
await loadEntries();
|
||
} catch (error) {
|
||
console.error(error);
|
||
setStatus("Clearing failed.");
|
||
}
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
window.onload = () => {
|
||
loadEntries();
|
||
setInterval(loadStatus, 15000);
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|