feat: Implement is_deleted flag for items, add new utility scripts, and refine UI styles.

This commit is contained in:
x
2026-01-24 17:24:22 +01:00
parent ee416a1d08
commit 63e86e9be1
9 changed files with 265 additions and 145 deletions

40
debug/fix_deleted.mjs Normal file
View File

@@ -0,0 +1,40 @@
import db from "../src/inc/sql.mjs";
import { promises as fs } from "fs";
(async () => {
console.log("Starting migration...");
const items = await db`select id, dest from items where active = false`;
console.log(`Found ${items.length} inactive items.`);
let trashCount = 0;
let pendingCount = 0;
let brokenCount = 0;
for (const item of items) {
try {
await fs.access(`./deleted/b/${item.dest}`);
// File exists in deleted, mark as is_deleted = true
await db`update items set is_deleted = true where id = ${item.id}`;
trashCount++;
} catch {
// Not in deleted, check public
try {
await fs.access(`./public/b/${item.dest}`);
// In public, is_deleted = false (default)
pendingCount++;
} catch {
// Not in either? Broken.
console.log(`Item ${item.id} (${item.dest}) missing from both locations.`);
brokenCount++;
// Default is false, which effectively puts it in pending queue as 'broken'
}
}
}
console.log(`Migration complete.`);
console.log(`Trash (soft-deleted): ${trashCount}`);
console.log(`Pending: ${pendingCount}`);
console.log(`Broken: ${brokenCount}`);
process.exit(0);
})();

View File

@@ -10,7 +10,8 @@
"autotagger": "node debug/autotagger.mjs",
"thumbnailer": "node debug/thumbnailer.mjs",
"test": "node debug/test.mjs",
"clean": "node debug/clean.mjs"
"clean": "node debug/clean.mjs",
"fix:deleted": "node debug/fix_deleted.mjs"
},
"author": "Flummi",
"license": "MIT",

View File

@@ -3386,7 +3386,7 @@ input#s_avatar {
.login-modal-content {
background: var(--bg);
border: 1px solid var(--accent);
border-radius: 8px;
border-radius: 0;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
max-width: 400px;
width: 100%;
@@ -3405,7 +3405,18 @@ input#s_avatar {
.login-modal-content .login-image {
max-width: 100%;
margin-bottom: 1rem;
border-radius: 4px;
border-radius: 0;
}
.notification-dot {
width: 10px;
height: 10px;
background-color: #39ff14;
/* Matrix neon green */
border-radius: 50%;
display: inline-block;
margin-left: 5px;
box-shadow: 0 0 5px #39ff14;
}
.login-modal-content input[type="text"],

View File

@@ -40,8 +40,7 @@ export default async bot => {
if (!(await lib.getTags(id)).filter(tag => tag.id == tagid).length) {
// insert
await db`
insert into "tags_assign" ${
db({
insert into "tags_assign" ${db({
item_id: id,
tag_id: tagid,
user_id: 1
@@ -113,8 +112,7 @@ export default async bot => {
if (!await lib.hasTag(id, 1)) {
// insert
await db`
insert into "tags_assign" ${
db({
insert into "tags_assign" ${db({
item_id: id,
tag_id: 1, // sfw
user_id: 1
@@ -159,8 +157,7 @@ export default async bot => {
if (!await lib.hasTag(id, 2)) {
// insert
await db`
insert into "tags_assign" ${
db({
insert into "tags_assign" ${db({
item_id: id,
tag_id: 2, // nsfw
user_id: 1
@@ -235,7 +232,7 @@ export default async bot => {
return await e.reply(`f0ck ${id}: too late lol`);
}
await db`update "items" set active = 'false' where id = ${id}`;
await db`update "items" set active = 'false', is_deleted = true where id = ${id}`;
await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });

View File

@@ -138,7 +138,7 @@ export default (router, tpl) => {
});
}
await db`update "items" set active = 'true' where id = ${id}`;
await db`update "items" set active = 'true', is_deleted = false where id = ${id}`;
// Check if files need moving (if they are in deleted/)
try {
@@ -169,51 +169,56 @@ export default (router, tpl) => {
const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c;
const pages = Math.ceil(total / limit);
const _posts = await db`
select id, mime, username, dest
from "items"
// Fetch Pending (not deleted)
const pending = await db`
select i.id, i.mime, i.username, i.dest, array_agg(t.tag) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
where
active = 'false'
order by id desc
limit ${limit} offset ${offset}
i.active = 'false' and i.is_deleted = false
group by i.id
order by i.id desc
`;
if (_posts.length === 0 && page > 1) {
// if page empty, maybe redirect to last page or page 1?
// Just render empty for now
}
// Fetch Trash (deleted)
const trash = await db`
select i.id, i.mime, i.username, i.dest, array_agg(t.tag) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
where
i.active = 'false' and i.is_deleted = true
group by i.id
order by i.id desc
`;
if (_posts.length === 0) {
return res.reply({
body: tpl.render('admin/approve', { posts: [], pages: 0, page: 1, tmp: null }, req)
});
}
const posts = await Promise.all(_posts.map(async p => {
// Try to get thumbnail from public or deleted
let thumb;
// Helper to process thumbnails
const processItems = async (items, isInTrash) => {
return Promise.all(items.map(async p => {
let thumb = '';
const path = isInTrash ? 'deleted' : 'public';
try {
// Try public first
thumb = (await fs.readFile(`./public/t/${p.id}.webp`)).toString('base64');
} catch {
try {
thumb = (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64');
} catch {
thumb = ""; // No thumbnail?
}
}
thumb = (await fs.readFile(`./${path}/t/${p.id}.webp`)).toString('base64');
} catch { }
return {
...p,
thumbnail: thumb
thumbnail: thumb,
tags: p.tags.filter(t => t !== null)
};
}));
};
const pendingProcessed = await processItems(pending, false);
const trashProcessed = await processItems(trash, true);
res.reply({
body: tpl.render('admin/approve', {
posts,
pending: pendingProcessed,
trash: trashProcessed,
page,
pages,
stats: { total: posts.length },
stats: { total: pending.length + trash.length },
tmp: null
}, req)
});

View File

@@ -46,7 +46,7 @@ export default async bot => {
continue;
}
await db`update "items" set active = 'false' where id = ${id}`;
await db`update "items" set active = 'false', is_deleted = true where id = ${id}`;
await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });

View File

@@ -103,6 +103,11 @@ process.on('unhandledRejection', err => {
where id = ${+user[0].sess_id}
`;
if (req.session.admin) {
const pending = await db`select count(*) as c from "items" where active = false and is_deleted = false`;
req.session.pending_count = pending[0].c;
}
req.session.theme = req.cookies.theme;
req.session.fullscreen = req.cookies.fullscreen;

View File

@@ -3,18 +3,22 @@
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
<table class="table" style="width: 100%">
@if(pending.length > 0)
<h2>Pending Uploads</h2>
<table class="table" style="width: 100%; margin-bottom: 30px;">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Tags</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(posts as post)
@each(pending as post)
<tr>
<td>
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
@@ -24,6 +28,11 @@
<td>{{ post.id }}</td>
<td>{{ post.username }}</td>
<td>{{ post.mime }}</td>
<td>
@each(post.tags as tag)
<span class="badge badge-secondary" style="margin-right: 5px;">{{ tag }}</span>
@endeach
</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-success">Approve</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Deny /
@@ -31,13 +40,60 @@
</td>
</tr>
@endeach
@if(posts.length === 0)
<tr>
<td colspan="5">No pending items.</td>
</tr>
@endif
</tbody>
</table>
@endif
@if(trash.length > 0)
<h2 style="color: #ff6b6b; margin-top: 40px;">Reference / Soft Deleted</h2>
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore
them.</p>
<table class="table" style="width: 100%; opacity: 0.8;">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Tags</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(trash as post)
<tr>
<td>
@if(post.thumbnail)
<img src="data:image/webp;base64,{{ post.thumbnail }}" style="max-height: 150px; opacity: 0.6;">
@else
<span style="color:red;">[File Missing]</span>
@endif
</td>
<td>{{ post.id }}</td>
<td>{{ post.username }}</td>
<td>{{ post.mime }}</td>
<td>
@each(post.tags as tag)
<span class="badge badge-secondary" style="margin-right: 5px;">{{ tag }}</span>
@endeach
</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-warning">Restore</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Purge</a>
</td>
</tr>
@endeach
</tbody>
</table>
@endif
@if(pending.length === 0 && trash.length === 0)
<div style="text-align: center; padding: 50px;">
<h3>No pending items.</h3>
<p>Go touch grass?</p>
</div>
@endif
<br>
@if(typeof pages !== 'undefined' && pages > 1)
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
@@ -54,7 +110,7 @@
<div style="text-align: center; margin-bottom: 20px;">
<button id="btn-deny-all" class="badge badge-danger" onclick="window.handleDenyAll(event)"
style="font-size: 1.2em; padding: 10px 20px; border: none; cursor: pointer;">Deny All</button>
style="font-size: 1.2em; padding: 10px 20px; border: none; cursor: pointer;">Deny All Visible</button>
</div>
<a href="/admin">Back to Admin</a>

View File

@@ -12,7 +12,12 @@
<a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a>
<a href="/upload">upload</a>
@if(session.admin)
<a href="/admin">admin</a>
<a href="/admin">Admin
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
<span class="notification-dot" title="{{ session.pending_count }} Pending"
onclick="event.preventDefault(); window.location.href='/admin/approve';"></span>
@endif
</a>
@endif
<a href="/settings">settings</a>
<a href="/about">about</a>