feat: Add image sources management and update server middleware
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled

This commit is contained in:
2026-02-17 20:44:53 -05:00
parent 854fd199bf
commit 706d48f549
5 changed files with 789 additions and 21 deletions

465
public/sources.html Normal file
View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeBase Sources</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
color-scheme: dark;
--bg: #0f131a;
--bg-accent: #161b24;
--panel: #1b2230;
--panel-strong: #222b3b;
--text: #e6e9ef;
--muted: #aab3c2;
--accent: #7aa2f7;
--accent-strong: #5d87f0;
--danger: #e05b5b;
--danger-strong: #c94b4b;
--border: #2b3446;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: radial-gradient(circle at top left, #1c2433, var(--bg));
min-height: 100vh;
padding: 10px;
color: var(--text);
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.page-title {
font-size: 1.8em;
font-weight: 600;
}
.nav-links {
display: flex;
gap: 10px;
}
.nav-links a {
color: var(--muted);
text-decoration: none;
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 3px;
}
.nav-links a:hover {
color: var(--text);
border-color: var(--accent);
}
.sources-panel {
background: var(--panel);
border-radius: 4px;
padding: 12px 16px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.sources-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
gap: 12px;
}
.sources-title {
font-weight: 600;
color: var(--text);
}
.sources-interval {
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9em;
}
.sources-interval input {
width: 110px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
color: var(--text);
}
.sources-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.sources-form input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
color: var(--text);
font-size: 0.9em;
}
.sources-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sources-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.source-item {
display: grid;
grid-template-columns: 1.2fr 1.4fr 0.8fr 0.6fr auto auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--panel-strong);
}
.source-edit-input {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 3px;
background: #1d2636;
color: var(--text);
font-size: 0.85em;
}
.source-toggle {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-size: 0.85em;
}
.tag-add-btn {
padding: 6px 12px;
background: var(--accent);
color: #0f131a;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: 600;
font-size: 0.85em;
}
.tag-add-btn:hover {
background: var(--accent-strong);
}
.source-save {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 0.8em;
}
.source-save:hover {
background: var(--accent);
color: #0f131a;
}
.source-delete {
background: transparent;
border: 1px solid var(--danger);
color: var(--danger);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 0.8em;
}
.source-delete:hover {
background: var(--danger);
color: white;
}
.sources-status {
margin-top: 10px;
font-size: 0.85em;
color: var(--muted);
}
@media (max-width: 900px) {
.source-item {
grid-template-columns: 1fr;
}
.sources-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<div class="page-title">Image Sources</div>
<div class="nav-links">
<a href="/index.html">Home</a>
<a href="/gallery.html">Gallery</a>
</div>
</div>
<div class="sources-panel">
<div class="sources-header">
<div class="sources-title">Manage Sources</div>
<div class="sources-interval">
Fetch interval (minutes)
<input type="number" id="fetchIntervalInput" min="0.01" step="0.01">
<button class="tag-add-btn" id="saveIntervalBtn">Save</button>
</div>
</div>
<div class="sources-form">
<input type="text" id="sourceNameInput" placeholder="Name">
<input type="url" id="sourceUrlInput" placeholder="Image URL">
<input type="text" id="sourceTagsInput" placeholder="Tags (comma separated)">
<div class="sources-actions">
<label class="source-toggle">
<input type="checkbox" id="sourceEnabledInput" checked>
Enabled
</label>
<button class="tag-add-btn" id="addSourceBtn">Add Source</button>
</div>
</div>
<div class="sources-list" id="sourcesList"></div>
<div class="sources-status" id="sourcesStatus"></div>
</div>
</div>
<script>
const API_BASE = '';
const fetchIntervalInput = document.getElementById('fetchIntervalInput');
const saveIntervalBtn = document.getElementById('saveIntervalBtn');
const sourceNameInput = document.getElementById('sourceNameInput');
const sourceUrlInput = document.getElementById('sourceUrlInput');
const sourceTagsInput = document.getElementById('sourceTagsInput');
const sourceEnabledInput = document.getElementById('sourceEnabledInput');
const addSourceBtn = document.getElementById('addSourceBtn');
const sourcesList = document.getElementById('sourcesList');
const sourcesStatus = document.getElementById('sourcesStatus');
async function loadSources() {
try {
const response = await fetch(`${API_BASE}/api/sources`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const config = data.config || {};
fetchIntervalInput.value = config.fetchInterval ?? '';
renderSources(config.sources || []);
sourcesStatus.textContent = '';
} catch (err) {
console.error('Error loading sources:', err);
sourcesStatus.textContent = 'Failed to load sources.';
}
}
function renderSources(sources) {
sourcesList.innerHTML = '';
if (!sources.length) {
sourcesList.innerHTML = '<div class="sources-status">No sources configured yet.</div>';
return;
}
sources.forEach((source, index) => {
const item = document.createElement('div');
item.className = 'source-item';
const nameInput = document.createElement('input');
nameInput.className = 'source-edit-input';
nameInput.type = 'text';
nameInput.value = source.name || '';
const urlInput = document.createElement('input');
urlInput.className = 'source-edit-input';
urlInput.type = 'url';
urlInput.value = source.url || '';
const tagsInput = document.createElement('input');
tagsInput.className = 'source-edit-input';
tagsInput.type = 'text';
tagsInput.value = (source.tags || []).join(', ');
const toggleWrap = document.createElement('label');
toggleWrap.className = 'source-toggle';
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = Boolean(source.enabled);
toggle.addEventListener('change', () => updateSource(index, { enabled: toggle.checked }));
toggleWrap.appendChild(toggle);
toggleWrap.appendChild(document.createTextNode('Enabled'));
const saveBtn = document.createElement('button');
saveBtn.className = 'source-save';
saveBtn.textContent = 'Save';
saveBtn.addEventListener('click', () => {
updateSource(index, {
name: nameInput.value.trim(),
url: urlInput.value.trim(),
tags: parseTagsInput(tagsInput.value)
});
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'source-delete';
deleteBtn.textContent = 'Remove';
deleteBtn.addEventListener('click', () => removeSource(index));
item.appendChild(nameInput);
item.appendChild(urlInput);
item.appendChild(tagsInput);
item.appendChild(toggleWrap);
item.appendChild(saveBtn);
item.appendChild(deleteBtn);
sourcesList.appendChild(item);
});
}
function parseTagsInput(value) {
return value
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
}
async function addSource() {
const name = sourceNameInput.value.trim();
const url = sourceUrlInput.value.trim();
const tags = parseTagsInput(sourceTagsInput.value);
const enabled = sourceEnabledInput.checked;
if (!name || !url) {
sourcesStatus.textContent = 'Name and URL are required.';
return;
}
sourcesStatus.textContent = 'Saving source...';
try {
const response = await fetch(`${API_BASE}/api/sources`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, url, tags, enabled })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourceNameInput.value = '';
sourceUrlInput.value = '';
sourceTagsInput.value = '';
sourceEnabledInput.checked = true;
sourcesStatus.textContent = 'Source added.';
await loadSources();
} catch (err) {
console.error('Error adding source:', err);
sourcesStatus.textContent = 'Failed to add source.';
}
}
async function updateSource(index, updates) {
try {
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Sources updated.';
await loadSources();
} catch (err) {
console.error('Error updating source:', err);
sourcesStatus.textContent = 'Failed to update source.';
}
}
async function removeSource(index) {
const confirmed = window.confirm('Remove this source?');
if (!confirmed) return;
try {
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Source removed.';
await loadSources();
} catch (err) {
console.error('Error removing source:', err);
sourcesStatus.textContent = 'Failed to remove source.';
}
}
async function saveFetchInterval() {
const intervalValue = Number(fetchIntervalInput.value);
if (!Number.isFinite(intervalValue) || intervalValue <= 0) {
sourcesStatus.textContent = 'Fetch interval must be a positive number.';
return;
}
sourcesStatus.textContent = 'Saving interval...';
try {
const response = await fetch(`${API_BASE}/api/sources/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ fetchInterval: intervalValue.toString() })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
sourcesStatus.textContent = 'Interval updated.';
await loadSources();
} catch (err) {
console.error('Error saving interval:', err);
sourcesStatus.textContent = 'Failed to update interval.';
}
}
addSourceBtn.addEventListener('click', addSource);
saveIntervalBtn.addEventListener('click', saveFetchInterval);
document.addEventListener('DOMContentLoaded', loadSources);
</script>
</body>
</html>