feat: add soft delete and media tab for user attachments with admin restore

This commit is contained in:
2026-06-11 18:52:56 +00:00
parent c74228bc6c
commit 3862958fae
40 changed files with 1549 additions and 81 deletions
-52
View File
@@ -82,61 +82,9 @@
color: var(--accent);
}
.attachment-lightbox {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
cursor: pointer;
}
.attachment-lightbox.visible {
display: flex;
}
.attachment-lightbox img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius);
}
.attachment-lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: none;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 480px) {
.attachment-gallery-item {
width: 80px;
height: 80px;
}
}
@media (hover: none) and (pointer: coarse) {
.attachment-lightbox-close {
width: 48px;
height: 48px;
top: 0.75rem;
right: 0.75rem;
font-size: 1.375rem;
}
}
+1 -1
View File
@@ -867,7 +867,7 @@ button.comment-form-submit:hover {
.context-menu,
.dp-toast-host,
.feed-fab,
.attachment-lightbox {
.lightbox-overlay {
will-change: transform;
}
+58
View File
@@ -0,0 +1,58 @@
img[data-lightbox] {
cursor: zoom-in;
}
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
cursor: zoom-out;
overscroll-behavior: contain;
}
.lightbox-overlay.visible {
display: flex;
}
.lightbox-overlay .lightbox-image {
max-width: 95vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius);
cursor: default;
}
.lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: var(--white);
border: none;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
@media (hover: none) and (pointer: coarse) {
.lightbox-close {
width: 48px;
height: 48px;
top: 0.75rem;
right: 0.75rem;
font-size: 1.375rem;
}
}
+185
View File
@@ -0,0 +1,185 @@
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-md);
margin: 0.5rem 0;
}
.media-tile {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
background: var(--bg-card-hover);
}
.media-tile .gallery-thumb,
.media-tile .gallery-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-tile .gallery-video {
background: #000;
object-fit: contain;
}
.media-tile .non-image {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.5rem;
text-align: center;
text-decoration: none;
color: var(--text-secondary);
}
.media-tile .non-image .icon {
font-size: 2rem;
margin-bottom: 0.25rem;
}
.media-tile .non-image .name {
font-size: 0.6875rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-tile .non-image .size {
font-size: 0.625rem;
color: var(--text-muted);
}
.media-tile-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: var(--space-sm);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 45%, transparent 70%, rgba(0, 0, 0, 0.55));
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: none;
}
.media-tile:hover .media-tile-overlay,
.media-tile:focus-within .media-tile-overlay {
opacity: 1;
}
.media-tile-overlay > * {
pointer-events: auto;
}
.media-source-link {
font-size: 0.6875rem;
font-weight: 600;
color: var(--white);
background: rgba(0, 0, 0, 0.5);
padding: 0.125rem 0.5rem;
border-radius: var(--radius);
text-decoration: none;
}
.media-source-link:hover {
background: var(--accent);
}
.media-delete-btn {
margin-left: auto;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: var(--white);
font-size: 0.9375rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.media-delete-btn:hover {
background: var(--danger);
}
.media-tile.media-removing {
opacity: 0;
transform: scale(0.92);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.media-tile-meta {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
font-size: 0.6875rem;
background: rgba(0, 0, 0, 0.55);
color: var(--white);
}
.media-uploader {
color: var(--white);
font-weight: 600;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-uploader:hover {
color: var(--accent);
}
.media-deleted-at {
color: var(--text-secondary);
flex-shrink: 0;
}
.media-tile-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: var(--space-sm);
padding: var(--space-sm);
background: rgba(0, 0, 0, 0.55);
}
.media-tile-actions .admin-inline-form {
flex: 1;
}
.media-tile-actions .admin-btn {
width: 100%;
justify-content: center;
}
@media (hover: none) and (pointer: coarse) {
.media-tile-overlay {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.media-tile-overlay,
.media-tile.media-removing {
transition: none;
}
}
+4 -1
View File
@@ -23,6 +23,7 @@ import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
@@ -31,7 +32,8 @@ class Application {
this.dialog = document.createElement("dp-dialog");
this.contextMenu = document.createElement("dp-context-menu");
this.toast = document.createElement("dp-toast");
document.body.append(this.dialog, this.contextMenu, this.toast);
this.lightbox = document.createElement("dp-lightbox");
document.body.append(this.dialog, this.contextMenu, this.toast, this.lightbox);
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
@@ -58,6 +60,7 @@ class Application {
this.devii = new DeviiTerminal();
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
this.mediaGallery = new MediaGallery();
}
}
+3
View File
@@ -13,6 +13,9 @@ export class ContentEnhancer {
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
contentRenderer.applyTo(el);
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
img.dataset.lightbox = "";
});
});
contentRenderer.highlightAll();
}
+33
View File
@@ -0,0 +1,33 @@
import { Http } from "./Http.js";
export class MediaGallery {
constructor() {
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-media-delete]");
if (!btn) return;
e.preventDefault();
this.remove(btn);
});
}
async remove(btn) {
const uid = btn.dataset.mediaDelete;
const tile = btn.closest("[data-media-tile]");
try {
await Http.send(`/media/${uid}/delete`);
if (tile) {
tile.classList.add("media-removing");
setTimeout(() => tile.remove(), 220);
}
if (window.app && window.app.toast) {
window.app.toast.show("Media deleted");
}
} catch (err) {
if (window.app && window.app.toast) {
window.app.toast.show(err.message, { type: "error" });
}
}
}
}
window.MediaGallery = MediaGallery;
@@ -0,0 +1,71 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppLightbox extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this.lastFocus = null;
this.bodyOverflow = "";
this.build();
this.bind();
}
build() {
const overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.innerHTML =
'<button type="button" class="lightbox-close" aria-label="Close">&times;</button>' +
'<img class="lightbox-image" src="" alt="">';
this.appendChild(overlay);
this.overlay = overlay;
this.image = overlay.querySelector(".lightbox-image");
this.closeBtn = overlay.querySelector(".lightbox-close");
}
bind() {
this.overlay.addEventListener("click", (e) => {
if (e.target !== this.image) this.close();
});
this.closeBtn.addEventListener("click", () => this.close());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.overlay.classList.contains("visible")) {
e.preventDefault();
this.close();
}
});
document.addEventListener("click", (e) => {
const thumb = e.target.closest("img[data-lightbox]");
if (!thumb || thumb.closest("a")) return;
e.preventDefault();
this.open(thumb.dataset.full || thumb.currentSrc || thumb.src, { alt: thumb.alt });
});
}
open(src, options) {
const opts = options || {};
if (!src) return;
this.image.src = src;
this.image.alt = opts.alt || "";
this.lastFocus = document.activeElement;
this.bodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
this.overlay.classList.add("visible");
setTimeout(() => this.closeBtn.focus(), 20);
}
close() {
this.overlay.classList.remove("visible");
this.image.src = "";
document.body.style.overflow = this.bodyOverflow;
if (this.lastFocus && this.lastFocus.focus) this.lastFocus.focus();
}
}
customElements.define("dp-lightbox", AppLightbox);
+1
View File
@@ -7,4 +7,5 @@ import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
import "./AppLightbox.js";
import "./ContainerTerminal.js";