Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d7408e14 |
@@ -7,6 +7,32 @@ const fs = require('fs');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// lightweight level logger
|
||||||
|
const LOG_LEVEL = process.env.LOG_LEVEL || 'info';
|
||||||
|
const LOG_ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
||||||
|
function log(level, ...args) {
|
||||||
|
if (LOG_ORDER[level] <= LOG_ORDER[LOG_LEVEL]) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const out = `[${ts}] [${level.toUpperCase()}]`;
|
||||||
|
if (level === 'error') console.error(out, ...args);
|
||||||
|
else if (level === 'warn') console.warn(out, ...args);
|
||||||
|
else if (level === 'debug' && console.debug) console.debug(out, ...args);
|
||||||
|
else console.log(out, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional request logger (only when debug)
|
||||||
|
if (LOG_LEVEL === 'debug') {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
res.once('finish', () => {
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
log('debug', `${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Use DATA_DIR env var if set, otherwise use local "data" folder
|
// Use DATA_DIR env var if set, otherwise use local "data" folder
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
|
|
||||||
@@ -15,7 +41,7 @@ const db = new sqlite3.Database(dbPath);
|
|||||||
const upload = multer({ dest: path.join(DATA_DIR, 'uploads/') });
|
const upload = multer({ dest: path.join(DATA_DIR, 'uploads/') });
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/uploads', express.static(path.join(DATA_DIR, 'uploads')));
|
app.use('/uploads', express.static(path.join(DATA_DIR, 'uploads'), { maxAge: '1d' }));
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
// Create uploads directory if it doesn't exist
|
||||||
fs.mkdirSync(path.join(DATA_DIR, 'uploads'), { recursive: true });
|
fs.mkdirSync(path.join(DATA_DIR, 'uploads'), { recursive: true });
|
||||||
@@ -40,6 +66,8 @@ app.get('/api/counters', (req, res) => {
|
|||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
value: Number(row.value)
|
value: Number(row.value)
|
||||||
}));
|
}));
|
||||||
|
// log names at debug level (won't clutter info logs)
|
||||||
|
log('debug', `Fetched ${counters.length} counters:`, counters.map(c => c.name));
|
||||||
res.json(counters);
|
res.json(counters);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +77,11 @@ app.post('/api/counters', upload.single('image'), (req, res) => {
|
|||||||
const { name = 'Counter', value = 0 } = req.body;
|
const { name = 'Counter', value = 0 } = req.body;
|
||||||
const image = req.file ? `/uploads/${req.file.filename}` : null;
|
const image = req.file ? `/uploads/${req.file.filename}` : null;
|
||||||
db.run('INSERT INTO counters (name, value, image) VALUES (?, ?, ?)', [name, value, image], function (err) {
|
db.run('INSERT INTO counters (name, value, image) VALUES (?, ?, ?)', [name, value, image], function (err) {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
if (err) {
|
||||||
|
log('error', 'Create counter failed:', err.message);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
log('info', `Created counter "${name}" (id: ${this.lastID})`);
|
||||||
res.json({ id: Number(this.lastID), name, value: Number(value), image });
|
res.json({ id: Number(this.lastID), name, value: Number(value), image });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -60,6 +92,9 @@ app.put('/api/counters/:id', upload.single('image'), (req, res) => {
|
|||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
let image = null;
|
let image = null;
|
||||||
|
|
||||||
|
// Log incoming request at debug level only
|
||||||
|
log('debug', `PUT /api/counters/${id} body:`, req.body, 'file:', !!req.file);
|
||||||
|
|
||||||
// If a new image is uploaded, get its path
|
// If a new image is uploaded, get its path
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
image = `/uploads/${req.file.filename}`;
|
image = `/uploads/${req.file.filename}`;
|
||||||
@@ -83,17 +118,46 @@ app.put('/api/counters/:id', upload.single('image'), (req, res) => {
|
|||||||
params.push(id);
|
params.push(id);
|
||||||
const sql = `UPDATE counters SET ${fields.join(', ')} WHERE id = ?`;
|
const sql = `UPDATE counters SET ${fields.join(', ')} WHERE id = ?`;
|
||||||
|
|
||||||
|
// Log SQL and params at debug level
|
||||||
|
log('debug', `Updating counter ${id}`, sql, params);
|
||||||
|
|
||||||
db.run(sql, params, function (err) {
|
db.run(sql, params, function (err) {
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
if (err) {
|
||||||
res.json({ updated: this.changes });
|
log('error', `DB error updating counter ${id}:`, err.message);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
// Fetch the updated row to log the current name and value
|
||||||
|
db.get('SELECT name, value FROM counters WHERE id = ?', [id], (err2, row2) => {
|
||||||
|
if (err2) {
|
||||||
|
log('error', `Failed to fetch updated counter ${id}:`, err2.message);
|
||||||
|
} else if (row2) {
|
||||||
|
log('info', `Counter "${row2.name}" (id: ${id}) updated to value ${Number(row2.value)}`);
|
||||||
|
} else {
|
||||||
|
log('info', `Counter ${id} updated, changes: ${this.changes}`);
|
||||||
|
}
|
||||||
|
res.json({ updated: this.changes });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a counter
|
// Delete a counter (fetch name first so we can log it)
|
||||||
app.delete('/api/counters/:id', (req, res) => {
|
app.delete('/api/counters/:id', (req, res) => {
|
||||||
db.run('DELETE FROM counters WHERE id = ?', [req.params.id], function (err) {
|
const id = req.params.id;
|
||||||
if (err) return res.status(500).json({ error: err.message });
|
db.get('SELECT name FROM counters WHERE id = ?', [id], (err, row) => {
|
||||||
res.json({ deleted: this.changes });
|
if (err) {
|
||||||
|
log('error', `Lookup before delete failed for id ${id}:`, err.message);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
const name = row && row.name ? row.name : null;
|
||||||
|
|
||||||
|
db.run('DELETE FROM counters WHERE id = ?', [id], function (err) {
|
||||||
|
if (err) {
|
||||||
|
log('error', `Delete counter ${name} with ${id} failed:`, err.message);
|
||||||
|
return res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
log('info', `Deleted counter${name ? ` "${name}"` : ''} (id: ${id})`);
|
||||||
|
res.json({ deleted: this.changes });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,7 +173,7 @@ app.get('/*path', (req, res) => {
|
|||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`API server running on http://localhost:${PORT}`);
|
console.log(`API server listening on port: ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import CounterCard from "./CounterCard.svelte";
|
import CounterCard from "./CounterCard.svelte";
|
||||||
import AddCounterCard from "./AddCounterCard.svelte";
|
import AddCounterCard from "./AddCounterCard.svelte";
|
||||||
|
import { API_COUNTERS } from "./config";
|
||||||
|
|
||||||
type Counter = {
|
type Counter = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,19 +14,17 @@
|
|||||||
let counters: Counter[] = [];
|
let counters: Counter[] = [];
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
// API endpoint
|
// Use a relative API base so it works no matter the host/origin
|
||||||
const API = "http://localhost:3000/api/counters";
|
const API = API_COUNTERS;
|
||||||
const CACHE_KEY = "tally-counters-cache";
|
const CACHE_KEY = "tally-counters-cache";
|
||||||
|
|
||||||
async function fetchCounters() {
|
async function fetchCounters() {
|
||||||
loading = true;
|
loading = true;
|
||||||
// Try to load from cache first
|
|
||||||
const cached = localStorage.getItem(CACHE_KEY);
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
counters = JSON.parse(cached);
|
counters = JSON.parse(cached);
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
// Always fetch fresh data in the background
|
|
||||||
const res = await fetch(API);
|
const res = await fetch(API);
|
||||||
const fresh = await res.json();
|
const fresh = await res.json();
|
||||||
counters = fresh;
|
counters = fresh;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { clickOutside } from "./clickOutside";
|
import { clickOutside } from "./clickOutside";
|
||||||
|
import { API_BASE } from "./config";
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
export let counter: {
|
export let counter: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -21,16 +23,38 @@
|
|||||||
export let onSetEditImage: (file: File | null) => void;
|
export let onSetEditImage: (file: File | null) => void;
|
||||||
export let onCancelEdit: (id: number) => void;
|
export let onCancelEdit: (id: number) => void;
|
||||||
|
|
||||||
// For previewing the new image
|
// Build a safe image URL:
|
||||||
let previewUrl: string | null = null;
|
function imageUrl(img?: string | null) {
|
||||||
|
if (!img) return null;
|
||||||
$: if (editImage) {
|
// already absolute
|
||||||
previewUrl = URL.createObjectURL(editImage);
|
if (img.startsWith("http") || img.startsWith("//")) return img;
|
||||||
} else {
|
// ensure no double slashes when API_BASE is set
|
||||||
previewUrl = null;
|
const base = (API_BASE || "").replace(/\/$/, "");
|
||||||
|
if (base) return `${base}${img}`;
|
||||||
|
return img; // relative path like "/uploads/..."
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKEND_URL = "http://localhost:3000";
|
// preview management (revoke previous object URL)
|
||||||
|
let previewUrl: string | null = null;
|
||||||
|
let _prevObjectUrl: string | null = null;
|
||||||
|
$: {
|
||||||
|
if (editImage) {
|
||||||
|
const u = URL.createObjectURL(editImage);
|
||||||
|
if (_prevObjectUrl) URL.revokeObjectURL(_prevObjectUrl);
|
||||||
|
previewUrl = u;
|
||||||
|
_prevObjectUrl = u;
|
||||||
|
} else {
|
||||||
|
if (_prevObjectUrl) {
|
||||||
|
URL.revokeObjectURL(_prevObjectUrl);
|
||||||
|
_prevObjectUrl = null;
|
||||||
|
}
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (_prevObjectUrl) URL.revokeObjectURL(_prevObjectUrl);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -56,11 +80,7 @@
|
|||||||
{#if previewUrl}
|
{#if previewUrl}
|
||||||
<img src={previewUrl} alt="Preview" class="card-img" />
|
<img src={previewUrl} alt="Preview" class="card-img" />
|
||||||
{:else if counter.image}
|
{:else if counter.image}
|
||||||
<img
|
<img src={imageUrl(counter.image)} alt={counter.name} class="card-img" />
|
||||||
src={`${BACKEND_URL}${counter.image}`}
|
|
||||||
alt={counter.name}
|
|
||||||
class="card-img"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-img placeholder"></div>
|
<div class="card-img placeholder"></div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -89,10 +109,10 @@
|
|||||||
<div class="card-img-container">
|
<div class="card-img-container">
|
||||||
{#if counter.image}
|
{#if counter.image}
|
||||||
<img
|
<img
|
||||||
src={`${BACKEND_URL}${counter.image}`}
|
src={imageUrl(counter.image)}
|
||||||
alt={counter.name}
|
alt={counter.name}
|
||||||
class="card-img"
|
class="card-img"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card-img placeholder">No image</div>
|
<div class="card-img placeholder">No image</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
3
frontend/src/config.ts
Normal file
3
frontend/src/config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const API_BASE = import.meta.env.VITE_API_URL ?? ''; // empty = same origin
|
||||||
|
export const API_COUNTERS = `${API_BASE}/api/counters`;
|
||||||
|
export const UPLOADS_BASE = `${API_BASE}/uploads`;
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
})
|
server: {
|
||||||
|
port: Number(process.env.VITE_PORT) || 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user