Ad js upload

This commit is contained in:
2026-01-08 13:11:30 +01:00
parent 7c9f47cb1f
commit 361c2b3ec6
8 changed files with 221 additions and 22 deletions

View File

@@ -34,7 +34,9 @@ public class AssetStorageService {
Map.entry("audio/wav", ".wav"), Map.entry("audio/wav", ".wav"),
Map.entry("audio/ogg", ".ogg"), Map.entry("audio/ogg", ".ogg"),
Map.entry("audio/webm", ".webm"), Map.entry("audio/webm", ".webm"),
Map.entry("audio/flac", ".flac") Map.entry("audio/flac", ".flac"),
Map.entry("application/javascript", ".js"),
Map.entry("text/javascript", ".js")
); );
private final Path assetRoot; private final Path assetRoot;

View File

@@ -155,8 +155,9 @@ public class ChannelDirectoryService {
.orElse("asset_" + System.currentTimeMillis()); .orElse("asset_" + System.currentTimeMillis());
boolean isAudio = optimized.mediaType().startsWith("audio/"); boolean isAudio = optimized.mediaType().startsWith("audio/");
double defaultWidth = isAudio ? 400 : 640; boolean isCode = isCodeMediaType(optimized.mediaType()) || isCodeMediaType(mediaType);
double defaultHeight = isAudio ? 80 : 360; double defaultWidth = isAudio ? 400 : isCode ? 480 : 640;
double defaultHeight = isAudio ? 80 : isCode ? 270 : 360;
double width = optimized.width() > 0 ? optimized.width() : defaultWidth; double width = optimized.width() > 0 ? optimized.width() : defaultWidth;
double height = optimized.height() > 0 ? optimized.height() : defaultHeight; double height = optimized.height() > 0 ? optimized.height() : defaultHeight;
@@ -362,6 +363,14 @@ public class ChannelDirectoryService {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }
private boolean isCodeMediaType(String mediaType) {
if (mediaType == null || mediaType.isBlank()) {
return false;
}
String normalized = mediaType.toLowerCase(Locale.ROOT);
return normalized.startsWith("application/javascript") || normalized.startsWith("text/javascript");
}
private String topicFor(String broadcaster) { private String topicFor(String broadcaster) {
return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT); return "/topic/channel/" + broadcaster.toLowerCase(Locale.ROOT);
} }

View File

@@ -26,7 +26,9 @@ public class MediaDetectionService {
Map.entry("mov", "video/quicktime"), Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"), Map.entry("mp3", "audio/mpeg"),
Map.entry("wav", "audio/wav"), Map.entry("wav", "audio/wav"),
Map.entry("ogg", "audio/ogg") Map.entry("ogg", "audio/ogg"),
Map.entry("js", "application/javascript"),
Map.entry("mjs", "text/javascript")
); );
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());

View File

@@ -72,6 +72,10 @@ public class MediaOptimizationService {
return new OptimizedAsset(bytes, mediaType, 0, 0, null); return new OptimizedAsset(bytes, mediaType, 0, 0, null);
} }
if (mediaType.startsWith("application/javascript") || mediaType.startsWith("text/javascript")) {
return new OptimizedAsset(bytes, mediaType, 0, 0, null);
}
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
if (image != null) { if (image != null) {
return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null); return new OptimizedAsset(bytes, mediaType, image.getWidth(), image.getHeight(), null);

View File

@@ -1755,6 +1755,14 @@ button:disabled:hover {
color: #fbbf24; color: #fbbf24;
} }
.code-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #60a5fa;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;

View File

@@ -46,6 +46,7 @@ const selectedAssetMeta = document.getElementById("selected-asset-meta");
const selectedAssetResolution = document.getElementById("selected-asset-resolution"); const selectedAssetResolution = document.getElementById("selected-asset-resolution");
const selectedAssetIdLabel = document.getElementById("selected-asset-id"); const selectedAssetIdLabel = document.getElementById("selected-asset-id");
const selectedAssetBadges = document.getElementById("selected-asset-badges"); const selectedAssetBadges = document.getElementById("selected-asset-badges");
const selectedEditBtn = document.getElementById("selected-asset-edit");
const selectedVisibilityBtn = document.getElementById("selected-asset-visibility"); const selectedVisibilityBtn = document.getElementById("selected-asset-visibility");
const selectedDeleteBtn = document.getElementById("selected-asset-delete"); const selectedDeleteBtn = document.getElementById("selected-asset-delete");
const assetActionRow = document.getElementById("asset-actions"); const assetActionRow = document.getElementById("asset-actions");
@@ -111,7 +112,7 @@ function cancelPendingTransform(assetId) {
function ensureLayerPosition(assetId, placement = "keep") { function ensureLayerPosition(assetId, placement = "keep") {
const asset = assets.get(assetId); const asset = assets.get(assetId);
if (asset && isAudioAsset(asset)) { if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) {
return; return;
} }
const existingIndex = layerOrder.indexOf(assetId); const existingIndex = layerOrder.indexOf(assetId);
@@ -132,10 +133,10 @@ function ensureLayerPosition(assetId, placement = "keep") {
function getLayerOrder() { function getLayerOrder() {
layerOrder = layerOrder.filter((id) => { layerOrder = layerOrder.filter((id) => {
const asset = assets.get(id); const asset = assets.get(id);
return asset && !isAudioAsset(asset); return asset && !isAudioAsset(asset) && !isCodeAsset(asset);
}); });
assets.forEach((asset, id) => { assets.forEach((asset, id) => {
if (isAudioAsset(asset)) { if (isAudioAsset(asset) || isCodeAsset(asset)) {
return; return;
} }
if (!layerOrder.includes(id)) { if (!layerOrder.includes(id)) {
@@ -157,6 +158,12 @@ function getAudioAssets() {
.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0)); .sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
} }
function getCodeAssets() {
return Array.from(assets.values())
.filter((asset) => isCodeAsset(asset))
.sort((a, b) => (b.createdAtMs || 0) - (a.createdAtMs || 0));
}
function getRenderOrder() { function getRenderOrder() {
return [...getLayerOrder()] return [...getLayerOrder()]
.reverse() .reverse()
@@ -166,7 +173,7 @@ function getRenderOrder() {
function getLayerValue(assetId) { function getLayerValue(assetId) {
const asset = assets.get(assetId); const asset = assets.get(assetId);
if (asset && isAudioAsset(asset)) { if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) {
return 0; return 0;
} }
const order = getLayerOrder(); const order = getLayerOrder();
@@ -645,6 +652,11 @@ function drawAsset(asset) {
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
ctx.rotate((renderState.rotation * Math.PI) / 180); ctx.rotate((renderState.rotation * Math.PI) / 180);
if (isCodeAsset(asset)) {
ctx.restore();
return;
}
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
autoStartAudio(asset); autoStartAudio(asset);
ctx.restore(); ctx.restore();
@@ -930,6 +942,23 @@ function isAudioAsset(asset) {
return type.startsWith("audio/"); return type.startsWith("audio/");
} }
function isCodeAsset(asset) {
const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase();
return type.startsWith("application/javascript") || type.startsWith("text/javascript");
}
function isJavaScriptFile(file) {
if (!file) {
return false;
}
const type = (file.type || "").toLowerCase();
if (type.includes("javascript")) {
return true;
}
const name = (file.name || "").toLowerCase();
return name.endsWith(".js") || name.endsWith(".mjs");
}
function isVideoElement(element) { function isVideoElement(element) {
return element && element.tagName === "VIDEO"; return element && element.tagName === "VIDEO";
} }
@@ -939,6 +968,9 @@ function getDisplayMediaType(asset) {
if (!raw) { if (!raw) {
return "Unknown"; return "Unknown";
} }
if (isCodeAsset(asset)) {
return "JavaScript";
}
const parts = raw.split("/"); const parts = raw.split("/");
return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase(); return parts.length > 1 ? parts[1].toUpperCase() : raw.toUpperCase();
} }
@@ -1265,8 +1297,9 @@ function renderAssetList() {
list.appendChild(createPendingListItem(pending)); list.appendChild(createPendingListItem(pending));
}); });
const codeAssets = getCodeAssets();
const audioAssets = getAudioAssets(); const audioAssets = getAudioAssets();
const sortedAssets = [...audioAssets, ...getAssetsByLayer()]; const sortedAssets = [...codeAssets, ...audioAssets, ...getAssetsByLayer()];
sortedAssets.forEach((asset) => { sortedAssets.forEach((asset) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "asset-item"; li.className = "asset-item";
@@ -1285,13 +1318,28 @@ function renderAssetList() {
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`; name.textContent = asset.name || `Asset ${asset.id.slice(0, 6)}`;
const details = document.createElement("small"); const details = document.createElement("small");
details.textContent = `${Math.round(asset.width)}x${Math.round(asset.height)}`; details.textContent = isCodeAsset(asset)
? "JavaScript"
: `${Math.round(asset.width)}x${Math.round(asset.height)}`;
meta.appendChild(name); meta.appendChild(name);
meta.appendChild(details); meta.appendChild(details);
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "actions"; actions.className = "actions";
if (isCodeAsset(asset)) {
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "ghost icon-button";
editBtn.innerHTML = '<i class="fa-solid fa-code"></i>';
editBtn.title = "Edit script";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
openCodeAssetEditor(asset);
});
actions.appendChild(editBtn);
}
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
const playBtn = document.createElement("button"); const playBtn = document.createElement("button");
playBtn.type = "button"; playBtn.type = "button";
@@ -1313,7 +1361,7 @@ function renderAssetList() {
actions.appendChild(playBtn); actions.appendChild(playBtn);
} }
if (!isAudioAsset(asset)) { if (!isAudioAsset(asset) && !isCodeAsset(asset)) {
const toggleBtn = document.createElement("button"); const toggleBtn = document.createElement("button");
toggleBtn.type = "button"; toggleBtn.type = "button";
toggleBtn.className = "ghost icon-button"; toggleBtn.className = "ghost icon-button";
@@ -1406,6 +1454,12 @@ function updatePlayButtonIcon(button, isLooping, isPlayingLoop) {
} }
function createPreviewElement(asset) { function createPreviewElement(asset) {
if (isCodeAsset(asset)) {
const icon = document.createElement("div");
icon.className = "asset-preview code-icon";
icon.innerHTML = '<i class="fa-solid fa-code" aria-hidden="true"></i>';
return icon;
}
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
const icon = document.createElement("div"); const icon = document.createElement("div");
icon.className = "asset-preview audio-icon"; icon.className = "asset-preview audio-icon";
@@ -1621,7 +1675,7 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
aspectLockInput.checked = isAspectLocked(asset.id); aspectLockInput.checked = isAspectLocked(asset.id);
aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked); aspectLockInput.onchange = () => setAspectLock(asset.id, aspectLockInput.checked);
} }
const hideLayout = isAudioAsset(asset); const hideLayout = isAudioAsset(asset) || isCodeAsset(asset);
if (layoutSection) { if (layoutSection) {
layoutSection.classList.toggle("hidden", hideLayout); layoutSection.classList.toggle("hidden", hideLayout);
const layoutControls = layoutSection.querySelectorAll("input, button"); const layoutControls = layoutSection.querySelectorAll("input, button");
@@ -1633,7 +1687,8 @@ function updateSelectedAssetControls(asset = getSelectedAsset()) {
if (assetActionButtons.length) { if (assetActionButtons.length) {
assetActionButtons.forEach((button) => { assetActionButtons.forEach((button) => {
const allowForAudio = button.dataset.audioEnabled === "true"; const allowForAudio = button.dataset.audioEnabled === "true";
const disableButton = hideLayout && !allowForAudio; const allowForCode = button.dataset.codeEnabled === "true";
const disableButton = hideLayout && !(allowForAudio || allowForCode);
button.disabled = disableButton; button.disabled = disableButton;
button.classList.toggle("disabled", disableButton); button.classList.toggle("disabled", disableButton);
}); });
@@ -1703,8 +1758,13 @@ function updateSelectedAssetSummary(asset) {
} }
if (selectedAssetResolution) { if (selectedAssetResolution) {
if (asset) { if (asset) {
selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`; if (isCodeAsset(asset)) {
selectedAssetResolution.classList.remove("hidden"); selectedAssetResolution.textContent = "";
selectedAssetResolution.classList.add("hidden");
} else {
selectedAssetResolution.textContent = `${Math.round(asset.width)}×${Math.round(asset.height)}`;
selectedAssetResolution.classList.remove("hidden");
}
} else { } else {
selectedAssetResolution.textContent = ""; selectedAssetResolution.textContent = "";
selectedAssetResolution.classList.add("hidden"); selectedAssetResolution.classList.add("hidden");
@@ -1723,7 +1783,7 @@ function updateSelectedAssetSummary(asset) {
selectedAssetBadges.innerHTML = ""; selectedAssetBadges.innerHTML = "";
if (asset) { if (asset) {
selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset))); selectedAssetBadges.appendChild(createBadge(getDisplayMediaType(asset)));
const aspectLabel = !isAudioAsset(asset) ? formatAspectRatioLabel(asset) : ""; const aspectLabel = !isAudioAsset(asset) && !isCodeAsset(asset) ? formatAspectRatioLabel(asset) : "";
if (aspectLabel) { if (aspectLabel) {
selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle")); selectedAssetBadges.appendChild(createBadge(aspectLabel, "subtle"));
} }
@@ -1733,6 +1793,13 @@ function updateSelectedAssetSummary(asset) {
} }
} }
} }
if (selectedEditBtn) {
selectedEditBtn.disabled = !asset || !isCodeAsset(asset);
selectedEditBtn.onclick = null;
if (asset && isCodeAsset(asset)) {
selectedEditBtn.onclick = () => openCodeAssetEditor(asset);
}
}
if (selectedVisibilityBtn) { if (selectedVisibilityBtn) {
selectedVisibilityBtn.disabled = !asset; selectedVisibilityBtn.disabled = !asset;
selectedVisibilityBtn.onclick = null; selectedVisibilityBtn.onclick = null;
@@ -1754,6 +1821,10 @@ function updateSelectedAssetSummary(asset) {
} }
triggerAudioPlayback(asset, nextPlay); triggerAudioPlayback(asset, nextPlay);
}; };
} else if (asset && isCodeAsset(asset)) {
selectedVisibilityBtn.disabled = true;
selectedVisibilityBtn.title = "Script assets do not render on the canvas";
selectedVisibilityBtn.innerHTML = '<i class="fa-solid fa-eye-slash"></i>';
} else if (asset) { } else if (asset) {
selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset"; selectedVisibilityBtn.title = asset.hidden ? "Show asset" : "Hide asset";
selectedVisibilityBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? "fa-eye" : "fa-eye-slash"}"></i>`; selectedVisibilityBtn.innerHTML = `<i class="fa-solid ${asset.hidden ? "fa-eye" : "fa-eye-slash"}"></i>`;
@@ -1769,6 +1840,61 @@ function updateSelectedAssetSummary(asset) {
} }
} }
function openCodeAssetEditor(asset) {
if (!asset) {
return;
}
const modal = document.getElementById("custom-asset-modal");
const nameInput = document.getElementById("custom-asset-name");
const codeInput = document.getElementById("custom-asset-code");
const errorWrapper = document.getElementById("custom-asset-error");
const errorTitle = document.getElementById("js-error-title");
const errorDetails = document.getElementById("js-error-details");
if (errorWrapper) {
errorWrapper.classList.add("hidden");
}
if (errorTitle) {
errorTitle.textContent = "";
}
if (errorDetails) {
errorDetails.textContent = "";
}
if (nameInput) {
nameInput.value = asset.name || "";
}
if (codeInput) {
codeInput.value = "";
codeInput.placeholder = "Loading script...";
codeInput.disabled = true;
codeInput.dataset.assetId = asset.id;
}
if (modal) {
modal.classList.remove("hidden");
}
fetch(asset.url)
.then((response) => {
if (!response.ok) {
throw new Error("Failed to load script");
}
return response.text();
})
.then((text) => {
if (codeInput) {
codeInput.disabled = false;
codeInput.value = text;
}
})
.catch(() => {
if (codeInput) {
codeInput.disabled = false;
codeInput.value = "";
}
showToast("Unable to load script content.", "error");
});
}
function ensureDurationMetadata(asset) { function ensureDurationMetadata(asset) {
if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) { if (!asset || hasDuration(asset) || (!isVideoAsset(asset) && !isAudioAsset(asset))) {
return; return;
@@ -2092,7 +2218,7 @@ function uploadAsset(file = null) {
const fileInput = document.getElementById("asset-file"); const fileInput = document.getElementById("asset-file");
const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null);
if (!selectedFile) { if (!selectedFile) {
showToast("Choose an image, GIF, video, or audio file to upload.", "info"); showToast("Choose an image, GIF, video, audio, or JavaScript file to upload.", "info");
return; return;
} }
if (selectedFile.size > UPLOAD_LIMIT_BYTES) { if (selectedFile.size > UPLOAD_LIMIT_BYTES) {
@@ -2100,6 +2226,32 @@ function uploadAsset(file = null) {
return; return;
} }
if (isJavaScriptFile(selectedFile)) {
selectedFile
.text()
.then((source) => {
if (typeof getUserJavaScriptSourceError === "function") {
const error = getUserJavaScriptSourceError(source);
if (error) {
showToast(`JavaScript error: ${error.title}`, "error");
if (fileNameLabel) {
fileNameLabel.textContent = "Upload failed";
}
return;
}
}
beginAssetUpload(selectedFile);
})
.catch(() => {
showToast("Unable to read the JavaScript file. Please try again.", "error");
});
return;
}
beginAssetUpload(selectedFile);
}
function beginAssetUpload(selectedFile) {
const pendingId = addPendingUpload(selectedFile.name); const pendingId = addPendingUpload(selectedFile.name);
const data = new FormData(); const data = new FormData();
data.append("file", selectedFile); data.append("file", selectedFile);
@@ -2121,10 +2273,11 @@ function uploadAsset(file = null) {
showToast("Upload received. Processing asset...", "success"); showToast("Upload received. Processing asset...", "success");
updatePendingUpload(pendingId, { status: "processing" }); updatePendingUpload(pendingId, { status: "processing" });
}) })
.catch(() => { .catch((e) => {
if (fileNameLabel) { if (fileNameLabel) {
fileNameLabel.textContent = "Upload failed"; fileNameLabel.textContent = "Upload failed";
} }
console.error(e);
removePendingUpload(pendingId); removePendingUpload(pendingId);
showToast("Upload failed. Please try again with a supported file.", "error"); showToast("Upload failed. Please try again with a supported file.", "error");
}); });

View File

@@ -37,7 +37,7 @@ audioUnlockEvents.forEach((eventName) => {
function ensureLayerPosition(assetId, placement = "keep") { function ensureLayerPosition(assetId, placement = "keep") {
const asset = assets.get(assetId); const asset = assets.get(assetId);
if (asset && isAudioAsset(asset)) { if (asset && (isAudioAsset(asset) || isCodeAsset(asset))) {
return; return;
} }
const existingIndex = layerOrder.indexOf(assetId); const existingIndex = layerOrder.indexOf(assetId);
@@ -58,10 +58,10 @@ function ensureLayerPosition(assetId, placement = "keep") {
function getLayerOrder() { function getLayerOrder() {
layerOrder = layerOrder.filter((id) => { layerOrder = layerOrder.filter((id) => {
const asset = assets.get(id); const asset = assets.get(id);
return asset && !isAudioAsset(asset); return asset && !isAudioAsset(asset) && !isCodeAsset(asset);
}); });
assets.forEach((asset, id) => { assets.forEach((asset, id) => {
if (isAudioAsset(asset)) { if (isAudioAsset(asset) || isCodeAsset(asset)) {
return; return;
} }
if (!layerOrder.includes(id)) { if (!layerOrder.includes(id)) {
@@ -345,6 +345,11 @@ function drawAsset(asset) {
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight); ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
ctx.rotate((renderState.rotation * Math.PI) / 180); ctx.rotate((renderState.rotation * Math.PI) / 180);
if (isCodeAsset(asset)) {
ctx.restore();
return;
}
if (isAudioAsset(asset)) { if (isAudioAsset(asset)) {
if (!asset.hidden) { if (!asset.hidden) {
autoStartAudio(asset); autoStartAudio(asset);
@@ -433,6 +438,11 @@ function isAudioAsset(asset) {
return asset?.mediaType?.startsWith("audio/"); return asset?.mediaType?.startsWith("audio/");
} }
function isCodeAsset(asset) {
const type = (asset?.mediaType || asset?.originalMediaType || "").toLowerCase();
return type.startsWith("application/javascript") || type.startsWith("text/javascript");
}
function isVideoElement(element) { function isVideoElement(element) {
return element?.tagName === "VIDEO"; return element?.tagName === "VIDEO";
} }

View File

@@ -51,7 +51,7 @@
id="asset-file" id="asset-file"
class="file-input-field" class="file-input-field"
type="file" type="file"
accept="image/*,video/*,audio/*" accept="image/*,video/*,audio/*,application/javascript,text/javascript,.js,.mjs"
onchange="handleFileSelection(this)" onchange="handleFileSelection(this)"
/> />
<label for="asset-file" class="file-input-trigger"> <label for="asset-file" class="file-input-trigger">
@@ -312,6 +312,16 @@
> >
<i class="fa-solid fa-rotate-right"></i> <i class="fa-solid fa-rotate-right"></i>
</button> </button>
<button
id="selected-asset-edit"
class="secondary"
type="button"
title="Edit script"
disabled
data-code-enabled="true"
>
<i class="fa-solid fa-code"></i>
</button>
<button <button
id="selected-asset-visibility" id="selected-asset-visibility"
class="secondary" class="secondary"
@@ -329,6 +339,7 @@
title="Delete asset" title="Delete asset"
disabled disabled
data-audio-enabled="true" data-audio-enabled="true"
data-code-enabled="true"
> >
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
</button> </button>