feat: add pins history table and database management for pinned content

- Updated package.json to include better-sqlite3 and dotenv dependencies.
- Added a new admin interface for displaying pinned content history with filtering and pagination.
- Implemented database module for managing pinned content, including CRUD operations and statistics.
- Enhanced frontend functionality to load and display pins with real-time updates and error handling.
This commit is contained in:
besoeasy
2025-12-31 07:57:28 +05:30
parent 5e6ec5adf1
commit cd96e3d6ee
11 changed files with 1001 additions and 13 deletions
+2
View File
@@ -1,2 +1,4 @@
node_modules
temp_uploads/
db/
.env
+5 -1
View File
@@ -1,3 +1,5 @@
require("dotenv").config();
// Main application entry point
const express = require("express");
const fs = require("fs");
@@ -16,7 +18,8 @@ const {
healthHandler,
statusHandler,
nostrHandler,
uploadHandler
uploadHandler,
pinsHandler,
} = require("./modules/routes");
const { runNostrJob, pinnerJob } = require("./modules/jobs");
@@ -50,6 +53,7 @@ setupMiddleware(app);
app.get("/health", healthHandler);
app.get("/status", statusHandler);
app.get("/nostr", (req, res) => nostrHandler(req, res, NPUB));
app.get("/api/pins", pinsHandler);
app.post("/upload", upload.single("file"), uploadHandler);
// Apply error handler
+1 -1
View File
@@ -37,7 +37,7 @@ const HOST = "0.0.0.0";
const UPLOAD_TEMP_DIR = "/tmp/filedrop";
// Nostr timing configuration
const NOSTR_CHECK_INTERVAL_MS = 11 * 60 * 1000; // 11 minutes
const NOSTR_CHECK_INTERVAL_MS = 7 * 60 * 1000; // 7 minutes
const PINNER_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
module.exports = {
+189
View File
@@ -0,0 +1,189 @@
// Database module for tracking pinned content
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
// Initialize database
const dbDir = path.join(__dirname, '..', 'db');
const dbPath = process.env.DB_PATH || path.join(dbDir, 'pins.db');
// Ensure db directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Enable WAL mode for better concurrency
db.pragma('journal_mode = WAL');
// Create pins table
db.exec(`
CREATE TABLE IF NOT EXISTS pins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL,
cid TEXT NOT NULL UNIQUE,
size INTEGER DEFAULT 0,
timestamp INTEGER NOT NULL,
author TEXT,
type TEXT NOT NULL CHECK(type IN ('self', 'friend')),
status TEXT NOT NULL DEFAULT 'pinned' CHECK(status IN ('pending', 'pinned', 'cached', 'failed')),
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_pins_cid ON pins(cid);
CREATE INDEX IF NOT EXISTS idx_pins_event_id ON pins(event_id);
CREATE INDEX IF NOT EXISTS idx_pins_type ON pins(type);
CREATE INDEX IF NOT EXISTS idx_pins_status ON pins(status);
CREATE INDEX IF NOT EXISTS idx_pins_timestamp ON pins(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_pins_created_at ON pins(created_at DESC);
`);
// Prepared statements
const insertPinStmt = db.prepare(`
INSERT OR REPLACE INTO pins (event_id, cid, size, timestamp, author, type, status, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now'))
`);
const updatePinSizeStmt = db.prepare(`
UPDATE pins SET size = ?, status = ?, updated_at = strftime('%s', 'now')
WHERE cid = ?
`);
const getPinByCidStmt = db.prepare(`
SELECT * FROM pins WHERE cid = ?
`);
const getPinsStmt = db.prepare(`
SELECT * FROM pins
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const getPinsByTypeStmt = db.prepare(`
SELECT * FROM pins
WHERE type = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const getStatsStmt = db.prepare(`
SELECT
type,
status,
COUNT(*) as count,
COALESCE(SUM(size), 0) as total_size
FROM pins
GROUP BY type, status
`);
const getTotalCountStmt = db.prepare(`
SELECT COUNT(*) as total FROM pins
`);
const getRecentPinsStmt = db.prepare(`
SELECT * FROM pins
ORDER BY created_at DESC
LIMIT ?
`);
// Functions
const recordPin = ({ eventId, cid, size = 0, timestamp, author, type, status = 'pinned' }) => {
try {
insertPinStmt.run(eventId, cid, size, timestamp, author, type, status);
console.log(`[DB] Recorded ${type} pin: ${cid} from event ${eventId}`);
return true;
} catch (err) {
console.error(`[DB] Failed to record pin:`, err.message);
return false;
}
};
const updatePinSize = (cid, size, status = 'pinned') => {
try {
updatePinSizeStmt.run(size, status, cid);
console.log(`[DB] Updated pin size: ${cid} = ${size} bytes`);
return true;
} catch (err) {
console.error(`[DB] Failed to update pin size:`, err.message);
return false;
}
};
const getPinByCid = (cid) => {
try {
return getPinByCidStmt.get(cid);
} catch (err) {
console.error(`[DB] Failed to get pin by CID:`, err.message);
return null;
}
};
const getPins = (limit = 50, offset = 0) => {
try {
return getPinsStmt.all(limit, offset);
} catch (err) {
console.error(`[DB] Failed to get pins:`, err.message);
return [];
}
};
const getPinsByType = (type, limit = 50, offset = 0) => {
try {
return getPinsByTypeStmt.all(type, limit, offset);
} catch (err) {
console.error(`[DB] Failed to get pins by type:`, err.message);
return [];
}
};
const getStats = () => {
try {
return getStatsStmt.all();
} catch (err) {
console.error(`[DB] Failed to get stats:`, err.message);
return [];
}
};
const getTotalCount = () => {
try {
const result = getTotalCountStmt.get();
return result ? result.total : 0;
} catch (err) {
console.error(`[DB] Failed to get total count:`, err.message);
return 0;
}
};
const getRecentPins = (limit = 10) => {
try {
return getRecentPinsStmt.all(limit);
} catch (err) {
console.error(`[DB] Failed to get recent pins:`, err.message);
return [];
}
};
// Cleanup on exit
process.on('exit', () => {
db.close();
});
process.on('SIGINT', () => {
db.close();
process.exit(0);
});
module.exports = {
db,
recordPin,
updatePinSize,
getPinByCid,
getPins,
getPinsByType,
getStats,
getTotalCount,
getRecentPins,
};
+12 -5
View File
@@ -5,18 +5,25 @@ const { IPFS_API } = require("./config");
// Get total size of pinned content
const getPinnedSize = async () => {
try {
const pinResponse = await axios.post(`${IPFS_API}/api/v0/pin/ls?type=recursive`, null, { timeout: 10000 });
const pinResponse = await axios.post(`${IPFS_API}/api/v0/pin/ls?type=recursive`, {}, { timeout: 10000 });
const pins = pinResponse.data.Keys || {};
const cids = Object.keys(pins);
let totalSize = 0;
for (const cid of cids) {
try {
const statResponse = await axios.post(`${IPFS_API}/api/v0/object/stat?arg=${encodeURIComponent(cid)}`, null, { timeout: 5000 });
totalSize += statResponse.data.CumulativeSize || 0;
const statResponse = await axios.post(`${IPFS_API}/api/v0/files/stat?arg=/ipfs/${encodeURIComponent(cid)}`, {}, { timeout: 5000 });
const size = statResponse.data.CumulativeSize || statResponse.data.Size || 0;
totalSize += size;
} catch (err) {
// Skip CIDs that fail to stat
console.warn(`Failed to stat pinned CID ${cid}:`, err.message);
// Try alternative method with block/stat
try {
const blockResponse = await axios.post(`${IPFS_API}/api/v0/block/stat?arg=${encodeURIComponent(cid)}`, {}, { timeout: 5000 });
totalSize += blockResponse.data.Size || 0;
} catch (blockErr) {
// Skip CIDs that fail to stat
console.warn(`Failed to stat pinned CID ${cid}:`, err.message);
}
}
}
return { totalSize, count: cids.length };
+52 -5
View File
@@ -16,7 +16,8 @@ const {
setLastNostrRun,
} = require("./queue");
const { isPinned, pinCid, addCid } = require("./nostr");
const { isPinned, pinCid, addCid, getCidSize } = require("./nostr");
const { recordPin, updatePinSize, getPinByCid } = require("./database");
let timerProbabilityMethod = 0.9;
@@ -160,11 +161,37 @@ const pinnerJob = async () => {
}
if (cidToPin) {
const cidObj = selfQueue[cidToPinIndex];
console.log(`\n[Self] Pinning CID: ${cidToPin}`);
// Record to database first (as pending)
recordPin({
eventId: cidObj.eventId,
cid: cidToPin,
size: 0,
timestamp: cidObj.timestamp,
author: cidObj.author,
type: 'self',
status: 'pending'
});
// Fire-and-forget: start pinning without waiting
pinCid(cidToPin)
.then(() => console.log(`✓ Successfully pinned: ${cidToPin}`))
.catch(err => console.error(`❌ Failed to pin ${cidToPin}:`, err.message));
.then(async () => {
console.log(`✓ Successfully pinned: ${cidToPin}`);
// Try to get size after pinning
try {
const size = await getCidSize(cidToPin);
updatePinSize(cidToPin, size, 'pinned');
} catch (err) {
updatePinSize(cidToPin, 0, 'pinned');
}
})
.catch(err => {
console.error(`❌ Failed to pin ${cidToPin}:`, err.message);
updatePinSize(cidToPin, 0, 'failed');
});
removeFromSelfQueue(cidToPinIndex);
incrementPinnedSelf();
console.log(`📊 Counter updated: totalPinnedSelf = ${incrementPinnedSelf.length}`);
@@ -188,10 +215,30 @@ const pinnerJob = async () => {
console.log(` Event: ${primalLink}`);
console.log(` Author: ${cidObj.author} | Time: ${new Date(cidObj.timestamp * 1000).toISOString()}`);
// Record to database first (as pending)
recordPin({
eventId: cidObj.eventId,
cid: cid,
size: 0,
timestamp: cidObj.timestamp,
author: cidObj.author,
type: 'friend',
status: 'pending'
});
// Fire-and-forget: start caching without waiting
addCid(cid)
.then(() => console.log(`✓ Successfully cached: ${cid}`))
.catch(err => console.error(`❌ Failed to cache ${cid}:`, err.message));
.then((result) => {
console.log(`✓ Successfully cached: ${cid}`);
// Update with actual size from result
const size = result?.size || 0;
updatePinSize(cid, size, 'cached');
})
.catch(err => {
console.error(`❌ Failed to cache ${cid}:`, err.message);
updatePinSize(cid, 0, 'failed');
});
removeFromFriendsQueue(randomIndex);
incrementCachedFriends();
console.log(`📊 Counter updated: totalCachedFriends = ${incrementCachedFriends.length}`);
+19
View File
@@ -290,6 +290,24 @@ const addCid = async (cid, ipfsApi = IPFS_API) => {
return { cid, size: res.data.length, alreadyPinned, newlyAdded: !alreadyPinned };
};
// Get size of a CID
const getCidSize = async (cid, ipfsApi = IPFS_API) => {
try {
// Try files/stat first
const statResponse = await axios.post(`${ipfsApi}/api/v0/files/stat?arg=/ipfs/${encodeURIComponent(cid)}`, {}, { timeout: 5000 });
return statResponse.data.CumulativeSize || statResponse.data.Size || 0;
} catch (err) {
// Try block/stat as fallback
try {
const blockResponse = await axios.post(`${ipfsApi}/api/v0/block/stat?arg=${encodeURIComponent(cid)}`, {}, { timeout: 5000 });
return blockResponse.data.Size || 0;
} catch (blockErr) {
console.warn(`Failed to get size for CID ${cid}:`, blockErr.message);
return 0;
}
}
};
const syncNostrPins = async ({
npubOrPubkey,
ipfsApi = IPFS_API,
@@ -467,6 +485,7 @@ module.exports = {
isPinned,
pinCid,
addCid,
getCidSize,
syncNostrPins,
syncFollowPins,
toNpub,
+64
View File
@@ -23,6 +23,14 @@ const {
constants: { DEFAULT_RELAYS },
} = require("./nostr");
const {
getPins,
getPinsByType,
getStats,
getTotalCount,
getRecentPins,
} = require("./database");
const unlinkAsync = promisify(fs.unlink);
// Health check endpoint
@@ -265,9 +273,65 @@ const uploadHandler = async (req, res) => {
}
};
// Pins history endpoint
const pinsHandler = async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const offset = parseInt(req.query.offset) || 0;
const type = req.query.type; // 'self', 'friend', or undefined for all
let pins;
if (type && (type === 'self' || type === 'friend')) {
pins = getPinsByType(type, limit, offset);
} else {
pins = getPins(limit, offset);
}
const total = getTotalCount();
const stats = getStats();
res.json({
success: true,
pins: pins.map(pin => ({
id: pin.id,
eventId: pin.event_id,
cid: pin.cid,
size: pin.size,
timestamp: pin.timestamp,
author: pin.author,
type: pin.type,
status: pin.status,
createdAt: pin.created_at,
updatedAt: pin.updated_at,
})),
pagination: {
limit,
offset,
total,
hasMore: offset + limit < total,
},
stats: stats.reduce((acc, stat) => {
const key = `${stat.type}_${stat.status}`;
acc[key] = {
count: stat.count,
totalSize: stat.total_size,
};
return acc;
}, {}),
});
} catch (err) {
console.error("Pins handler error:", err);
res.status(500).json({
success: false,
error: err.message,
});
}
};
module.exports = {
healthHandler,
statusHandler,
nostrHandler,
uploadHandler,
pinsHandler,
};
+429
View File
@@ -10,8 +10,10 @@
"license": "ISC",
"dependencies": {
"axios": "^1.8.4",
"better-sqlite3": "^12.5.0",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"form-data": "^4.0.2",
"mime-types": "^3.0.2",
@@ -170,6 +172,74 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bl/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -223,6 +293,30 @@
"node": ">= 0.8"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -278,6 +372,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -408,6 +508,30 @@
"ms": "2.0.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -436,6 +560,27 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -465,6 +610,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -525,6 +679,15 @@
"node": ">= 0.6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -571,6 +734,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -655,6 +824,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -701,6 +876,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -780,12 +961,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -883,6 +1090,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -904,6 +1123,12 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -929,6 +1154,12 @@
"node": ">= 6.0.0"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -938,6 +1169,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
@@ -1009,6 +1252,15 @@
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1024,6 +1276,32 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -1049,6 +1327,16 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -1117,6 +1405,21 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -1164,6 +1467,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -1296,6 +1611,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -1328,6 +1688,57 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1337,6 +1748,18 @@
"node": ">=0.6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1401,6 +1824,12 @@
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+3 -1
View File
@@ -19,8 +19,10 @@
"homepage": "https://github.com/besoeasy/file-drop#readme",
"dependencies": {
"axios": "^1.8.4",
"better-sqlite3": "^12.5.0",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"form-data": "^4.0.2",
"mime-types": "^3.0.2",
@@ -28,4 +30,4 @@
"nostr-tools": "^2.9.0",
"ws": "^8.18.0"
}
}
}
+225
View File
@@ -219,6 +219,93 @@
</div>
</div>
</div>
<!-- Pins History Table -->
<div class="mt-6">
<div class="glassmorphism rounded-2xl shadow-xl p-6 animate-fade-in">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<i class="fas fa-history text-2xl text-black"></i>
<div>
<h2 class="text-2xl font-bold text-black">Pinned Content History</h2>
<p class="text-gray-500 text-sm">Track all media pinned from Nostr events</p>
</div>
</div>
<div class="flex gap-2">
<select id="filterType" class="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="">All Types</option>
<option value="self">Self Only</option>
<option value="friend">Friends Only</option>
</select>
<button id="refreshPins"
class="px-4 py-2 rounded-lg bg-black text-white hover:bg-gray-800 transition-colors">
<i class="fas fa-sync-alt mr-2"></i>Refresh
</button>
</div>
</div>
<!-- Stats Row -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
<p class="text-blue-600 text-xs font-semibold uppercase mb-1">Total Pins</p>
<p class="text-blue-900 text-2xl font-bold" id="statsTotal">0</p>
</div>
<div class="bg-green-50 rounded-lg p-4 border border-green-200">
<p class="text-green-600 text-xs font-semibold uppercase mb-1">Self Pinned</p>
<p class="text-green-900 text-2xl font-bold" id="statsSelfPinned">0</p>
</div>
<div class="bg-purple-50 rounded-lg p-4 border border-purple-200">
<p class="text-purple-600 text-xs font-semibold uppercase mb-1">Friends Cached</p>
<p class="text-purple-900 text-2xl font-bold" id="statsFriendsCached">0</p>
</div>
<div class="bg-orange-50 rounded-lg p-4 border border-orange-200">
<p class="text-orange-600 text-xs font-semibold uppercase mb-1">Total Size</p>
<p class="text-orange-900 text-xl font-bold" id="statsSize">0 B</p>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100 border-b-2 border-gray-300">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Event ID</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">CID</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Size</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Type</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Timestamp</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-gray-700 uppercase">Author</th>
</tr>
</thead>
<tbody id="pinsTableBody" class="divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500">
<i class="fas fa-spinner fa-spin mr-2"></i>Loading pins...
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<div class="text-sm text-gray-600" id="paginationInfo">Showing 0 of 0</div>
<div class="flex gap-2">
<button id="prevPage"
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
<i class="fas fa-chevron-left mr-2"></i>Previous
</button>
<button id="nextPage"
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Next<i class="fas fa-chevron-right ml-2"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</main>
@@ -311,11 +398,149 @@
return (bytes / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
}
function truncate(str, len = 12) {
if (!str) return '-';
if (str.length <= len) return str;
return str.substring(0, len) + '...';
}
// Pins table management
let currentPage = 0;
const limit = 50;
async function loadPins() {
const filterType = document.getElementById("filterType").value;
const pinsTableBody = document.getElementById("pinsTableBody");
try {
pinsTableBody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-500"><i class="fas fa-spinner fa-spin mr-2"></i>Loading...</td></tr>';
let url = `/api/pins?limit=${limit}&offset=${currentPage * limit}`;
if (filterType) {
url += `&type=${filterType}`;
}
const res = await fetch(url);
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load pins');
}
// Update stats
const stats = data.stats || {};
const selfPinned = (stats.self_pinned?.count || 0) + (stats.self_pending?.count || 0);
const friendsCached = (stats.friend_cached?.count || 0) + (stats.friend_pending?.count || 0);
const totalSize = Object.values(stats).reduce((sum, stat) => sum + (stat.totalSize || 0), 0);
document.getElementById("statsTotal").textContent = data.pagination.total;
document.getElementById("statsSelfPinned").textContent = selfPinned;
document.getElementById("statsFriendsCached").textContent = friendsCached;
document.getElementById("statsSize").textContent = formatBytes(totalSize);
// Update table
const pins = data.pins || [];
if (pins.length === 0) {
pinsTableBody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-500">No pins found</td></tr>';
} else {
pinsTableBody.innerHTML = pins.map(pin => {
const statusColor = {
'pinned': 'bg-green-100 text-green-800',
'cached': 'bg-blue-100 text-blue-800',
'pending': 'bg-yellow-100 text-yellow-800',
'failed': 'bg-red-100 text-red-800'
}[pin.status] || 'bg-gray-100 text-gray-800';
const typeColor = pin.type === 'self' ? 'bg-purple-100 text-purple-800' : 'bg-indigo-100 text-indigo-800';
return `
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href="https://primal.net/e/${pin.eventId}" target="_blank"
class="text-blue-600 hover:underline font-mono text-xs"
title="${pin.eventId}">
${truncate(pin.eventId, 16)}
</a>
</td>
<td class="px-4 py-3">
<a href="https://ipfs.io/ipfs/${pin.cid}" target="_blank"
class="text-blue-600 hover:underline font-mono text-xs"
title="${pin.cid}">
${truncate(pin.cid, 16)}
</a>
</td>
<td class="px-4 py-3 font-mono text-xs">${formatBytes(pin.size)}</td>
<td class="px-4 py-3">
<span class="px-2 py-1 rounded-full text-xs font-semibold ${typeColor}">
${pin.type}
</span>
</td>
<td class="px-4 py-3">
<span class="px-2 py-1 rounded-full text-xs font-semibold ${statusColor}">
${pin.status}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600">${formatTimestamp(pin.timestamp)}</td>
<td class="px-4 py-3">
<a href="https://nosta.me/${pin.author}" target="_blank"
class="text-blue-600 hover:underline font-mono text-xs"
title="${pin.author}">
${truncate(pin.author, 12)}
</a>
</td>
</tr>
`;
}).join('');
}
// Update pagination
const start = currentPage * limit + 1;
const end = Math.min((currentPage + 1) * limit, data.pagination.total);
document.getElementById("paginationInfo").textContent =
`Showing ${start}-${end} of ${data.pagination.total}`;
document.getElementById("prevPage").disabled = currentPage === 0;
document.getElementById("nextPage").disabled = !data.pagination.hasMore;
} catch (err) {
console.error('Failed to load pins:', err);
pinsTableBody.innerHTML = `<tr><td colspan="7" class="px-4 py-8 text-center text-red-500"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${err.message}</td></tr>`;
}
}
document.getElementById("refresh").addEventListener("click", () => {
load();
});
document.getElementById("refreshPins").addEventListener("click", () => {
currentPage = 0;
loadPins();
});
document.getElementById("filterType").addEventListener("change", () => {
currentPage = 0;
loadPins();
});
document.getElementById("prevPage").addEventListener("click", () => {
if (currentPage > 0) {
currentPage--;
loadPins();
}
});
document.getElementById("nextPage").addEventListener("click", () => {
currentPage++;
loadPins();
});
load();
loadPins();
</script>
</body>