zkt26/skuska/frontend/index.html
Bohdan Kapliuk 15f4373858 skuska
2026-05-13 19:50:55 +03:00

473 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
window.onload = () => {
loadEntries();
setInterval(loadStatus, 15000);
};
</script>
</body>
</html>