feat: Postgres CRUD backend for card text field
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 5s

- schema.sql: card_text table (single-row, id=1) with content + updated_at
- server.js: Express + pg server with CRUD API:
    GET    /api/text  — read current text
    PUT    /api/text  — create / update text
    DELETE /api/text  — reset to default lorem ipsum
- package.json: express + pg dependencies (converts project to Node type)
- index.html: card now loads text from DB, inline Edit / Save / Cancel
  controls, Reset button with confirm dialog, last-updated timestamp

DB: testapp (appuser) on localhost:5432

Closes #8
This commit is contained in:
OpenClaw Agent 2026-03-13 15:12:51 +00:00
parent 818d09f7e2
commit 1ea5ac60cc
4 changed files with 261 additions and 6 deletions

View File

@ -13,6 +13,8 @@
--btn-bg: #111;
--btn-fg: #fff;
--btn-hover: #333;
--input-bg: #f9f9f9;
--meta: #888;
}
:root[data-theme="dark"] {
--bg: #0d0d0d;
@ -23,6 +25,8 @@
--btn-bg: #2a2a2a;
--btn-fg: #e0e0e0;
--btn-hover: #3a3a3a;
--input-bg: #0d0d0d;
--meta: #555;
}
*, *::before, *::after { box-sizing: border-box; }
body {
@ -57,13 +61,14 @@
transition: background .15s;
}
#theme-toggle:hover { background: var(--btn-hover); }
/* Card */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.4rem 1.8rem;
max-width: 520px;
text-align: center;
width: min(520px, 96vw);
line-height: 1.6;
box-shadow: 0 0 0 1px rgba(34, 197, 94, .15),
0 0 18px 4px rgba(34, 197, 94, .25),
@ -75,32 +80,165 @@
0 0 20px 5px rgba(34, 197, 94, .3),
0 0 50px 10px rgba(34, 197, 94, .12);
}
#card-text { margin: 0 0 .9rem; }
#card-meta { color: var(--meta); font-size: .72rem; margin-bottom: .7rem; min-height: 1rem; }
.card-actions { display: flex; gap: .5rem; justify-content: flex-end; }
.card-btn {
background: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid var(--border);
border-radius: 7px;
padding: .35rem .75rem;
font-size: .8rem;
cursor: pointer;
font-family: inherit;
transition: background .15s;
}
.card-btn:hover { background: var(--btn-hover); }
.card-btn:disabled { opacity: .4; cursor: default; }
.card-btn-danger { color: #f57c7c; border-color: rgba(245,124,124,.3); }
.card-btn-danger:hover { background: rgba(245,124,124,.1); }
#edit-form { display: none; flex-direction: column; gap: .6rem; margin-top: .5rem; }
#edit-form.open { display: flex; }
#edit-input {
width: 100%; min-height: 90px; background: var(--input-bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 8px; padding: .55rem .7rem;
font-family: inherit; font-size: .9rem; resize: vertical; outline: none;
transition: border-color .15s;
}
#edit-input:focus { border-color: var(--accent); }
#card-status { font-size: .75rem; color: var(--meta); min-height: 1rem; text-align: right; }
</style>
</head>
<body>
<button id="theme-toggle">🌙 Dark mode</button>
<h1>🚀 Deploy verified ✅</h1>
<div class="card">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</div>
<div class="card" id="text-card">
<p id="card-text">Loading…</p>
<div id="card-meta"></div>
<div class="card-actions" id="card-actions">
<button class="card-btn" id="edit-btn">✏️ Edit</button>
<button class="card-btn card-btn-danger" id="reset-btn">↺ Reset</button>
</div>
<div id="edit-form">
<textarea id="edit-input" placeholder="Enter text…"></textarea>
<div style="display:flex; gap:.5rem; justify-content:flex-end; align-items:center;">
<span id="card-status"></span>
<button class="card-btn" id="cancel-btn">✕ Cancel</button>
<button class="card-btn" id="save-btn">💾 Save</button>
</div>
</div>
</div>
<p>Committed by OpenClaw at <code>2026-03-13 10:11:00 UTC</code></p>
<script>
// ── Theme ──────────────────────────────────────────────────────────────
(function() {
var root = document.documentElement;
var btn = document.getElementById('theme-toggle');
// Load saved preference; default to light
var saved = localStorage.getItem('theme') || 'light';
setTheme(saved);
btn.addEventListener('click', function() {
setTheme(root.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
});
function setTheme(t) {
root.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
btn.textContent = t === 'light' ? '🌙 Dark mode' : '☀️ Light mode';
}
})();
// ── CRUD card ──────────────────────────────────────────────────────────
(function() {
var BASE = '/test';
var textEl = document.getElementById('card-text');
var metaEl = document.getElementById('card-meta');
var editForm = document.getElementById('edit-form');
var editInput = document.getElementById('edit-input');
var statusEl = document.getElementById('card-status');
var editBtn = document.getElementById('edit-btn');
var saveBtn = document.getElementById('save-btn');
var cancelBtn = document.getElementById('cancel-btn');
var resetBtn = document.getElementById('reset-btn');
function setMeta(updated_at) {
metaEl.textContent = updated_at
? 'Last updated: ' + new Date(updated_at).toLocaleString()
: '';
}
function setStatus(msg, color) {
statusEl.textContent = msg;
statusEl.style.color = color || '';
if (msg) setTimeout(function() { statusEl.textContent = ''; }, 2500);
}
// READ
function load() {
fetch(BASE + '/api/text')
.then(function(r) { return r.json(); })
.then(function(d) {
textEl.textContent = d.content || '';
setMeta(d.updated_at);
})
.catch(function() { textEl.textContent = '(failed to load)'; });
}
load();
// Edit mode toggle
editBtn.addEventListener('click', function() {
editInput.value = textEl.textContent;
editForm.classList.add('open');
editBtn.style.display = 'none';
resetBtn.style.display = 'none';
editInput.focus();
});
cancelBtn.addEventListener('click', function() {
editForm.classList.remove('open');
editBtn.style.display = '';
resetBtn.style.display = '';
});
// UPDATE
saveBtn.addEventListener('click', function() {
var content = editInput.value.trim();
if (!content) { setStatus('Cannot be empty.', '#f57c7c'); return; }
saveBtn.disabled = true;
fetch(BASE + '/api/text', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.error) { setStatus('Error: ' + d.error, '#f57c7c'); saveBtn.disabled = false; return; }
textEl.textContent = d.content;
setMeta(d.updated_at);
editForm.classList.remove('open');
editBtn.style.display = '';
resetBtn.style.display = '';
setStatus('Saved!', '#6fcf6f');
saveBtn.disabled = false;
}).catch(function() { setStatus('Network error.', '#f57c7c'); saveBtn.disabled = false; });
});
// DELETE (reset)
resetBtn.addEventListener('click', function() {
if (!confirm('Reset to default text?')) return;
resetBtn.disabled = true;
fetch(BASE + '/api/text', { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(d) {
textEl.textContent = d.content;
setMeta(d.updated_at);
setStatus('Reset!', '#6fcf6f');
resetBtn.disabled = false;
}).catch(function() { setStatus('Network error.', '#f57c7c'); resetBtn.disabled = false; });
});
})();
</script>
<script src="https://dev.bl.pixeldev.eu/feedback-tool/widget.js" data-repo="pixeldev/test"></script>
</body>
</html>

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "test",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.2.1",
"pg": "^8.13.3"
}
}

13
schema.sql Normal file
View File

@ -0,0 +1,13 @@
-- schema.sql — test project
-- Run against the "testapp" Postgres database
CREATE TABLE IF NOT EXISTS card_text (
id INT PRIMARY KEY DEFAULT 1,
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Seed default row (idempotent)
INSERT INTO card_text (id, content)
VALUES (1, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.')
ON CONFLICT (id) DO NOTHING;

92
server.js Normal file
View File

@ -0,0 +1,92 @@
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const fs = require('fs');
const app = express();
app.use(express.json());
const PORT = parseInt(process.env.PORT || '3070');
const BASE = (process.env.BASE_PATH || '/test').replace(/\/$/, '');
const DEFAULT_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
// ── Postgres ──────────────────────────────────────────────────────────────
const pgPass = process.env.PG_PASS ||
(fs.existsSync('/root/pg-appuser.env')
? fs.readFileSync('/root/pg-appuser.env', 'utf8').match(/APP_PASS=(.*)/)?.[1]?.trim()
: null);
const pool = new Pool({
host: '127.0.0.1',
port: 5432,
database: process.env.PG_DB || 'testapp',
user: process.env.PG_USER || 'appuser',
password: pgPass,
});
async function initDb() {
await pool.query(`
CREATE TABLE IF NOT EXISTS card_text (
id INT PRIMARY KEY DEFAULT 1,
content TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO card_text (id, content) VALUES (1, $1)
ON CONFLICT (id) DO NOTHING;
`, [DEFAULT_TEXT]);
}
// ── CORS ──────────────────────────────────────────────────────────────────
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.header('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
// ── GET /api/text ─────────────────────────────────────────────────────────
app.get(['/api/text', BASE + '/api/text'], async (req, res) => {
try {
const { rows } = await pool.query('SELECT content, updated_at FROM card_text WHERE id = 1');
res.json(rows[0] || { content: DEFAULT_TEXT, updated_at: null });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── PUT /api/text — create / update ──────────────────────────────────────
app.put(['/api/text', BASE + '/api/text'], async (req, res) => {
const { content } = req.body;
if (!content?.trim()) return res.status(400).json({ error: 'content required' });
try {
const { rows } = await pool.query(
`INSERT INTO card_text (id, content, updated_at) VALUES (1, $1, NOW())
ON CONFLICT (id) DO UPDATE SET content = $1, updated_at = NOW()
RETURNING content, updated_at`,
[content.trim()]
);
res.json(rows[0]);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── DELETE /api/text — reset to default ──────────────────────────────────
app.delete(['/api/text', BASE + '/api/text'], async (req, res) => {
try {
const { rows } = await pool.query(
`UPDATE card_text SET content = $1, updated_at = NOW() WHERE id = 1
RETURNING content, updated_at`,
[DEFAULT_TEXT]
);
res.json(rows[0] || { content: DEFAULT_TEXT });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Serve index.html ──────────────────────────────────────────────────────
app.get([BASE, BASE + '/', BASE + '/index.html'], (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// ── Boot ──────────────────────────────────────────────────────────────────
initDb()
.then(() => app.listen(PORT, '127.0.0.1', () => console.log(`test on :${PORT} at ${BASE}`)))
.catch(err => { console.error('DB init failed:', err); process.exit(1); });