feat: Postgres CRUD backend for card text field
All checks were successful
Deploy to dev.bl.pixeldev.eu / deploy (push) Successful in 5s
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:
parent
818d09f7e2
commit
1ea5ac60cc
150
index.html
150
index.html
@ -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
12
package.json
Normal 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
13
schema.sql
Normal 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
92
server.js
Normal 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); });
|
||||
Loading…
Reference in New Issue
Block a user