1
0
forked from w0bm/f0bm

35 Commits

Author SHA1 Message Date
x
54f266ff3d adding shortcuts 2026-01-24 01:38:25 +01:00
x
a9871187ab numeric tag entry point fix potential 2026-01-24 01:35:30 +01:00
x
43da214f73 fixing upload 2026-01-24 01:28:14 +01:00
x
c822a4f4e7 possible user fav fix 2026-01-24 01:16:49 +01:00
x
a8bb3e67f5 changin rand buton style 2026-01-24 01:11:03 +01:00
x
85912f4ba1 LUPE KLEINER! 2026-01-24 00:43:46 +01:00
x
f3a1fde23d commenting out rand button in navbar 2026-01-24 00:42:33 +01:00
x
8085b0166c making search icon smaller 2026-01-24 00:41:33 +01:00
x
85578b179b clean up deleted q 2026-01-24 00:14:25 +01:00
x
1a3514effa more possible fixes for uploading 2026-01-23 23:44:50 +01:00
x
a439683caf possible upload fix 2026-01-23 23:42:23 +01:00
x
577d73af11 realizing webupload with approval functionality 2026-01-23 23:35:12 +01:00
x
42f4e19897 remove debug output for fav randoming 2026-01-23 22:09:58 +01:00
x
0a5f57b5a9 another possible fix for fav randoming 2026-01-23 22:06:57 +01:00
x
03f2630090 potential fix for mixed random results when unathenticated 2026-01-23 22:00:49 +01:00
x
6692f32c4b possible fix for random fav behaviour 2026-01-23 21:52:34 +01:00
x
8af49b6ec1 improving fav detection logic 2026-01-23 21:38:47 +01:00
x
9c25f89adc adding a better navbar 2026-01-23 21:31:06 +01:00
x
ee6fda8f06 new modal for deleting tags and items 2026-01-23 20:52:49 +01:00
x
e9c377dc87 fixing random not working for user fav view 2026-01-23 20:28:03 +01:00
x
f5e386593d fixing tag image encoding 2026-01-23 20:08:38 +01:00
x
1dd4b54b48 change how tags are displayed in tag image 2026-01-23 19:52:39 +01:00
x
4de2652ffe adding cool search 2026-01-23 19:44:17 +01:00
7b1e0af0cb Merge pull request 'fixing background visibility and states' (#5) from eins-f0bm into f0bm
Reviewed-on: w0bm/f0bm#5
2026-01-23 17:38:20 +00:00
x
224064d0ca fixing background visibility and states 2026-01-23 18:37:44 +01:00
52533486a2 Merge pull request 'eins-f0bm' (#4) from eins-f0bm into f0bm
Reviewed-on: w0bm/f0bm#4
2026-01-23 16:05:04 +00:00
x
3ee28fd0b7 Merge branch 'f0bm' into eins-f0bm 2026-01-23 17:03:13 +01:00
eins
964284d5c9 REAL seamless now! 2026-01-23 15:53:45 +00:00
x
9a03d5f697 adding generic tag cards 2026-01-23 16:53:19 +01:00
eins
9b1041dda7 first version of infinite scrolling 2026-01-23 15:45:28 +00:00
4bc8b8f436 Merge pull request 'fixed issues with the random button and hotkeys' (#2) from eins/f0bm:f0bm into f0bm
Reviewed-on: w0bm/f0bm#2
2026-01-23 14:53:20 +00:00
eins
45f9345e9c fix r key on tag overview 2026-01-23 14:46:39 +00:00
eins
c74e5a7402 fixed scrolling in overview 2026-01-23 14:39:42 +00:00
eins
6799ec1567 fixed issues with the random button and hotkeys 2026-01-23 14:07:52 +00:00
007cf3189c Merge pull request 'added AJAX loading for videos' (#1) from eins/f0bm:eins-patch-1 into f0bm
Reviewed-on: w0bm/f0bm#1
Reviewed-by: Kibi Kelburton <schrumpel@noreply.DOMAIN>
2026-01-23 13:33:59 +00:00
25 changed files with 3060 additions and 492 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ deleted/b
deleted/ca deleted/ca
deleted/t deleted/t
tmp/* tmp/*
tools

View File

@@ -1197,7 +1197,9 @@ body {
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
overflow: unset; overflow: unset;
font-size: 14px; font-size: 14px;
height: /* 100%; */auto; height:
/* 100%; */
auto;
} }
.wrapper { .wrapper {
@@ -1318,6 +1320,13 @@ div.posts>a:hover::after {
grid-template-columns: auto 1fr 0fr; grid-template-columns: auto 1fr 0fr;
justify-content: start; justify-content: start;
border-bottom: 1px solid var(--nav-border-color); border-bottom: 1px solid var(--nav-border-color);
background: var(--nav-bg);
transition: background 0.2s ease;
}
.navbar.scrolled {
background: #000 !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
} }
.navbar-brand { .navbar-brand {
@@ -1424,7 +1433,7 @@ ul.navbar-nav li.nav-item {
} }
.nav-link[data-toggle="dropdown"].ddcontent::after { .nav-link[data-toggle="dropdown"].ddcontent::after {
content: "\00a0("attr(content) ")\00a0\25bc"; content: "\00a0(" attr(content) ")\00a0\25bc";
} }
.nav-link[data-toggle="dropdown"]:not(.ddcontent)::after { .nav-link[data-toggle="dropdown"]:not(.ddcontent)::after {
@@ -1576,14 +1585,10 @@ span.placeholder {
} }
@media (max-width: 1056px) { @media (max-width: 1056px) {
.navbar {
display: grid;
grid-template-rows: 1fr 1fr;
grid-template-areas: 'f0ck f0ck f0ck';
}
/* Navbar grid layout removed for modern-navbar compatibility */
.navbar-brand { .navbar-brand {
grid-area: f0ck; /* maintained for potential other uses or reset */
} }
.pagination-container-fluid { .pagination-container-fluid {
@@ -1608,7 +1613,7 @@ span.placeholder {
} }
@media (max-width: 1325px) { @media (max-width: 1325px) {
/* ranking page - idea */ /* ranking page - idea */
/* .ranking { /* .ranking {
grid-template-columns: 1fr 1fr !important; grid-template-columns: 1fr 1fr !important;
} */ } */
@@ -1783,8 +1788,9 @@ span.placeholder {
.index-container { .index-container {
width: 100%; width: 100%;
padding: 5px; padding: 5px;
/* background-color: var(--navigation-links-bg); /* background-color: var(--navigation-links-bg);
*/} */
}
@media (min-width: 361px) { @media (min-width: 361px) {
.embed-responsive-image { .embed-responsive-image {
@@ -2779,7 +2785,9 @@ ul.navbar-nav-guests li.nav-item {
margin-right: .5rem; margin-right: .5rem;
} }
.navbar-expand-lg .navbar-nav-guests .nav-link, .pagination > a, .pagination > span { .navbar-expand-lg .navbar-nav-guests .nav-link,
.pagination>a,
.pagination>span {
padding-right: .5rem; padding-right: .5rem;
padding-left: .5rem; padding-left: .5rem;
} }
@@ -2812,12 +2820,16 @@ ul.navbar-nav-guests li.nav-item {
/* Pagination Responsiveness */ /* Pagination Responsiveness */
@media (max-width: 799px) { @media (max-width: 799px) {
.navbar-expand-lg .navbar-nav-guests .nav-link, .pagination > a, .pagination > span {
.navbar-expand-lg .navbar-nav-guests .nav-link,
.pagination>a,
.pagination>span {
padding-right: 2px; padding-right: 2px;
padding-left: 2px; padding-left: 2px;
} }
.pagination > a, .pagination > span { .pagination>a,
.pagination>span {
margin-right: 2px; margin-right: 2px;
margin-left: 2px; margin-left: 2px;
} }
@@ -2830,15 +2842,19 @@ ul.navbar-nav-guests li.nav-item {
/* fadeIn effect */ /* fadeIn effect */
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
img#f0ck-image, div.imageDoor, div.posts a, video { img#f0ck-image,
animation: 1s ease-out 0s 1 fadeInFX; div.imageDoor,
div.posts a,
video {
animation: 1s ease-out 0s 1 fadeInFX;
} }
/* f0ckgle */ /* f0ckgle */
@@ -2892,11 +2908,13 @@ img#f0ck-image, div.imageDoor, div.posts a, video {
padding: 5px; padding: 5px;
} }
.f0cks h5, .favs h5 { .f0cks h5,
.favs h5 {
background: var(--dropdown-bg); background: var(--dropdown-bg);
} }
.f0cks-header, .favs-header { .f0cks-header,
.favs-header {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
background: var(--img-border-color); background: var(--img-border-color);
@@ -2925,7 +2943,7 @@ div.favs div.posts {
filter: blur(100px); filter: blur(100px);
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
z-index: 0; z-index: 0;
transition: 2s ease; transition: opacity 1.5s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0.2; opacity: 0.2;
} }
@@ -2940,7 +2958,8 @@ button#togglebg {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% {
100% {
opacity: 1; opacity: 1;
} }
} }
@@ -2949,7 +2968,8 @@ button#togglebg {
0% { 0% {
opacity: 1; opacity: 1;
} }
100% {
100% {
opacity: 0; opacity: 0;
} }
} }
@@ -2957,28 +2977,30 @@ button#togglebg {
@keyframes fadeIn { @keyframes fadeIn {
0% { 0% {
opacity: 0; opacity: 0;
} }
100% { 100% {
opacity: 0.2; opacity: 0.2;
} }
} }
@keyframes fadeOut { @keyframes fadeOut {
0% { 0% {
opacity: 0.2; opacity: 0.2;
} }
100% {
opacity: 0; 100% {
} opacity: 0;
}
} }
.fader-in { .fader-in {
animation: fadeIn .8s steps(100) forwards; opacity: 0.4 !important;
} }
.fader-out { .fader-out {
animation: fadeOut .8s steps(100) forwards opacity: 0 !important;
} }
.settings { .settings {
@@ -3004,6 +3026,7 @@ input#s_avatar {
#s_avatar:hover { #s_avatar:hover {
background: #ffffff0f; background: #ffffff0f;
} }
.theforceofthree { .theforceofthree {
display: grid; display: grid;
grid-template-columns: 0.4fr 1fr 0.4fr; grid-template-columns: 0.4fr 1fr 0.4fr;
@@ -3012,3 +3035,330 @@ input#s_avatar {
.upload { .upload {
padding: 10px; padding: 10px;
} }
/* Infinite scroll loading indicator */
.loading-spinner {
display: inline-block;
font-size: 12px;
color: var(--footbar-color);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.1;
}
50% {
opacity: 1;
}
}
/* Modern Tags Layout */
.tags-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
padding: 20px 0;
}
.tag-card {
display: flex;
flex-direction: column;
background: var(--badge-bg, #171717);
border-radius: 12px;
overflow: hidden;
text-decoration: none !important;
transition: transform 0.2s, box-shadow 0.2s;
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.1));
position: relative;
}
.tag-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
background: var(--dropdown-bg, #232323);
border-color: var(--accent, #9f0);
}
.tag-card-image {
width: 100%;
height: 100px;
overflow: hidden;
position: relative;
background: #000;
}
.tag-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s;
opacity: 0.8;
}
.tag-card:hover .tag-card-image img {
transform: scale(1.1);
opacity: 1;
}
.tag-card-content {
padding: 15px;
display: flex;
flex-direction: column;
gap: 5px;
}
.tag-name {
color: var(--white, #fff);
font-weight: bold;
font-size: 1.1em;
font-family: var(--font, monospace);
}
.tag-count {
color: #888;
font-size: 0.9em;
}
/* Search Overlay */
#search-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(5px);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
#search-overlay.visible {
opacity: 1;
}
#search-input {
background: transparent;
border: none;
border-bottom: 2px solid var(--accent);
color: var(--white);
font-size: 3rem;
width: 100%;
max-width: 800px;
text-align: center;
outline: none;
font-family: var(--font);
padding: 10px;
}
#search-input::placeholder {
color: #555;
text-transform: uppercase;
}
#search-close {
position: absolute;
top: 20px;
right: 30px;
color: var(--white);
font-size: 2rem;
cursor: pointer;
font-family: sans-serif;
opacity: 0.7;
transition: opacity 0.2s;
}
#search-close:hover {
opacity: 1;
}
/* Delete Tag Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(5px);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
padding: 30px;
border-radius: 10px;
text-align: center;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
min-width: 300px;
}
.modal-content h3 {
margin-top: 0;
color: var(--white);
}
.modal-content p {
color: #ccc;
margin: 20px 0;
}
.modal-actions {
display: flex;
justify-content: center;
gap: 15px;
}
.modal-actions button {
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-weight: bold;
font-family: var(--font);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-secondary {
background: #555;
color: white;
}
.btn-secondary:hover {
background: #666;
}
/* Nav User Dropdown */
.nav-user-dropdown {
position: relative;
margin-left: 15px;
}
.nav-user-btn {
background: transparent;
border: 1px solid var(--nav-border-color);
color: var(--white);
padding: 6px 12px;
border-radius: 0;
cursor: pointer;
font-family: var(--font);
font-size: 14px;
transition: all 0.2s ease;
}
.nav-user-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--nav-border-color-hover);
}
.nav-user-menu {
display: none;
position: absolute;
top: calc(100% + 5px);
left: 0;
min-width: 150px;
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
border-radius: 0;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
z-index: 10000;
overflow: hidden;
}
.nav-user-menu.show {
display: block;
}
.nav-user-menu a {
display: block;
padding: 10px 15px;
color: var(--white);
text-decoration: none;
transition: background 0.2s;
}
.nav-user-menu a:hover {
background: rgba(255, 255, 255, 0.1);
}
.nav-user-divider {
height: 1px;
background: var(--nav-border-color);
margin: 5px 0;
}
/* Nav Left Group - Flexbox for dropdown + links */
.nav-left-group {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.nav-links {
display: flex;
align-items: center;
gap: 12px;
}
.nav-links a {
color: var(--white);
text-decoration: none;
font-size: 14px;
opacity: 0.8;
transition: opacity 0.2s;
}
.nav-links a:hover {
opacity: 1;
}
.nav-links svg {
vertical-align: middle;
}
/* Mobile responsive navbar */
@media (max-width: 600px) {
.navbar {
flex-wrap: wrap;
gap: 10px;
}
.nav-left-group {
order: 2;
width: 100%;
justify-content: space-between;
}
.nav-links {
gap: 8px;
}
.nav-user-btn {
padding: 4px 8px;
font-size: 12px;
}
.nav-links a {
font-size: 12px;
}
}

View File

@@ -216,7 +216,7 @@ video {
} }
#main { #main {
padding: 25px; padding: 0px 25px 0px 25px;
} }
.container { .container {
@@ -262,7 +262,7 @@ video {
background: #0000008a !important; background: #0000008a !important;
} }
.pagination > a { .pagination>a {
background: #232323b2; background: #232323b2;
} }
@@ -285,32 +285,33 @@ div.search {
div.sbt { div.sbt {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-content: center; align-content: center;
} }
#sbtButton { #sbtButton {
visibility: hidden; visibility: hidden;
} }
#sbtInput { #sbtInput {
background: #00000021; background: #00000021;
box-shadow: -1px -1px 0px #252525; box-shadow: -1px -1px 0px #252525;
border: inset 1px #0000001c; border: inset 1px #0000001c;
padding: revert; padding: revert;
box-shadow: inset 0px 0px 5px 1px #0000005e; box-shadow: inset 0px 0px 5px 1px #0000005e;
width: 100%; width: 100%;
} }
.navigation-links { .navigation-links {
display: grid; display: grid;
grid-row: 1; grid-row: 1;
grid-column: 2; grid-column: 2;
grid-template-columns: auto auto 1fr; grid-template-columns: auto auto 1fr;
} }
.navigation-links-guest, ol { .navigation-links-guest,
margin: 5px; ol {
margin-block-start: 0; margin: 5px;
margin-block-end: 0; margin-block-start: 0;
padding-inline-start: 0; margin-block-end: 0;
padding-inline-start: 0;
} }

View File

@@ -130,12 +130,12 @@
return false; return false;
} }
renderTags(res.tags); renderTags(res.tags);
span.parentElement.removeChild(span); if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = ""; testList.innerText = "";
addtagClick(); addtagClick();
} }
else if (e.key === "Escape") { else if (e.key === "Escape") {
span.parentElement.removeChild(span); if (span.parentElement) span.parentElement.removeChild(span);
testList.innerText = ""; testList.innerText = "";
} }
else { else {
@@ -184,13 +184,46 @@
if (!ctx) return; if (!ctx) return;
const { postid, poster } = ctx; const { postid, poster } = ctx;
if (!confirm(`Reason for deleting f0ckpost ${postid} by ${poster} (Weihnachten™)`)) const modal = document.getElementById('delete-item-modal');
return; const idEl = document.getElementById('delete-item-id');
const res = await post("/api/v2/admin/deletepost", { const posterEl = document.getElementById('delete-item-poster');
postid: postid const confirmBtn = document.getElementById('delete-item-confirm');
}); const cancelBtn = document.getElementById('delete-item-cancel');
if (!res.success) {
alert(res.msg); if (modal) {
idEl.textContent = postid;
posterEl.textContent = poster || 'unknown';
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = async () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
try {
const res = await post("/api/v2/admin/deletepost", {
postid: postid
});
if (!res.success) {
alert(res.msg);
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
} else {
closeModal();
window.location.href = '/';
}
} catch (e) {
alert('Error: ' + e); // Or e.message
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
};
} }
}; };

View File

@@ -8,6 +8,40 @@ window.requestAnimFrame = (function () {
(() => { (() => {
let video; let video;
// User dropdown toggle
const userToggle = document.getElementById('nav-user-toggle');
const userMenu = document.getElementById('nav-user-menu');
if (userToggle && userMenu) {
userToggle.addEventListener('click', (e) => {
e.stopPropagation();
userMenu.classList.toggle('show');
});
document.addEventListener('click', (e) => {
if (!userMenu.contains(e.target) && !userToggle.contains(e.target)) {
userMenu.classList.remove('show');
}
});
}
// Initialize background preference
if (localStorage.getItem('background') == undefined) {
localStorage.setItem('background', 'true');
}
var background = localStorage.getItem('background') === 'true';
// Apply initial visual state
var initialCanvas = document.getElementById('bg');
if (initialCanvas) {
if (background) {
initialCanvas.classList.add('fader-in');
initialCanvas.classList.remove('fader-out');
} else {
initialCanvas.classList.add('fader-out');
initialCanvas.classList.remove('fader-in');
}
}
if (elem = document.querySelector("#my-video")) { if (elem = document.querySelector("#my-video")) {
video = new v0ck(elem); video = new v0ck(elem);
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
@@ -17,13 +51,27 @@ window.requestAnimFrame = (function () {
} }
}); });
const toggleBg = document.getElementById('togglebg');
if (toggleBg) {
toggleBg.addEventListener('click', function (e) { if (elem !== null) {
e.preventDefault(); // ... existing code ...
background = !background; }
localStorage.setItem('background', background.toString()); }
var canvas = document.getElementById('bg');
// Export init function for dynamic calls
window.initBackground = () => {
// Re-fetch elements as they might have been replaced
const elem = document.querySelector("#my-video");
const canvas = document.getElementById('bg');
if (elem) {
// Initialize video wrapper if needed or just get instance
// Assuming v0ck handles re-init or we just use raw element for events
// But video variable is local.
// We need to re-bind 'play' event if it's a new element.
if (canvas) {
// Restore visual state on re-init
if (background) { if (background) {
canvas.classList.add('fader-in'); canvas.classList.add('fader-in');
canvas.classList.remove('fader-out'); canvas.classList.remove('fader-out');
@@ -31,33 +79,102 @@ window.requestAnimFrame = (function () {
canvas.classList.add('fader-out'); canvas.classList.add('fader-out');
canvas.classList.remove('fader-in'); canvas.classList.remove('fader-in');
} }
animationLoop();
});
}
if (elem !== null) { const context = canvas.getContext('2d');
if (localStorage.getItem('background') == undefined) { const cw = canvas.width = canvas.clientWidth | 0;
localStorage.setItem('background', 'true'); const ch = canvas.height = canvas.clientHeight | 0;
}
var background = localStorage.getItem('background') === 'true'; const animationLoop = () => {
var canvas = document.getElementById('bg'); if (elem.paused || elem.ended || !background)
if (canvas) {
var context = canvas.getContext('2d');
var cw = canvas.width = canvas.clientWidth | 0;
var ch = canvas.height = canvas.clientHeight | 0;
function animationLoop() {
if (video.paused || video.ended || !background)
return; return;
context.drawImage(video, 0, 0, cw, ch); context.drawImage(elem, 0, 0, cw, ch);
window.requestAnimFrame(animationLoop); window.requestAnimFrame(animationLoop);
} }
elem.addEventListener('play', animationLoop); elem.addEventListener('play', animationLoop);
if (!elem.paused) {
animationLoop();
}
} }
} }
} };
// Initial call
window.initBackground();
const loadPageAjax = async (url) => {
// Show loading indicator
const navbar = document.querySelector("nav.navbar");
if (navbar) navbar.classList.add("pbwork");
try {
// Extract page number, user, tag, etc.
let page = 1;
const pMatch = url.match(/\/p\/(\d+)/);
if (pMatch) page = pMatch[1];
// Extract context
let tag = null, user = null, mime = null;
const tagMatch = url.match(/\/tag\/([^/]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
const userMatch = url.match(/\/user\/([^/]+)/);
if (userMatch) user = decodeURIComponent(userMatch[1]);
const mimeMatch = url.match(/\/(image|audio|video)/);
if (mimeMatch) mime = mimeMatch[1];
let ajaxUrl = `/ajax/items/?page=${page}`;
if (tag) ajaxUrl += `&tag=${encodeURIComponent(tag)}`;
if (user) ajaxUrl += `&user=${encodeURIComponent(user)}`;
if (mime) ajaxUrl += `&mime=${encodeURIComponent(mime)}`;
console.log("Fetching Page:", ajaxUrl);
const response = await fetch(ajaxUrl, { credentials: 'include' });
const data = await response.json();
if (data.success) {
// Replace grid content
// If "infinite scroll" we might append, but pagination implies jumping properly?
// User said "resembled in pagination", which implies staying in sync.
// If I click Next Page, I expect to SEE page 2.
// But infinite scroll usually appends.
// Let's implement REPLACE for explicit page navigation to be safe/standard.
// Wait, the "infinite scroll" feature usually implies APPEND.
// If the user wants infinite scroll, they shouldn't click pagination?
// But if they scroll, `changePage` is called which clicks `.next`.
// So if I replace content, it breaks infinite scroll flow (items disappear).
// So I should APPEND if it's "next page" and we are already on the page?
// But `changePage` is triggered by scroll.
// Let's APPEND.
const posts = document.querySelector('.posts');
if (posts) {
// Check if we are appending (next page) or jumping
// For simple "infinite scroll", we append.
posts.insertAdjacentHTML('beforeend', data.html);
}
// Update pagination
if (data.pagination) {
document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = data.pagination);
}
// Update History
history.pushState({}, '', url);
}
} catch (err) {
console.error(err);
window.location.href = url; // Fallback
} finally {
if (navbar) navbar.classList.remove("pbwork");
// Restore pagination visibility for Grid View
const navPag = document.querySelector('.pagination-container-fluid');
if (navPag) navPag.style.display = '';
}
};
let tt = false; let tt = false;
const stimeout = 500; const stimeout = 500;
@@ -68,31 +185,52 @@ window.requestAnimFrame = (function () {
} }
}; };
// Navbar scroll effect - make background black when scrolling
const navbar = document.querySelector('.navbar');
if (navbar) {
window.addEventListener('scroll', () => {
if (window.scrollY > 50) {
navbar.classList.add('scrolled');
} else {
navbar.classList.remove('scrolled');
}
});
}
const loadItemAjax = async (url, inheritContext = true) => { const loadItemAjax = async (url, inheritContext = true) => {
console.log("loadItemAjax called with:", url, "inheritContext:", inheritContext); console.log("loadItemAjax called with:", url, "inheritContext:", inheritContext);
// Show loading indicator // Show loading indicator
const navbar = document.querySelector("nav.navbar"); const navbar = document.querySelector("nav.navbar");
if (navbar) navbar.classList.add("pbwork"); if (navbar) navbar.classList.add("pbwork");
// Extract item ID from URL. Regex now handles query params, hashes, and trailing slashes. // Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
const match = url.match(/\/(\d+)(?:\/|#|\?|$)/); // Split path, filter numeric, pop last.
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
if (!match) { // Hide navbar pagination for Item View (matches SSR)
const navPag = document.querySelector('.pagination-container-fluid');
if (navPag) navPag.style.display = 'none';
if (numericSegments.length === 0) {
console.warn("loadItemAjax: No ID match found in URL", url); console.warn("loadItemAjax: No ID match found in URL", url);
// fallback for weird/external links // fallback for weird/external links
window.location.href = url; window.location.href = url;
return; return;
} }
const itemid = match[1]; const itemid = numericSegments.pop();
// <context-preservation> // <context-preservation>
// Extract context from Target URL first // Extract context from Target URL first
let tag = null, user = null; let tag = null, user = null, isFavs = false;
const tagMatch = url.match(/\/tag\/([^/]+)/); const tagMatch = url.match(/\/tag\/([^/]+)/);
if (tagMatch) tag = decodeURIComponent(tagMatch[1]); if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
const userMatch = url.match(/\/user\/([^/]+)/); const userMatch = url.match(/\/user\/([^/]+)/);
if (userMatch) user = decodeURIComponent(userMatch[1]); // Note: "user" variable shadowed? No, block scope or different name? let user defined above. if (userMatch) {
user = decodeURIComponent(userMatch[1]);
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
}
// If missing and inheritContext is true, check Window Location // If missing and inheritContext is true, check Window Location
if (inheritContext) { if (inheritContext) {
@@ -102,7 +240,11 @@ window.requestAnimFrame = (function () {
} }
if (!user) { if (!user) {
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
if (wUserMatch) user = decodeURIComponent(wUserMatch[1]); if (wUserMatch) {
user = decodeURIComponent(wUserMatch[1]);
// Check for /favs (with or without trailing /, item id, or query params)
if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
}
} }
} }
// </context-preservation> // </context-preservation>
@@ -114,13 +256,14 @@ window.requestAnimFrame = (function () {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tag) params.append('tag', tag); if (tag) params.append('tag', tag);
if (user) params.append('user', user); if (user) params.append('user', user);
if (isFavs) params.append('fav', 'true');
if ([...params].length > 0) { if ([...params].length > 0) {
ajaxUrl += '?' + params.toString(); ajaxUrl += '?' + params.toString();
} }
console.log("Fetching:", ajaxUrl); console.log("Fetching:", ajaxUrl);
const response = await fetch(ajaxUrl); const response = await fetch(ajaxUrl, { credentials: 'include' });
if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`); if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`);
const rawText = await response.text(); const rawText = await response.text();
@@ -140,21 +283,32 @@ window.requestAnimFrame = (function () {
html = rawText; html = rawText;
} }
let container = document.querySelector('.container'); let container = document.querySelector('#main .container');
if (!container && document.querySelector('.index-container')) { if (!container && document.querySelector('.index-container')) {
// Transition from Index to Item View // Transition from Index to Item View
const main = document.getElementById('main'); const main = document.getElementById('main');
main.innerHTML = '<div class="container"></div>'; main.innerHTML = '<div class="container"></div>';
container = main.querySelector('.container'); container = main.querySelector('.container');
} else if (!container && document.getElementById('main')) {
// Transition from User Profile or other pages without .container
const main = document.getElementById('main');
main.innerHTML = '<div class="container"></div>';
container = main.querySelector('.container');
} else if (container) { } else if (container) {
// Already in Item View, clear usage // Check if we are on Tags Overview logic (which reuses .container)
const oldContent = container.querySelector('.content'); const tagsOverview = container.querySelector('.tags');
const oldMetadata = container.querySelector('.metadata'); if (tagsOverview) {
const oldHeader = container.querySelector('._204863'); container.innerHTML = '';
if (oldHeader) oldHeader.remove(); } else {
if (oldContent) oldContent.remove(); // Already in Item View, clear usage
if (oldMetadata) oldMetadata.remove(); const oldContent = container.querySelector('.content');
const oldMetadata = container.querySelector('.metadata');
const oldHeader = container.querySelector('._204863');
if (oldHeader) oldHeader.remove();
if (oldContent) oldContent.remove();
if (oldMetadata) oldMetadata.remove();
}
} }
container.insertAdjacentHTML('beforeend', html); container.insertAdjacentHTML('beforeend', html);
@@ -169,13 +323,17 @@ window.requestAnimFrame = (function () {
// If we inherited context, we should reflect it in the URL // If we inherited context, we should reflect it in the URL
let pushUrl = `/${itemid}`; let pushUrl = `/${itemid}`;
// Logic from ajax.mjs context reconstruction: // Logic from ajax.mjs context reconstruction:
if (user) pushUrl = `/user/${user}/${itemid}`; // User takes precedence usually? Or strictly mutually exclusive in UI if (user) {
pushUrl = `/user/${user}/${itemid}`;
if (isFavs) pushUrl = `/user/${user}/favs/${itemid}`;
}
else if (tag) pushUrl = `/tag/${tag}/${itemid}`; else if (tag) pushUrl = `/tag/${tag}/${itemid}`;
// We overwrite proper URL even if the link clicked was "naked" // We overwrite proper URL even if the link clicked was "naked"
history.pushState({}, '', pushUrl); history.pushState({}, '', pushUrl);
setupMedia(); setupMedia();
if (window.initBackground) window.initBackground();
// Try to extract ID from response if possible or just use itemid // Try to extract ID from response if possible or just use itemid
document.title = `f0bm - ${itemid}`; document.title = `f0bm - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork"); if (navbar) navbar.classList.remove("pbwork");
@@ -187,13 +345,12 @@ window.requestAnimFrame = (function () {
}; };
const changePage = (e, pbwork = true) => { const changePage = (e, pbwork = true) => {
if (e.tagName === 'A') { if (pbwork) {
e.preventDefault(); const nav = document.querySelector("nav.navbar");
loadItemAjax(e.href); if (nav) nav.classList.add("pbwork");
} else {
pbwork && document.querySelector("nav.navbar").classList.add("pbwork");
!tt && (tt = setTimeout(() => e.click(), stimeout));
} }
// Trigger native click for navigation
e.click();
}; };
// Intercept clicks // Intercept clicks
@@ -208,10 +365,10 @@ window.requestAnimFrame = (function () {
return; return;
} }
const link = e.target.closest('#next, #prev, #random, .id-link, .nav-next, .nav-prev'); const link = e.target.closest('#next, #prev, #random, #nav-random, .id-link, .nav-next, .nav-prev');
if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
// Special check for random // Special check for random
if (link.id === 'random') { if (link.id === 'random' || link.id === 'nav-random') {
e.preventDefault(); e.preventDefault();
const nav = document.querySelector("nav.navbar"); const nav = document.querySelector("nav.navbar");
if (nav) nav.classList.add("pbwork"); if (nav) nav.classList.add("pbwork");
@@ -224,7 +381,12 @@ window.requestAnimFrame = (function () {
if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1])); if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1]));
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/); const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
if (wUserMatch) params.append('user', decodeURIComponent(wUserMatch[1])); if (wUserMatch) {
params.append('user', decodeURIComponent(wUserMatch[1]));
if (window.location.href.match(/\/favs(\/|$|\?)/)) {
params.append('fav', 'true');
}
}
if ([...params].length > 0) { if ([...params].length > 0) {
randomUrl += '?' + params.toString(); randomUrl += '?' + params.toString();
@@ -240,18 +402,109 @@ window.requestAnimFrame = (function () {
window.location.href = link.href; window.location.href = link.href;
} }
}) })
.catch(() => window.location.href = link.href); .catch((err) => {
console.error("Random fetch failed:", err);
window.location.href = link.href;
});
return; return;
} }
// Standard item links // Standard item links
e.preventDefault(); e.preventDefault();
loadItemAjax(link.href, true); if (link.href.match(/\/p\/\d+/) || link.href.match(/[?&]page=\d+/)) {
loadPageAjax(link.href);
} else {
loadItemAjax(link.href, true);
}
} else if (e.target.closest('#togglebg')) {
e.preventDefault();
background = !background;
localStorage.setItem('background', background.toString());
var canvas = document.getElementById('bg');
if (canvas) {
if (background) {
canvas.classList.remove('fader-out');
canvas.classList.add('fader-in');
// Re-trigger loop if started completely fresh or paused
if (video && !video.paused) {
// We need to access animationLoop from closure?
// Accessing it via window.initBackground might be cleaner or just restart it.
// But initBackground defines it locally.
// We can just rely on initBackground being called or canvas update.
// Actually, if we just change opacity, the loop doesn't need to stop/start technically,
// but for performance we stopped it if !background.
// So we should restart it.
window.initBackground();
}
} else {
canvas.classList.remove('fader-in');
canvas.classList.add('fader-out');
}
}
} else if (e.target.closest('.removetag')) {
e.preventDefault();
const removeBtn = e.target.closest('.removetag');
const tagLink = removeBtn.previousElementSibling;
if (tagLink) {
const tagName = tagLink.textContent.trim();
const idLink = document.querySelector('.id-link');
const id = idLink ? idLink.textContent.trim() : null;
if (id && tagName) {
const modal = document.getElementById('delete-tag-modal');
const nameEl = document.getElementById('delete-tag-name');
const confirmBtn = document.getElementById('delete-tag-confirm');
const cancelBtn = document.getElementById('delete-tag-cancel');
if (modal) {
nameEl.textContent = tagName;
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
fetch(`/api/v2/admin/${id}/tags/${encodeURIComponent(tagName)}`, {
method: 'DELETE'
})
.then(r => r.json())
.then(data => {
if (data.success) {
removeBtn.parentElement.remove();
closeModal();
} else {
alert('Error: ' + (data.msg || 'Unknown error'));
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
})
.catch(err => {
console.error(err);
alert('Failed to delete tag');
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
});
};
}
}
}
} }
}); });
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
loadItemAjax(window.location.href, true); if (window.location.href.match(/\/p\/\d+/) || window.location.href.match(/[?&]page=\d+/) || window.location.pathname === '/') {
loadPageAjax(window.location.href);
} else {
loadItemAjax(window.location.href, true);
}
}); });
// <keybindings> // <keybindings>
@@ -261,7 +514,7 @@ window.requestAnimFrame = (function () {
"a": clickOnElementBinding("#next"), "a": clickOnElementBinding("#next"),
"ArrowRight": clickOnElementBinding("#prev"), "ArrowRight": clickOnElementBinding("#prev"),
"d": clickOnElementBinding("#prev"), "d": clickOnElementBinding("#prev"),
"r": clickOnElementBinding("#random"), "r": clickOnElementBinding("#random, #nav-random"),
" ": clickOnElementBinding("#f0ck-image") " ": clickOnElementBinding("#f0ck-image")
}; };
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
@@ -327,59 +580,116 @@ window.requestAnimFrame = (function () {
} }
// </image-responsive> // </image-responsive>
// <scroller> // <infinite-scroll>
let tts = 0; const postsContainer = document.querySelector("div.posts");
const scroll_treshold = 1; if (postsContainer) {
if ([...document.querySelectorAll("div.posts")].length === 1) { // Infinite scroll state
document.addEventListener("wheel", e => { let infiniteState = {
loading: false,
hasMore: true,
currentPage: 1
};
// Extract current page from URL
const pageMatch = window.location.pathname.match(/\/p\/(\d+)/);
if (pageMatch) infiniteState.currentPage = parseInt(pageMatch[1]);
// Extract context (tag/user/mime) from URL
const getContext = () => {
const ctx = {};
const tagMatch = window.location.pathname.match(/\/tag\/([^/]+)/);
if (tagMatch) ctx.tag = decodeURIComponent(tagMatch[1]);
const userMatch = window.location.pathname.match(/\/user\/([^/]+)/);
if (userMatch) ctx.user = decodeURIComponent(userMatch[1]);
const mimeMatch = window.location.pathname.match(/\/(image|audio|video)(?:\/|$)/);
if (mimeMatch) ctx.mime = mimeMatch[1];
return ctx;
};
// Build URL path for history
const buildUrl = (page) => {
const ctx = getContext();
let path = '/';
if (ctx.tag) path += `tag/${ctx.tag}/`;
if (ctx.user) path += `user/${ctx.user}/`;
if (ctx.mime) path += `${ctx.mime}/`;
if (page > 1) path += `p/${page}`;
return path.replace(/\/$/, '') || '/';
};
// Fetch and append more items
const loadMoreItems = async () => {
if (infiniteState.loading || !infiniteState.hasMore) return;
infiniteState.loading = true;
const foot = document.querySelector("div#footbar");
if (foot) {
foot.innerHTML = '<span class="loading-spinner">Loading...</span>';
foot.style.color = 'var(--footbar-color)';
}
const nextPage = infiniteState.currentPage + 1;
const ctx = getContext();
const params = new URLSearchParams();
params.append('page', nextPage);
if (ctx.tag) params.append('tag', ctx.tag);
if (ctx.user) params.append('user', ctx.user);
if (ctx.mime) params.append('mime', ctx.mime);
try {
const response = await fetch(`/ajax/items?${params.toString()}`);
const data = await response.json();
if (data.success && data.html) {
// Append new thumbnails
postsContainer.insertAdjacentHTML('beforeend', data.html);
// Update state
infiniteState.currentPage = data.currentPage;
infiniteState.hasMore = data.hasMore;
// Update URL
history.replaceState({}, '', buildUrl(infiniteState.currentPage));
// Update pagination display if exists
const paginationLinks = document.querySelectorAll('.pagination .pagination-int-item, .pagination .btn');
paginationLinks.forEach(el => {
if (el.textContent.trim() == infiniteState.currentPage) {
el.classList.add('disabled');
}
});
} else {
infiniteState.hasMore = false;
}
} catch (err) {
console.error('Infinite scroll fetch error:', err);
} finally {
infiniteState.loading = false;
if (foot) {
foot.innerHTML = infiniteState.hasMore ? '&#9660;' : '&#8212;';
foot.style.color = 'transparent';
}
}
};
// Scroll detection - preload before reaching bottom
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
window.addEventListener("scroll", () => {
if (!document.querySelector('#main')) return; if (!document.querySelector('#main')) return;
if (Math.ceil(window.innerHeight + window.scrollY) >= document.querySelector('#main').offsetHeight && e.deltaY > 0) { // down const scrollPosition = window.innerHeight + window.scrollY;
if (elem = document.querySelector(".pagination > .next:not(.disabled)")) { const pageHeight = document.querySelector('#main').offsetHeight;
if (tts < scroll_treshold) { const distanceFromBottom = pageHeight - scrollPosition;
const foot = document.querySelector("div#footbar");
if (foot) { // Load more when within PRELOAD_OFFSET pixels of bottom
foot.style.boxShadow = "inset 0px 4px 0px var(--footbar-color)"; if (distanceFromBottom < PRELOAD_OFFSET && infiniteState.hasMore && !infiniteState.loading) {
foot.style.color = "var(--footbar-color)"; loadMoreItems();
}
tts++;
}
else
changePage(elem);
}
}
else if (window.scrollY <= 0 && e.deltaY < 0) { // up
if (elem = document.querySelector(".pagination > .prev:not(.disabled)")) {
if (tts < scroll_treshold) {
const nav = document.querySelector("nav.navbar");
if (nav) {
nav.style.boxShadow = "0px 2px 0px var(--loading-indicator-color)";
nav.style.transition = ".2s ease-in-out";
}
tts++;
}
else
changePage(elem);
}
}
else {
tts = 0;
const foot = document.querySelector("div#footbar");
if (foot) {
foot.style.boxShadow = "unset";
foot.style.color = "transparent";
}
const nav = document.querySelector("nav.navbar");
if (nav) nav.style.boxShadow = "unset";
} }
}); });
} }
// </infinite-scroll>
const rmatch = /\/p\/(\d+?)/;
if (document.referrer.match(rmatch) && document.location.href.match(rmatch))
if (document.location.href.match(rmatch) < document.referrer.match(rmatch))
document.body.scrollTop = document.body.scrollHeight;
// </scroller>
// <swipe> // <swipe>
const swipeRT = { const swipeRT = {
@@ -513,9 +823,77 @@ window.requestAnimFrame = (function () {
// <scroller> // <scroller>
// <search-overlay>
const initSearch = () => {
if (!document.getElementById('search-overlay')) {
const overlay = document.createElement('div');
overlay.id = 'search-overlay';
overlay.innerHTML = `
<div id="search-close">&times;</div>
<input type="text" id="search-input" placeholder="Search Tags..." autocomplete="off">
`;
document.body.appendChild(overlay);
const input = document.getElementById('search-input');
const close = document.getElementById('search-close');
const btns = document.querySelectorAll('#nav-search-btn, #nav-search-btn-guest');
const toggleSearch = (show) => {
if (show) {
overlay.style.display = 'flex';
// Force reflow
overlay.offsetHeight;
overlay.classList.add('visible');
input.focus();
} else {
overlay.classList.remove('visible');
setTimeout(() => {
overlay.style.display = 'none';
}, 200);
}
};
btns.forEach(btn => btn.addEventListener('click', (e) => {
e.preventDefault();
toggleSearch(true);
}));
close.addEventListener('click', () => toggleSearch(false));
// Close on click outside (background)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) toggleSearch(false);
});
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.classList.contains('visible')) {
toggleSearch(false);
}
// "k" to open
if (e.key === 'k' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && !overlay.classList.contains('visible')) {
e.preventDefault();
toggleSearch(true);
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const val = input.value.trim();
if (val) {
window.location.href = `/tag/${encodeURIComponent(val)}`;
}
}
});
}
};
initSearch();
// </search-overlay>
// </scroller> // </scroller>
})(); })();
// disable default scroll event when mouse is on content div // disable default scroll event when mouse is on content div
// this is useful for items that have a lot of tags for example: 12536 // this is useful for items that have a lot of tags for example: 12536
const targetSelector = '.content'; const targetSelector = '.content';
@@ -541,10 +919,13 @@ function init() {
window.addEventListener('load', init); window.addEventListener('load', init);
document.getElementById('sbtForm').addEventListener('submit', (e) => { const sbtForm = document.getElementById('sbtForm');
e.preventDefault(); if (sbtForm) {
const input = document.getElementById('sbtInput').value.trim(); sbtForm.addEventListener('submit', (e) => {
if (input) { e.preventDefault();
window.location.href = `/tag/${encodeURIComponent(input)}`; const input = document.getElementById('sbtInput').value.trim();
} if (input) {
}); window.location.href = `/tag/${encodeURIComponent(input)}`;
}
});
}

View File

@@ -1,10 +1,10 @@
const Cookie = { const Cookie = {
get: name => { get: name => {
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1]; const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
if(c) return decodeURIComponent(c); if (c) return decodeURIComponent(c);
}, },
set: (name, value, opts = {}) => { set: (name, value, opts = {}) => {
if(opts.days) { if (opts.days) {
opts['max-age'] = opts.days * 60 * 60 * 24; opts['max-age'] = opts.days * 60 * 60 * 24;
delete opts.days; delete opts.days;
} }
@@ -17,8 +17,11 @@ const Cookie = {
(() => { (() => {
const acttheme = Cookie.get('theme') ?? "w0bm"; const acttheme = Cookie.get('theme') ?? "w0bm";
const themecontainer = document.querySelector("li#themes > ul.dropdown-menu"); const themecontainer = document.querySelector("li#themes > ul.dropdown-menu");
if (!themecontainer) return; // Theme menu not present on this page
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase()); const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
if(acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme)) if (acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
document.documentElement.setAttribute("theme", acttheme); document.documentElement.setAttribute("theme", acttheme);
[...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => { [...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => {
e.preventDefault(); e.preventDefault();
@@ -30,15 +33,15 @@ const Cookie = {
})); }));
document.addEventListener("keydown", e => { document.addEventListener("keydown", e => {
if(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
return; return;
const acttheme = Cookie.get('theme') ?? "w0bm"; const acttheme = Cookie.get('theme') ?? "w0bm";
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase()); const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
const k = e.key; const k = e.key;
if(k === "t") { if (k === "t") {
e.preventDefault(); e.preventDefault();
let i = themes.indexOf(acttheme); let i = themes.indexOf(acttheme);
if(++i >= themes.length) if (++i >= themes.length)
i = 0; i = 0;
document.documentElement.setAttribute("theme", themes[i]); document.documentElement.setAttribute("theme", themes[i]);
document.querySelector("#themes > a").setAttribute("content", themes[i]); document.querySelector("#themes > a").setAttribute("content", themes[i]);
@@ -46,10 +49,10 @@ const Cookie = {
} }
}); });
if(tbuttonfull = document.querySelector('svg#a_tfull')) { if (tbuttonfull = document.querySelector('svg#a_tfull')) {
tbuttonfull.addEventListener('click', e => { tbuttonfull.addEventListener('click', e => {
let f = Cookie.get('fullscreen'); let f = Cookie.get('fullscreen');
if(f == 1) { if (f == 1) {
Cookie.set('fullscreen', 0); Cookie.set('fullscreen', 0);
document.querySelector('html').setAttribute('res', ''); document.querySelector('html').setAttribute('res', '');
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`; tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`;

348
public/s/js/upload.js Normal file
View File

@@ -0,0 +1,348 @@
(() => {
const form = document.getElementById('upload-form');
if (!form) return;
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const filePreview = document.getElementById('file-preview');
// Note: prompt is now a label, but accessible via class
const dropZonePrompt = dropZone.querySelector('.drop-zone-prompt');
const fileName = document.getElementById('file-name');
const fileSize = document.getElementById('file-size');
const removeFile = document.getElementById('remove-file');
const tagInput = document.getElementById('tag-input');
const tagsList = document.getElementById('tags-list');
const tagsHidden = document.getElementById('tags-hidden');
const tagCount = document.getElementById('tag-count');
const tagSuggestions = document.getElementById('tag-suggestions');
const submitBtn = document.getElementById('submit-btn');
const progressContainer = document.getElementById('upload-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const statusDiv = document.getElementById('upload-status');
let tags = [];
let selectedFile = null;
// Flash Message Logic
const showFlash = (msg, type = 'success') => {
const existing = document.querySelector('.flash-message');
if (existing) existing.remove();
const flash = document.createElement('div');
flash.className = `flash-message ${type}`;
flash.textContent = msg;
Object.assign(flash.style, {
position: 'fixed',
top: '20px',
left: '50%',
transform: 'translateX(-50%)',
padding: '15px 30px',
borderRadius: '5px',
color: '#fff',
fontWeight: '600',
zIndex: '9999',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
background: type === 'success' ? '#51cf66' : '#ff6b6b',
opacity: '0',
transition: 'opacity 0.3s'
});
document.body.appendChild(flash);
// Fade in
requestAnimationFrame(() => flash.style.opacity = '1');
// Remove after 5s
setTimeout(() => {
flash.style.opacity = '0';
setTimeout(() => flash.remove(), 300);
}, 5000);
};
const formatSize = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return bytes.toFixed(2) + ' ' + units[i];
};
const updateSubmitButton = () => {
const rating = document.querySelector('input[name="rating"]:checked');
const hasFile = selectedFile !== null;
const hasRating = rating !== null;
const hasTags = tags.length >= 3;
submitBtn.disabled = !(hasFile && hasRating && hasTags);
if (!hasTags) {
submitBtn.querySelector('.btn-text').textContent = (3 - tags.length) + ' more tag' + (3 - tags.length !== 1 ? 's' : '') + ' required';
} else if (!hasFile) {
submitBtn.querySelector('.btn-text').textContent = 'Select a file';
} else if (!hasRating) {
submitBtn.querySelector('.btn-text').textContent = 'Select SFW or NSFW';
} else {
submitBtn.querySelector('.btn-text').textContent = 'Upload';
}
tagCount.textContent = '(' + tags.length + '/3 minimum)';
tagCount.classList.toggle('valid', tags.length >= 3);
};
const handleFile = (file) => {
if (!file) return;
const validTypes = ['video/mp4', 'video/webm'];
// Check extensions as fallback
const ext = file.name.split('.').pop().toLowerCase();
const validExts = ['mp4', 'webm'];
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
statusDiv.textContent = 'Only mp4 and webm files are allowed';
statusDiv.className = 'upload-status error';
return;
}
selectedFile = file;
fileName.textContent = file.name;
fileSize.textContent = formatSize(file.size);
dropZonePrompt.style.display = 'none';
filePreview.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
// Video Preview
const itemPreview = filePreview.querySelector('.item-preview') || document.createElement('div');
itemPreview.className = 'item-preview';
itemPreview.style.marginRight = '15px';
// Clear previous
const existingVid = filePreview.querySelector('video');
if (existingVid) existingVid.remove();
const vid = document.createElement('video');
vid.src = URL.createObjectURL(file);
vid.controls = false;
vid.autoplay = true;
vid.muted = true;
vid.loop = true;
vid.style.maxHeight = '100px';
vid.style.maxWidth = '150px';
vid.style.borderRadius = '4px';
filePreview.prepend(vid);
updateSubmitButton();
};
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
// Attach drag events only to dropZone now (Input is hidden)
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFile(files[0]);
});
// Native change listener on hidden input
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
removeFile.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
selectedFile = null;
fileInput.value = '';
dropZonePrompt.style.display = 'block';
filePreview.style.display = 'none';
// Clear preview video
const vid = filePreview.querySelector('video');
if (vid) vid.remove();
updateSubmitButton();
});
const addTag = (tagName) => {
tagName = tagName.trim().toLowerCase();
if (!tagName || tags.includes(tagName)) return;
if (tagName === 'sfw' || tagName === 'nsfw') return;
tags.push(tagName);
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = tagName + '<button type="button">&times;</button>';
chip.querySelector('button').addEventListener('click', () => {
tags = tags.filter(t => t !== tagName);
chip.remove();
updateSubmitButton();
});
tagsList.appendChild(chip);
tagsHidden.value = tags.join(',');
tagInput.value = '';
tagSuggestions.innerHTML = '';
tagSuggestions.classList.remove('show');
updateSubmitButton();
};
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagInput.value);
}
});
let debounceTimer;
tagInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = tagInput.value.trim();
if (query.length < 2) {
tagSuggestions.classList.remove('show');
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch('/api/v2/admin/tags/suggest?q=' + encodeURIComponent(query));
const data = await res.json();
if (data.success && data.suggestions && data.suggestions.length > 0) {
const filtered = data.suggestions.filter(s => !tags.includes(s.tag.toLowerCase()));
let html = '';
for (let i = 0; i < Math.min(8, filtered.length); i++) {
html += '<div class="tag-suggestion">' + filtered[i].tag + '</div>';
}
tagSuggestions.innerHTML = html;
tagSuggestions.classList.add('show');
tagSuggestions.querySelectorAll('.tag-suggestion').forEach(el => {
el.addEventListener('click', () => addTag(el.textContent));
});
} else {
tagSuggestions.classList.remove('show');
}
} catch (err) {
console.error(err);
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!tagInput.contains(e.target) && !tagSuggestions.contains(e.target)) {
tagSuggestions.classList.remove('show');
}
});
document.querySelectorAll('input[name="rating"]').forEach(radio => {
radio.addEventListener('change', updateSubmitButton);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile || tags.length < 3) return;
const rating = document.querySelector('input[name="rating"]:checked');
if (!rating) return;
submitBtn.disabled = true;
submitBtn.querySelector('.btn-text').style.display = 'none';
submitBtn.querySelector('.btn-loading').style.display = 'inline';
progressContainer.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('rating', rating.value);
formData.append('tags', tags.join(','));
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
}
});
xhr.onload = () => {
const res = JSON.parse(xhr.responseText);
if (res.success) {
statusDiv.innerHTML = '✓ ' + res.msg;
statusDiv.className = 'upload-status success';
// Flash Message
showFlash(res.msg, 'success');
form.reset();
tags = [];
tagsList.innerHTML = '';
selectedFile = null;
dropZonePrompt.style.display = 'block'; // label is actually flex/block via CSS
filePreview.style.display = 'none';
const vid = filePreview.querySelector('video');
if (vid) vid.remove();
} else {
statusDiv.textContent = '✕ ' + res.msg;
statusDiv.className = 'upload-status error';
if (res.repost) {
statusDiv.innerHTML += ' <a href="/' + res.repost + '">View existing</a>';
}
showFlash('Upload failed: ' + res.msg, 'error');
}
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
updateSubmitButton();
};
xhr.onerror = () => {
statusDiv.textContent = '✕ Upload failed. Please try again.';
statusDiv.className = 'upload-status error';
showFlash('Upload failed network error', 'error');
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
updateSubmitButton();
};
xhr.open('POST', '/api/v2/upload');
xhr.send(formData);
} catch (err) {
console.error(err);
statusDiv.textContent = '✕ Upload failed: ' + err.message;
statusDiv.className = 'upload-status error';
showFlash('Upload failed: ' + err.message, 'error');
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
updateSubmitButton();
}
});
updateSubmitButton();
})();

View File

@@ -8,12 +8,12 @@ const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
export default { export default {
getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => { getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = o.mime ?? null; const mime = o.mime ?? null;
const page = +(o.page ?? 1); const page = +(o.page ?? 1);
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%"; const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
const eps = o.limit ?? cfg.websrv.eps; const eps = o.limit ?? cfg.websrv.eps;
const tmp = { user, tag, mime, smime, page, mode: o.mode }; const tmp = { user, tag, mime, smime, page, mode: o.mode };
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0); const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
@@ -27,17 +27,17 @@ export default {
left join favorites on favorites.item_id = items.id left join favorites on favorites.item_id = items.id
left join "user" on "user".id = favorites.user_id left join "user" on "user".id = favorites.user_id
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag group by items.id, tags.tag
`)?.length || 0; `)?.length || 0;
if(!total || total === 0) { if (!total || total === 0) {
return { return {
success: false, success: false,
message: "404 - no f0cks given" message: "404 - no f0cks given"
@@ -61,13 +61,13 @@ export default {
left join "user" on "user".id = favorites.user_id left join "user" on "user".id = favorites.user_id
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2) left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
offset ${offset} offset ${offset}
@@ -75,7 +75,7 @@ export default {
`; `;
const cheat = []; const cheat = [];
for(let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++) for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i); cheat.push(i);
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' }); const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' });
@@ -96,17 +96,17 @@ export default {
}; };
}, },
getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => { getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = (o.mime ?? ""); const mime = (o.mime ?? "");
const itemid = +(o.itemid ?? 404); const itemid = +(o.itemid ?? 404);
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%"; const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
const tmp = { user, tag, mime, smime, itemid }; const tmp = { user, tag, mime, smime, itemid };
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0); const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
if(itemid === 404) { if (itemid === 404) {
return { return {
success: false, success: false,
message: "404 - f0ck not found" message: "404 - f0ck not found"
@@ -119,25 +119,32 @@ export default {
from items from items
left join tags_assign on tags_assign.item_id = items.id left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id left join tags on tags.id = tags_assign.tag_id
left join favorites on favorites.item_id = items.id ${o.fav
left join "user" on "user".id = favorites.user_id ? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
}
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2) left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
where where
${ db.unsafe(modequery) } ${db.unsafe(modequery)}
and items.active = 'true' and items.active = 'true'
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` } ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` } ${o.fav ? db`and "user"."user" ilike ${user}` : db``}
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` } ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${ mime ? db`and items.mime ilike ${smime}` : db`` } ${mime ? db`and items.mime ilike ${smime}` : db``}
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` } ${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
`; `;
console.log('[GETF0CK DEBUG] Query params:', { user, itemid, fav: o.fav });
console.log('[GETF0CK DEBUG] Items found:', items.length, 'Item IDs:', items.slice(0, 10).map(i => i.id));
const item = items.findIndex(i => i.id === itemid); const item = items.findIndex(i => i.id === itemid);
const actitem = items[item]; const actitem = items[item];
if(!actitem) { // sfw-check! console.log('[GETF0CK DEBUG] findIndex result:', item, 'actitem exists:', !!actitem);
if (!actitem) { // sfw-check!
return { return {
success: false, success: false,
message: "Sorry, this post is currently not visible." message: "Sorry, this post is currently not visible."
@@ -158,7 +165,7 @@ export default {
let coverart = true; let coverart = true;
try { try {
await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`); await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`);
} catch(err) { } catch (err) {
coverart = false; coverart = false;
} }
@@ -201,7 +208,7 @@ export default {
tmp tmp
}; };
return data; return data;
},getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => { }, getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => {
const user = o.user ? decodeURI(o.user) : null; const user = o.user ? decodeURI(o.user) : null;
const tag = lib.parseTag(o.tag ?? null); const tag = lib.parseTag(o.tag ?? null);
const mime = (o.mime ?? ""); const mime = (o.mime ?? "");
@@ -219,10 +226,15 @@ export default {
from favorites from favorites
inner join items on favorites.item_id = items.id inner join items on favorites.item_id = items.id
inner join "user" on "user".id = favorites.user_id inner join "user" on "user".id = favorites.user_id
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where where
"user".user ilike ${'%' + user + '%'} ${db.unsafe(modequery)}
and "user".user ilike ${'%' + user + '%'}
and items.active = 'true' and items.active = 'true'
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id
order by random() order by random()
limit 1 limit 1
`; `;
@@ -262,4 +274,4 @@ export default {
itemid: item[0].id itemid: item[0].id
}; };
} }
}; };

View File

@@ -5,7 +5,7 @@ import { promises as fs } from "fs";
export default (router, tpl) => { export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => { router.get(/^\/login(\/)?$/, async (req, res) => {
if(req.cookies.session) { if (req.cookies.session) {
return res.reply({ return res.reply({
body: tpl.render('error', { body: tpl.render('error', {
message: "you're already logged in lol", message: "you're already logged in lol",
@@ -25,9 +25,9 @@ export default (router, tpl) => {
where "login" = ${req.post.username.toLowerCase()} where "login" = ${req.post.username.toLowerCase()}
limit 1 limit 1
`; `;
if(user.length === 0) if (user.length === 0)
return res.reply({ body: "user doesn't exist or wrong password" }); return res.reply({ body: "user doesn't exist or wrong password" });
if(!(await lib.verify(req.post.password, user[0].password))) if (!(await lib.verify(req.post.password, user[0].password)))
return res.reply({ body: "user doesn't exist or wrong password" }); return res.reply({ body: "user doesn't exist or wrong password" });
const stamp = ~~(Date.now() / 1e3); const stamp = ~~(Date.now() / 1e3);
@@ -49,8 +49,7 @@ export default (router, tpl) => {
}; };
await db` await db`
insert into "user_sessions" ${ insert into "user_sessions" ${db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
} }
`; `;
@@ -67,7 +66,7 @@ export default (router, tpl) => {
from "user_sessions" from "user_sessions"
where id = ${+req.session.sess_id} where id = ${+req.session.sess_id}
`; `;
if(usersession.length === 0) if (usersession.length === 0)
return res.reply({ body: "nope 2" }); return res.reply({ body: "nope 2" });
await db` await db`
@@ -121,79 +120,176 @@ export default (router, tpl) => {
}); });
}); });
// router.get(/^\/admin\/log(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/approve\/?/, lib.auth, async (req, res) => {
// // Funktioniert ohne systemd service natürlich nicht. if (req.url.qs?.id) {
// exec("journalctl -qeu f0ck --no-pager", (err, stdout) => { const id = +req.url.qs.id;
// res.reply({ const f0ck = await db`
// body: tpl.render("admin/log", { select dest, mime
// log: stdout.split("\n").slice(0, -1), from "items"
// tmp: null where
// }, req) id = ${id} and
// }); active = 'false'
// }); limit 1
// }); `;
if (f0ck.length === 0) {
return res.reply({
body: `f0ck ${id}: f0ck not found`
});
}
// router.get(/^\/admin\/recover\/?/, lib.auth, async (req, res) => { await db`update "items" set active = 'true' where id = ${id}`;
// Gelöschte Objekte werden nicht aufgehoben.
// if(req.url.qs?.id) {
// const id = +req.url.qs.id;
// const f0ck = await db`
// select dest, mime
// from "items"
// where
// id = ${id} and
// active = 'false'
// limit 1
// `;
// if(f0ck.length === 0) {
// return res.reply({
// body: `f0ck ${id}: f0ck not found`
// });
// }
// await db`update "items" set active = 'true' where id = ${id}`; // Check if files need moving (if they are in deleted/)
try {
await fs.access(`./public/b/${f0ck[0].dest}`);
// Exists in public, good (new upload)
} catch {
// Not in public, likely a deleted item being recovered
await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_ => { });
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.unlink(`./deleted/t/${id}.webp`).catch(_ => { });
// await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_=>{}); if (f0ck[0].mime.startsWith('audio')) {
// await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_=>{}); await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_=>{}); await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/t/${id}.webp`).catch(_=>{}); }
}
// if(f0ck[0].mime.startsWith('audio')) { return res.writeHead(302, {
// await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{}); "Location": `/${id}`
// await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{}); }).end();
// } }
// return res.reply({ const page = +req.url.qs.page || 1;
// body: `f0ck ${id} recovered. <a href="/admin/recover">back</a>` const limit = 50;
// }); const offset = (page - 1) * limit;
// }
// const _posts = await db` const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c;
// select id, mime, username const pages = Math.ceil(total / limit);
// from "items"
// where
// active = 'false'
// order by id desc
// `;
// if(_posts.length === 0) { const _posts = await db`
// return res.reply({ select id, mime, username, dest
// body: 'blah' from "items"
// }); where
// } active = 'false'
order by id desc
limit ${limit} offset ${offset}
`;
// const posts = await Promise.all(_posts.map(async p => ({ if (_posts.length === 0 && page > 1) {
// ...p, // if page empty, maybe redirect to last page or page 1?
// thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64') // Just render empty for now
// }))); }
// res.reply({ if (_posts.length === 0) {
// body: tpl.render('admin/recover', { return res.reply({
// posts, body: tpl.render('admin/approve', { posts: [], pages: 0, page: 1, tmp: null }, req)
// tmp: null });
// }, req) }
// });
// }); const posts = await Promise.all(_posts.map(async p => {
// Try to get thumbnail from public or deleted
let thumb;
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?
}
}
return {
...p,
thumbnail: thumb
};
}));
res.reply({
body: tpl.render('admin/approve', {
posts,
page,
pages,
stats: { total: posts.length },
tmp: null
}, req)
});
});
const deleteItem = async (id) => {
const f0ck = await db`
select dest, mime
from "items"
where
id = ${id}
limit 1
`;
if (f0ck.length > 0) {
console.log(`[ADMIN DENY] Found item, deleting files: ${f0ck[0].dest}`);
// Delete files
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(e => console.log('File error pub/b:', e.message));
await fs.unlink(`./public/t/${id}.webp`).catch(e => console.log('File error pub/t:', e.message));
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(e => console.log('File error del/b:', e.message));
await fs.unlink(`./deleted/t/${id}.webp`).catch(e => console.log('File error del/t:', e.message));
if (f0ck[0].mime.startsWith('audio')) {
await fs.unlink(`./public/ca/${id}.webp`).catch(() => { });
await fs.unlink(`./deleted/ca/${id}.webp`).catch(() => { });
}
// Delete DB entries
console.log('[ADMIN DENY] Deleting DB entries...');
try {
await db`delete from "tags_assign" where item_id = ${id}`;
await db`delete from "favorites" where item_id = ${id}`;
await db`delete from "comments" where item_id = ${id}`.catch(() => { });
await db`delete from "items" where id = ${id}`;
console.log('[ADMIN DENY] Deleted successfully');
return true;
} catch (dbErr) {
console.error('[ADMIN DENY DB ERROR]', dbErr);
return false;
}
} else {
console.log('[ADMIN DENY] Item not found in DB');
return false;
}
};
router.get(/^\/admin\/deny\/?/, lib.auth, async (req, res) => {
console.log('[ADMIN DENY] Logs initiated');
if (req.url.qs?.id) {
const id = +req.url.qs.id;
console.log(`[ADMIN DENY] Denying ID: ${id}`);
await deleteItem(id);
return res.writeHead(302, {
"Location": `/admin/approve`
}).end();
}
console.log('[ADMIN DENY] No ID provided');
return res.writeHead(302, { "Location": "/admin/approve" }).end();
});
router.post(/^\/admin\/deny-multi\/?/, lib.auth, async (req, res) => {
try {
const ids = req.post.ids;
if (!Array.isArray(ids)) throw new Error('ids must be an array');
console.log(`[ADMIN DENY MULTI] Denying ${ids.length} items`);
for (const id of ids) {
await deleteItem(+id);
}
return res.reply({ success: true });
} catch (err) {
console.error('[ADMIN DENY MULTI ERROR]', err);
return res.reply({ success: false, msg: err.message }, 500);
}
});
return router; return router;
}; };

View File

@@ -14,7 +14,13 @@ export default (router, tpl) => {
let contextUrl = `/${req.params.itemid}`; let contextUrl = `/${req.params.itemid}`;
if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`; if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`;
if (query.user) contextUrl = `/user/${query.user}/${req.params.itemid}`; // User filter takes precedence if both? usually mutually exclusive if (query.user) {
contextUrl = query.fav === 'true'
? `/user/${query.user}/favs/${req.params.itemid}`
: `/user/${query.user}/${req.params.itemid}`;
}
console.log('[AJAX DEBUG] Params:', { itemid: req.params.itemid, user: query.user, fav: query.fav, contextUrl });
const data = await f0cklib.getf0ck({ const data = await f0cklib.getf0ck({
itemid: req.params.itemid, itemid: req.params.itemid,
@@ -23,9 +29,12 @@ export default (router, tpl) => {
url: contextUrl, url: contextUrl,
user: query.user, user: query.user,
tag: query.tag, tag: query.tag,
mime: query.mime mime: query.mime,
fav: query.fav === 'true'
}); });
console.log('[AJAX DEBUG] getf0ck result:', { success: data.success, message: data.message });
if (!data.success) { if (!data.success) {
return res.reply({ return res.reply({
code: 404, code: 404,
@@ -38,10 +47,8 @@ export default (router, tpl) => {
if (req.session) { if (req.session) {
data.session = { ...req.session }; data.session = { ...req.session };
// data.user comes from f0cklib (uploader). req.session.user is logged-in user string. // data.user comes from f0cklib (uploader). req.session.user is logged-in user string.
// If template engine confuses them, removing session.user from this context might help. // Templates use session.user for matching favorites. We must preserve it.
// item-partial doesn't use session.user. // if (data.session.user) delete data.session.user; // REMOVED THIS
// Note: If anything fails, it prints literal code, so we ensure no collision.
if (data.session.user) delete data.session.user;
} else { } else {
data.session = false; data.session = false;
} }
@@ -64,5 +71,65 @@ export default (router, tpl) => {
}); });
}); });
// Infinite scroll endpoint for index thumbnails
router.get(/\/ajax\/items/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = parseInt(query.page) || 1;
const data = await f0cklib.getf0cks({
page: page,
tag: query.tag || null,
user: query.user || null,
mime: query.mime || null,
mode: req.session.mode,
session: !!req.session,
fav: false
});
if (!data.success) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
html: '',
hasMore: false
})
});
}
// Render just the thumbnail items
const itemsHtml = tpl.render('snippets/items-grid', {
items: data.items,
link: data.link
});
// Render pagination
const paginationHtml = tpl.render('snippets/pagination', {
pagination: data.pagination,
link: data.link
});
const hasMore = data.pagination.next !== null;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
html: itemsHtml,
pagination: paginationHtml,
hasMore: hasMore,
nextPage: data.pagination.next,
currentPage: data.pagination.page
})
});
});
return router; return router;
}; };

View File

@@ -1,9 +1,12 @@
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import search from '../../routeinc/search.mjs'; import search from '../../routeinc/search.mjs';
const allowedMimes = [ "audio", "image", "video", "%" ]; const allowedMimes = ["audio", "image", "video", "%"];
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
export default router => { export default router => {
router.group(/^\/api\/v2/, group => { router.group(/^\/api\/v2/, group => {
group.get(/$/, (req, res) => { group.get(/$/, (req, res) => {
@@ -11,16 +14,33 @@ export default router => {
}); });
group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => { group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => {
const user = req.url.split[3] === "user" ? req.url.split[4] : "%"; const pathUser = req.url.split[3] === "user" ? req.url.split[4] : null;
const mime = (allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "") + "%"; const user = req.url.qs.user || pathUser || "%";
const pathMime = allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "";
const mime = (req.url.qs.mime || pathMime) + "%";
const tag = req.url.qs.tag || null;
const isFav = req.url.qs.fav === 'true';
const hasSession = !!req.session;
const modequery = mime.startsWith("audio") ? lib.getMode(0) : lib.getMode(req.session?.mode ?? 0);
const rows = await db` const rows = await db`
select * select "items".*
from "items" from "items"
${isFav
? db`join "favorites" on "favorites".item_id = "items".id join "user" as fu on fu.id = "favorites".user_id`
: db``
}
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
where where
${db.unsafe(modequery)} and
mime ilike ${mime} and mime ilike ${mime} and
username ilike ${user} and
active = 'true' active = 'true'
${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`}
${tag ? db`and tags.normalized ilike ${'%' + tag + '%'}` : db``}
${!hasSession && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
order by random() order by random()
limit 1 limit 1
`; `;
@@ -51,17 +71,15 @@ export default router => {
where where
${db.unsafe(modequery)} and ${db.unsafe(modequery)} and
active = 'true' active = 'true'
${ ${opt.older
opt.older ? db`and id <= ${opt.older}`
? db`and id <= ${opt.older}` : opt.newer
: opt.newer ? db`and id >= ${opt.newer}`
? db`and id >= ${opt.newer}` : db``
: db`` }
} order by id ${opt.newer
order by id ${ ? db`asc`
opt.newer : db`desc`
? db`asc`
: db`desc`
} }
limit ${eps} limit ${eps}
`).sort((a, b) => b.id - a.id); `).sort((a, b) => b.id - a.id);
@@ -98,7 +116,7 @@ export default router => {
limit 1 limit 1
`; `;
if(item.length === 0) { if (item.length === 0) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no items found' msg: 'no items found'
@@ -140,7 +158,7 @@ export default router => {
// tags lol // tags lol
group.put(/\/admin\/tags\/(?<tagname>.*)/, lib.loggedin, async (req, res) => { group.put(/\/admin\/tags\/(?<tagname>.*)/, lib.loggedin, async (req, res) => {
if(!req.params.tagname || !req.post.newtag) { if (!req.params.tagname || !req.post.newtag) {
return res.json({ return res.json({
success: false, success: false,
msg: 'missing tagname or newtag', msg: 'missing tagname or newtag',
@@ -154,7 +172,7 @@ export default router => {
const tagname = decodeURIComponent(req.params.tagname); const tagname = decodeURIComponent(req.params.tagname);
const newtag = req.post.newtag; const newtag = req.post.newtag;
if(['sfw', 'nsfw'].includes(tagname) || ['sfw', 'nsfw'].includes(newtag)) { if (['sfw', 'nsfw'].includes(tagname) || ['sfw', 'nsfw'].includes(newtag)) {
return res.json({ return res.json({
msg: 'f0ck you' msg: 'f0ck you'
}, 405); // method not allowed }, 405); // method not allowed
@@ -167,7 +185,7 @@ export default router => {
limit 1 limit 1
`)[0]; `)[0];
if(!tmptag) { if (!tmptag) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no tag found' msg: 'no tag found'
@@ -175,10 +193,9 @@ export default router => {
} }
const q = (await db` const q = (await db`
update "tags" set ${ update "tags" set ${db({
db({ tag: newtag
tag: newtag }, 'tag')
}, 'tag')
} }
where tag = ${tagname} where tag = ${tagname}
returning * returning *
@@ -195,7 +212,7 @@ export default router => {
const searchString = req.url.qs.q; const searchString = req.url.qs.q;
if(searchString?.length <= 1) { if (searchString?.length <= 1) {
reply.error = 'too short lol'; reply.error = 'too short lol';
return res.json(reply); return res.json(reply);
} }
@@ -212,7 +229,7 @@ export default router => {
`; `;
reply.success = true; reply.success = true;
reply.suggestions = search(q, searchString); reply.suggestions = search(q, searchString);
} catch(err) { } catch (err) {
reply.error = err.msg; reply.error = err.msg;
} }
@@ -220,7 +237,7 @@ export default router => {
}); });
group.post(/\/admin\/deletepost$/, lib.auth, async (req, res) => { group.post(/\/admin\/deletepost$/, lib.auth, async (req, res) => {
if(!req.post.postid) { if (!req.post.postid) {
return res.json({ return res.json({
success: false, success: false,
msg: 'no postid' msg: 'no postid'
@@ -228,7 +245,7 @@ export default router => {
} }
const id = +req.post.postid; const id = +req.post.postid;
if(id <= 1) { if (id <= 1) {
return res.json({ return res.json({
success: false success: false
}); });
@@ -243,7 +260,7 @@ export default router => {
limit 1 limit 1
`; `;
if(f0ck.length === 0) { if (f0ck.length === 0) {
return res.json({ return res.json({
success: false, success: false,
msg: `f0ck ${id}: f0ck not found` msg: `f0ck ${id}: f0ck not found`
@@ -252,14 +269,14 @@ export default router => {
await db`update "items" set active = 'false' where id = ${id}`; await db`update "items" set active = 'false' where id = ${id}`;
await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_=>{}); 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(_=>{}); await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_=>{}); await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_ => { });
await fs.unlink(`./public/t/${id}.webp`).catch(_=>{}); await fs.unlink(`./public/t/${id}.webp`).catch(_ => { });
if(f0ck[0].mime.startsWith('audio')) { if (f0ck[0].mime.startsWith('audio')) {
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{}); await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { });
await fs.unlink(`./public/ca/${id}.webp`).catch(_=>{}); await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { });
} }
res.json({ res.json({
@@ -276,7 +293,7 @@ export default router => {
where item_id = ${+postid} where item_id = ${+postid}
`; `;
if(Object.values(favs).filter(u => u.user_id === req.session.id)[0]) { if (Object.values(favs).filter(u => u.user_id === req.session.id)[0]) {
// del fav // del fav
await db` await db`
delete from "favorites" delete from "favorites"
@@ -287,11 +304,10 @@ export default router => {
else { else {
// add fav // add fav
await db` await db`
insert into "favorites" ${ insert into "favorites" ${db({
db({ item_id: +postid,
item_id: +postid, user_id: +req.session.id
user_id: +req.session.id }, 'item_id', 'user_id')
}, 'item_id', 'user_id')
} }
`; `;
} }

View File

@@ -0,0 +1,260 @@
import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import queue from '../../queue.mjs';
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
console.log('[UPLOAD DEBUG] collectBody started');
const chunks = [];
req.on('data', chunk => {
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
chunks.push(chunk);
});
req.on('end', () => {
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
resolve(Buffer.concat(chunks));
});
req.on('error', err => {
console.error('[UPLOAD DEBUG] Stream error:', err);
reject(err);
});
// Ensure stream is flowing
if (req.isPaused()) {
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
req.resume();
}
});
};
export default router => {
router.group(/^\/api\/v2/, group => {
group.post(/\/upload$/, lib.loggedin, async (req, res) => {
try {
console.log('[UPLOAD DEBUG] Request received');
// Use stored content type if available (from middleware bypass), otherwise use header
const contentType = req._multipartContentType || req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[UPLOAD DEBUG] No boundary found');
return res.json({ success: false, msg: 'Invalid content type' }, 400);
}
let body;
if (req.bodyPromise) {
console.log('[UPLOAD DEBUG] Waiting for buffered body from middleware promise...');
body = await req.bodyPromise;
console.log('[UPLOAD DEBUG] Received body from promise');
} else if (req.rawBody) {
console.log('[UPLOAD DEBUG] Using buffered body from middleware');
body = req.rawBody;
} else {
console.log('[UPLOAD DEBUG] Collecting body via collectBody...');
body = await collectBody(req);
}
if (!body) {
return res.json({ success: false, msg: 'Failed to receive file body' }, 400);
}
console.log('[UPLOAD DEBUG] Body size:', body.length);
const parts = parseMultipart(body, boundaryMatch[1]);
console.log('[UPLOAD DEBUG] Parsed parts:', Object.keys(parts));
// Validate required fields
const file = parts.file;
const rating = parts.rating; // 'sfw' or 'nsfw'
const tagsRaw = parts.tags; // comma-separated tags
if (!file || !file.data) {
return res.json({ success: false, msg: 'No file provided' }, 400);
}
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
return res.json({ success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
if (tags.length < 3) {
return res.json({ success: false, msg: 'At least 3 tags are required' }, 400);
}
// Validate MIME type
const allowedMimes = ['video/mp4', 'video/webm'];
let mime = file.contentType;
if (!allowedMimes.includes(mime)) {
return res.json({ success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
}
// Validate file size
const maxfilesize = cfg.main.maxfilesize;
const size = file.data.length;
if (size > maxfilesize) {
return res.json({
success: false,
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
}, 400);
}
// Generate UUID for filename
const uuid = await queue.genuuid();
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
const filename = `${uuid}.${ext}`;
const tmpPath = `./tmp/${filename}`;
const destPath = `./public/b/${filename}`;
// Save file temporarily
await fs.writeFile(tmpPath, file.data);
// Verify MIME with file command
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return res.json({ success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
// Generate checksum
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
// Check for repost
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(tmpPath).catch(() => { });
return res.json({
success: false,
msg: `This file already exists`,
repost: repost
}, 409);
}
// Move to public folder
await fs.copyFile(tmpPath, destPath);
await fs.unlink(tmpPath).catch(() => { });
// Insert into database (active=false for admin approval)
await db`
insert into items ${db({
src: '',
dest: filename,
mime: actualMime,
size: size,
checksum: checksum,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: false
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
}
`;
// Get the new item ID
const itemid = await queue.getItemID(filename);
// Generate thumbnail
try {
await queue.genThumbnail(filename, actualMime, itemid, '');
} catch (err) {
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
// Assign rating tag (sfw=1, nsfw=2)
const ratingTagId = rating === 'sfw' ? 1 : 2;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
// Assign user tags
for (const tagName of tags) {
// Check if tag exists, create if not
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
// Create new tag
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
// Assign tag to item
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
return res.json({
success: true,
msg: 'Upload successful! Your upload is pending admin approval.',
itemid: itemid
});
} catch (err) {
console.error('[UPLOAD ERROR]', err);
return res.json({ success: false, msg: 'Upload failed: ' + err.message }, 500);
}
});
});
return router;
};

View File

@@ -0,0 +1,56 @@
import crypto from 'crypto';
export default (router, tpl) => {
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
const tag = decodeURIComponent(req.params.tag);
// Create a deterministic hash from the tag
const hash = crypto.createHash('md5').update(tag).digest('hex');
// Escape character for SVG
const escapeXml = (unsafe) => {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case '\'': return '&apos;';
case '"': return '&quot;';
}
});
};
const displayTag = escapeXml(tag);
// Generate colors from hash
const c1 = '#' + hash.substring(0, 6);
const c2 = '#' + hash.substring(6, 12);
const c3 = '#' + hash.substring(12, 18);
// Generate some deterministic numbers for shapes
const n1 = parseInt(hash.substring(18, 20), 16);
const n2 = parseInt(hash.substring(20, 22), 16);
const svg = `
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="300" height="150" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
</svg>
`.trim();
res.writeHead(200, {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400'
});
res.end(svg);
});
return router;
};

View File

@@ -4,8 +4,10 @@ import lib from "./inc/lib.mjs";
import cuffeo from "cuffeo"; import cuffeo from "cuffeo";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import flummpress from "flummpress"; import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
process.on('unhandledRejection', err => { process.on('unhandledRejection', err => {
if (err.code === 'ERR_HTTP_HEADERS_SENT') return;
console.error(err); console.error(err);
throw err; throw err;
}); });
@@ -19,7 +21,7 @@ process.on('unhandledRejection', err => {
this.level = args.level || 0; this.level = args.level || 0;
this.name = args.name; this.name = args.name;
this.active = args.hasOwnProperty("active") ? args.active : true; this.active = args.hasOwnProperty("active") ? args.active : true;
this.clients = args.clients || [ "irc", "tg", "slack" ]; this.clients = args.clients || ["irc", "tg", "slack"];
this.f = args.f; this.f = args.f;
}, },
bot: await new cuffeo(cfg.clients) bot: await new cuffeo(cfg.clients)
@@ -27,7 +29,7 @@ process.on('unhandledRejection', err => {
console.time("loading"); console.time("loading");
const modules = { const modules = {
events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")), events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")),
trigger: (await fs.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs")) trigger: (await fs.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs"))
}; };
@@ -41,7 +43,7 @@ process.on('unhandledRejection', err => {
console.timeLog("loading", `${dir}/${mod}`); console.timeLog("loading", `${dir}/${mod}`);
return res; return res;
}))).flat(2) }))).flat(2)
})))).reduce((a, b) => ({...a, ...b})); })))).reduce((a, b) => ({ ...a, ...b }));
blah.events.forEach(event => { blah.events.forEach(event => {
console.timeLog("loading", `registering event > ${event.name}`); console.timeLog("loading", `registering event > ${event.name}`);
@@ -61,15 +63,16 @@ process.on('unhandledRejection', err => {
const router = app.router; const router = app.router;
const tpl = app.tpl; const tpl = app.tpl;
app.use(async (req, res) => { app.use(async (req, res) => {
// sessionhandler // sessionhandler
req.session = false; req.session = false;
if(req.url.pathname.match(/^\/(s|b|t|ca)\//)) if (req.url.pathname.match(/^\/(s|b|t|ca)\//))
return; return;
req.theme = req.cookies.theme || 'amoled'; req.theme = req.cookies.theme || 'amoled';
req.fullscreen = req.cookies.fullscreen || 0; req.fullscreen = req.cookies.fullscreen || 0;
if(req.cookies.session) { if (req.cookies.session) {
const user = await db` const user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".* select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
from "user_sessions" from "user_sessions"
@@ -79,7 +82,7 @@ process.on('unhandledRejection', err => {
limit 1 limit 1
`; `;
if(user.length === 0) { if (user.length === 0) {
return res.writeHead(307, { // delete session return res.writeHead(307, { // delete session
"Cache-Control": "no-cache, public", "Cache-Control": "no-cache, public",
"Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT", "Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
@@ -91,12 +94,11 @@ process.on('unhandledRejection', err => {
// log last action // log last action
await db` await db`
update "user_sessions" set ${ update "user_sessions" set ${db({
db({ last_used: ~~(Date.now() / 1e3),
last_used: ~~(Date.now() / 1e3), last_action: req.url.pathname,
last_action: req.url.pathname, browser: req.headers['user-agent']
browser: req.headers['user-agent'] }, 'last_used', 'last_action', 'browser')
}, 'last_used', 'last_action', 'browser')
} }
where id = ${+user[0].sess_id} where id = ${+user[0].sess_id}
`; `;
@@ -106,13 +108,12 @@ process.on('unhandledRejection', err => {
// update userprofile // update userprofile
await db` await db`
insert into "user_options" ${ insert into "user_options" ${db({
db({ user_id: +user[0].id,
user_id: +user[0].id, mode: user[0].mode ?? 0,
mode: user[0].mode ?? 0, theme: req.session.theme ?? 'amoled',
theme: req.session.theme ?? 'amoled', fullscreen: req.session.fullscreen || 0
fullscreen: req.session.fullscreen || 0 }, 'user_id', 'mode', 'theme', 'fullscreen')
}, 'user_id', 'mode', 'theme', 'fullscreen')
} }
on conflict ("user_id") do update set on conflict ("user_id") do update set
mode = excluded.mode, mode = excluded.mode,
@@ -123,6 +124,15 @@ process.on('unhandledRejection', err => {
} }
}); });
// Bypass middleware for direct upload handling
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/api/v2/upload') {
await handleUpload(req, res);
// Modify URL to prevent router matching and double execution
req.url.pathname = '/handled_upload_bypass';
}
});
tpl.views = "views"; tpl.views = "views";
tpl.debug = true; tpl.debug = true;
tpl.cache = false; tpl.cache = false;

254
src/upload_handler.mjs Normal file
View File

@@ -0,0 +1,254 @@
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
// Collect request body as buffer
const collectBody = (req) => {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
// Ensure stream flows
if (req.isPaused()) req.resume();
});
};
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
export const handleUpload = async (req, res) => {
console.log('[UPLOAD HANDLER] Started');
// Manual Session Lookup (because flummpress middleware might not have finished)
// We assume req.cookies is populated by framework or we need to parse it?
// index.mjs accesses req.cookies directly, so we assume it works.
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.md5(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
console.log('[UPLOAD HANDLER] Unauthorized - No valid session found');
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
// Mock req.session for consistency if needed by other logic, though we use 'user[0]' here
req.session = user[0];
console.log('[UPLOAD HANDLER] Authorized:', req.session.user);
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[UPLOAD HANDLER] No boundary');
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
console.log('[UPLOAD HANDLER] Collecting body...');
const body = await collectBody(req);
console.log('[UPLOAD HANDLER] Body collected, size:', body.length);
const parts = parseMultipart(body, boundaryMatch[1]);
// Validate required fields
const file = parts.file;
const rating = parts.rating;
const tagsRaw = parts.tags;
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
if (tags.length < 3) {
return sendJson(res, { success: false, msg: 'At least 3 tags are required' }, 400);
}
// Validate MIME type
const allowedMimes = ['video/mp4', 'video/webm'];
let mime = file.contentType;
if (!allowedMimes.includes(mime)) {
return sendJson(res, { success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
}
// Validate file size
const maxfilesize = cfg.main.maxfilesize;
const size = file.data.length;
if (size > maxfilesize) {
return sendJson(res, {
success: false,
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
}, 400);
}
// Generate UUID
const uuid = await queue.genuuid();
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
const filename = `${uuid}.${ext}`;
const tmpPath = `./tmp/${filename}`;
const destPath = `./public/b/${filename}`;
// Ensure directories exist
await fs.mkdir('./tmp', { recursive: true });
await fs.mkdir('./public/b', { recursive: true });
// Save temporarily
await fs.writeFile(tmpPath, file.data);
// Verify MIME
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
// Constants
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
// Check repost
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, {
success: false,
msg: `This file already exists`,
repost: repost
}, 409);
}
// Move to public
await fs.copyFile(tmpPath, destPath);
await fs.unlink(tmpPath).catch(() => { });
// Insert
await db`
insert into items ${db({
src: '',
dest: filename,
mime: actualMime,
size: size,
checksum: checksum,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: false
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
}
`;
const itemid = await queue.getItemID(filename);
// Thumbnail
try {
await queue.genThumbnail(filename, actualMime, itemid, '');
} catch (err) {
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
// Tags
const ratingTagId = rating === 'sfw' ? 1 : 2;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
for (const tagName of tags) {
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
return sendJson(res, {
success: true,
msg: 'Upload successful! Your upload is pending admin approval.',
itemid: itemid
});
} catch (err) {
console.error('[UPLOAD HANDLER ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};

View File

@@ -1,13 +1,22 @@
@include(snippets/header) @include(snippets/header)
<div id="main"> <div id="main">
<div class="about"> <div class="about">
<p>Welcome stranger!</p> <p>Welcome stranger!</p>
<p>bringing you some of the greatest webms from the past, the present and the future!</p> <p>bringing you some of the greatest webms from the past, the present and the future!</p>
<p>Enjoy your stay.</p> <p>How to use it?</p>
<img style="width: 200px" src="/s/img/cockfag.png" alt="cockfag"> <p>shortcuts</p>
<p>If you have any questions you can reach out via Mail.</p> <ul>
<p>mail: admin@w0bm.com</p> <li>k = search</li>
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p> <li>r = random</li>
</div> <li>p = toggle safe for rating</li>
<li>i = open tag input</li>
<li>x = del</li>
<li>scroll up/down inside video or inside the controls triggers next or prev</li>
<li>Arrow keys trigger next or prev</li>
</ul>
<p>If you have any questions you can reach out via Mail.</p>
<p>mail: admin@w0bm.com</p>
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p>
</div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)

View File

@@ -6,14 +6,15 @@
<span>Hier entsteht eine Internetpräsenz!</span><br> <span>Hier entsteht eine Internetpräsenz!</span><br>
<hr> <hr>
<p>f0ck stats: @if(typeof totals !== "undefined") <p>f0ck stats: @if(typeof totals !== "undefined")
total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }} total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }}
@endif</p> | nsfw: {{ totals.nsfw }}
@endif</p>
<hr> <hr>
<div class="admintools"> <div class="admintools">
<p>Adminwerkzeuge</p> <p>Adminwerkzeuge</p>
<ul> <ul>
<!-- <li><a href="/admin/log">Logs</a></li> <!-- <li><a href="/admin/log">Logs</a></li> -->
<li><a href="/admin/recover">Recover f0cks</a></li> --> <li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li> <li><a href="/admin/sessions">Sessions</a></li>
</ul> </ul>
</div> </div>

192
views/admin/approve.html Normal file
View File

@@ -0,0 +1,192 @@
@include(snippets/header)
<div id="main">
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
<table class="table" style="width: 100%">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(posts as post)
<tr>
<td>
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
</video>
</td>
<td>{{ post.id }}</td>
<td>{{ post.username }}</td>
<td>{{ post.mime }}</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 /
Delete</a>
</td>
</tr>
@endeach
@if(posts.length === 0)
<tr>
<td colspan="5">No pending items.</td>
</tr>
@endif
</tbody>
</table>
<br>
@if(typeof pages !== 'undefined' && pages > 1)
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
@if(page > 1)
<a href="/admin/approve?page={{ page - 1 }}" class="badge badge-secondary">&laquo; Prev</a>
@endif
<span>Page {{ page }} of {{ pages }}</span>
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next
&raquo;</a>
@endif
</div>
<br>
@endif
<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>
</div>
<a href="/admin">Back to Admin</a>
</div>
</div>
<!-- Custom Modal -->
<div id="custom-modal"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
<div
style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
<p id="modal-text">Are you sure?</p>
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
<button id="modal-cancel" class="badge badge-secondary"
style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
<button id="modal-confirm" class="badge badge-danger"
style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Dynamic Button Text
const btnDenyAllInit = document.getElementById('btn-deny-all');
if (btnDenyAllInit) {
const count = document.querySelectorAll('.btn-deny-async').length;
btnDenyAllInit.innerText = 'Deny All (' + count + ' visible)';
}
const modal = document.getElementById('custom-modal');
const modalTitle = document.getElementById('modal-title');
const modalText = document.getElementById('modal-text');
const btnConfirm = document.getElementById('modal-confirm');
const btnCancel = document.getElementById('modal-cancel');
let pendingAction = null;
const showModal = (title, text, action) => {
modalTitle.innerText = title;
modalText.innerText = text;
pendingAction = action;
modal.style.display = 'flex';
btnConfirm.onclick = async () => {
if (!pendingAction) return;
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing...';
try {
await pendingAction();
closeModal();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btnConfirm.disabled = false;
btnConfirm.innerText = 'Confirm';
}
};
};
const closeModal = () => {
modal.style.display = 'none';
pendingAction = null;
};
if (btnCancel) btnCancel.onclick = closeModal;
// Single Deny
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const url = btn.getAttribute('href');
const row = btn.closest('tr');
showModal('Deny Item', 'Permanently delete this item?', async () => {
const res = await fetch(url);
if (res.ok) {
row.style.opacity = '0';
setTimeout(() => row.remove(), 300);
} else {
throw new Error('Request failed');
}
});
});
});
// Global handler for Deny All
window.handleDenyAll = (e) => {
e.preventDefault();
console.log('Deny All clicked (Inline)');
const allBtn = [...document.querySelectorAll('.btn-deny-async')];
// Map to {id, element}
const targets = allBtn.map(b => {
const href = b.getAttribute('href');
const match = href ? href.match(/[?&]id=([^&]+)/) : null;
if (!match && href) console.log('No ID match for href:', href);
return match ? { id: match[1], btn: b } : null;
}).filter(item => item);
const ids = targets.map(t => t.id);
console.log('Deny List:', ids);
if (ids.length === 0) return alert('No items to deny');
showModal('Deny ALL', 'Permanently delete ' + ids.length + ' visible items?', async () => {
const res = await fetch('/admin/deny-multi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
const json = await res.json();
if (json.success) {
closeModal(); // UX Polish: Close modal immediately
// Visual Removal
targets.forEach(t => {
const row = t.btn.closest('tr');
if (row) {
row.style.opacity = '0';
}
});
// Allow transition then reload
setTimeout(() => {
window.location.reload();
}, 500);
} else {
throw new Error(json.msg || 'Failed');
}
});
};
});
</script>
@include(snippets/footer)

View File

@@ -1,5 +1,5 @@
@include(snippets/header) @include(snippets/header)
<canvas class="hidden-xs" id="bg"></canvas>
<div class="wrapper"> <div class="wrapper">
<div id="main"> <div id="main">

View File

@@ -1,10 +1,32 @@
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script> <div id="delete-tag-modal" class="modal-overlay" style="display:none;">
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script> <div class="modal-content">
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script> <h3>Delete Tag?</h3>
@if(session && session.admin) <p>Are you sure you want to delete the tag <strong id="delete-tag-name"></strong>?</p>
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script> <div class="modal-actions">
@elseif(session && !session.admin) <button id="delete-tag-confirm" class="btn-danger">Delete</button>
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script> <button id="delete-tag-cancel" class="btn-secondary">Cancel</button>
@endif </div>
</div>
</div>
<div id="delete-item-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h3>Delete Item?</h3>
<p>Are you sure you want to delete item <strong id="delete-item-id"></strong> by <strong
id="delete-item-poster"></strong>?</p>
<div class="modal-actions">
<button id="delete-item-confirm" class="btn-danger">Delete</button>
<button id="delete-item-cancel" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script>
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script>
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script>
@if(session && session.admin)
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script>
@elseif(session && !session.admin)
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script>
@endif
</body> </body>
</html> </html>

View File

@@ -1,14 +1,19 @@
<!doctype html> <!doctype html>
<html lang="en" theme="@if(typeof theme !== "undefined"){{ theme }}@endif" res="@if(typeof fullscreen !== "undefined"){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif"> <html lang="en" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif"
res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
<head> <head>
@if(typeof item !== "undefined")<title>f0bm - {{ item.id }}</title>@else<title>f0bm</title>@endif @if(typeof item !== 'undefined')<title>f0bm - {{ item.id }}</title>@else<title>f0bm</title>@endif
<link rel="icon" type="image/gif" href="/s/img/favicon.png" /> <link rel="icon" type="image/gif" href="/s/img/favicon.png" />
<link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)"> <link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)">
<link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)"> <link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)">
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@if(typeof item !== "undefined")<link rel="canonical" href="https://w0bm.com/{{ item.id }}" />@endif @if(typeof item !== 'undefined')
<link rel="canonical" href="https://w0bm.com/{{ item.id }}" />@endif
</head> </head>
<body> <body>
<!-- hier splitting betreiben --> <!-- hier splitting betreiben -->
<canvas class="hidden-xs" id="bg"></canvas>
@include(snippets/navbar) @include(snippets/navbar)

View File

@@ -0,0 +1,7 @@
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}"
data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}"
style="background-image: url('/t/{{ item.id }}.webp')">
<p></p>
</a>
@endeach

View File

@@ -2,18 +2,44 @@
<!-- logged in --> <!-- logged in -->
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a> <a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
<div class="nav-left-group">
<div class="navigation-links-guest"> <div class="nav-user-dropdown">
<ol> <button class="nav-user-btn" id="nav-user-toggle">
{{ session.user }} ▾
</button>
<div class="nav-user-menu" id="nav-user-menu">
<a href="/user/{{ session.user.toLowerCase() }}">profile</a>
<a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a>
<a href="/upload">upload</a>
@if(session.admin)
<a href="/admin">admin</a>
@endif
<a href="/settings">settings</a>
<div class="nav-user-divider"></div>
<a href="/logout">logout</a>
</div>
</div>
<div class="nav-links">
<a href="/tags">tags</a> <a href="/tags">tags</a>
<a href="/about">about</a> <a href="/about">about</a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random">rand</a> <a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
<path
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
</svg></a>
@endif @endif
</ol> <a href="#" id="nav-search-btn" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg></a>
</div>
</div> </div>
<!-- show pagination only for tags and main page --> <!-- show pagination only for tags and main page -->
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d+$/.test(url.pathname))
<div class="collapse navbar-collapse show" id="navbarSupportedContent"> <div class="collapse navbar-collapse show" id="navbarSupportedContent">
<div class="pagination-container-fluid"> <div class="pagination-container-fluid">
<div class="pagination-wrapper"> <div class="pagination-wrapper">
@@ -27,18 +53,28 @@
<!-- not logged in --> <!-- not logged in -->
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a> <a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
<div class="navigation-links-guest"> <div class="navigation-links-guest">
<ol> <ol>
<a href="/tags">tags</a> <a href="/tags">tags</a>
<a href="/about">about</a> <a href="/about">about</a>
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d$/.test(url.pathname))
<a href="/random">rand</a> <a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
<path
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
</svg></a>
@endif @endif
<a href="#" id="nav-search-btn-guest" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13"
height="13" fill="currentColor" viewBox="0 0 16 16">
<path
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
</svg></a>
</ol> </ol>
</div> </div>
<!-- show pagination only for tags and main page --> <!-- show pagination only for tags and main page -->
@if(!/^\/\d$/.test(url.pathname)) @if(!/^\/\d+$/.test(url.pathname))
<div class="collapse navbar-collapse show" id="navbarSupportedContent"> <div class="collapse navbar-collapse show" id="navbarSupportedContent">
<div class="pagination-container-fluid"> <div class="pagination-container-fluid">
<div class="pagination-wrapper"> <div class="pagination-wrapper">

View File

@@ -2,24 +2,30 @@
<div id="main"> <div id="main">
<div class="container"> <div class="container">
<h3 style="text-align: center;"></h3> <h3 style="text-align: center;"></h3>
<div class="tags"> <div class="tags-grid">
@if(session) @if(session)
@each(toptags_regged as toptag) @each(toptags_regged as toptag)
<div class="tag badge badge-light mr-2"> <a href="/tag/{!! toptag.tag !!}" class="tag-card">
<div class="tagbox-body"> <div class="tag-card-image">
<span class="toptag_id">{!! toptag.tag !!}</span> <img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
</div> </div>
</div> <div class="tag-card-content">
<span class="tag-name">#{!! toptag.tag !!}</span>
<span class="tag-count">{{ toptag.total_items }} posts</span>
</div>
</a>
@endeach @endeach
@else @else
@each(toptags as toptag) @each(toptags as toptag)
<div class="tag badge badge-light mr-2"> <a href="/tag/{!! toptag.tag !!}" class="tag-card">
<div class="tagbox-body"> <div class="tag-card-image">
<span class="toptag_id">{!! toptag.tag !!}</span> <img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
</div> </div>
</div> <div class="tag-card-content">
<span class="tag-name">#{!! toptag.tag !!}</span>
<span class="tag-count">{{ toptag.total_items }} posts</span>
</div>
</a>
@endeach @endeach
@endif @endif
</div> </div>

View File

@@ -1,37 +1,439 @@
@include(snippets/header) @include(snippets/header)
<div class="upload"> <div class="upload-container">
<h5>Upload</h5> <h2>Upload</h2>
<p>To add videos to the w0bm catalogue you must join our <a href="https://t.me/+w97TCd988ehkNWEy">Telegram</a> group</p>
<h5>Content Guideline</h5> <div class="content-guidelines">
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p> <h4>Content Guideline</h4>
<p>We do not want content that</p> <p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
<ul> <div class="guidelines-grid">
<li>glorifies Nazis</li> <div class="guidelines-dont">
<li>sexualizes children and minors</li> <h5>We do not want</h5>
<li>is political</li> <ul>
<li>glorifies military</li> <li>Content glorifying Nazis</li>
<li>depicts gore</li> <li>Sexualization of children/minors</li>
<li>depicts acts of terrorism</li> <li>Political content</li>
<li>depicts violence and cruelty against animals</li> <li>Military glorification</li>
</ul> <li>Gore</li>
<p>We want content that</p> <li>Acts of terrorism</li>
<ul> <li>Violence against animals</li>
<li>is cool</li> </ul>
<li>has deeper value</li> </div>
<li>is fun to watch</li> <div class="guidelines-do">
<li>has a vibe to it</li> <h5>We want</h5>
<li>can be looped for 5000 times and doesnt get boring</li> <ul>
</ul> <li>Cool content</li>
<p>but in general we welcome content that has been curated beforehand by the uploader and believe that they understand the vibe.</p> <li>Deeper value</li>
<p>Content that is deemed NSFW (Not Safe For Work) MUST be tagged with "nsfw"</p> <li>Fun to watch</li>
<p>This list is subject to change, please review it periodically.</p> <li>Has a vibe to it</li>
<br> <li>Can be looped 5000 times</li>
<h5>How it works</h5> </ul>
<ul> </div>
<li>The maximum filesize for direct file upload is 20MB and cannot be exceeded.</li> </div>
<li>There is a much higher limit for non-direct uploads via sending a URL.</li> </div>
<li>You can send a link to the group and put a !f behind it and the bot will pick it up and add it to w0bm.</li>
<li>In the menu below the bots message you can select the rating and additional tags.</li> @if(session)
</ul> <form id="upload-form" class="upload-form" enctype="multipart/form-data">
<div class="form-section">
<label>Video File <span class="required">*</span></label>
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" name="file" accept="video/mp4,video/webm" style="display: none;">
<label for="file-input" class="drop-zone-prompt"
style="cursor: pointer; display: block; width: 100%; height: 100%;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>Drop your mp4 or webm here<br>or click to browse</p>
</label>
<div class="file-preview" id="file-preview" style="display: none;">
<span class="file-name" id="file-name"></span>
<span class="file-size" id="file-size"></span>
<button type="button" class="btn-remove" id="remove-file"></button>
</div>
</div>
</div>
<div class="form-section">
<label>Rating <span class="required">*</span></label>
<div class="rating-options">
<label class="rating-option">
<input type="radio" name="rating" value="sfw" required>
<span class="rating-label sfw">SFW</span>
</label>
<label class="rating-option">
<input type="radio" name="rating" value="nsfw">
<span class="rating-label nsfw">NSFW</span>
</label>
</div>
</div>
<div class="form-section">
<label>Tags <span class="required">*</span> <span class="tag-count" id="tag-count">(0/3
minimum)</span></label>
<div class="tag-input-container">
<div class="tags-list" id="tags-list"></div>
<input type="text" id="tag-input" placeholder="Type a tag and press Enter" autocomplete="off">
<div class="tag-suggestions" id="tag-suggestions"></div>
</div>
<input type="hidden" name="tags" id="tags-hidden">
</div>
<div class="form-actions">
<button type="submit" id="submit-btn" class="btn-upload" disabled>
<span class="btn-text">3 tags required</span>
<span class="btn-loading" style="display: none;">Uploading...</span>
</button>
</div>
<div class="upload-progress" id="upload-progress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-text" id="progress-text">0%</span>
</div>
<div class="upload-status" id="upload-status"></div>
</form>
@else
<div class="login-required">
<p>You must be logged in to upload content.</p>
<a href="/login" class="btn-login">Login</a>
</div>
@endif
</div> </div>
<style>
.upload-container {
margin: 0px 25px 0px 25px
}
.upload-container h2 {
margin-bottom: 1.5rem;
color: var(--accent);
}
.content-guidelines {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.content-guidelines h4 {
margin-bottom: 0.5rem;
color: var(--accent);
}
.guidelines-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-top: 1rem;
}
.guidelines-dont h5 {
color: #ff6b6b;
}
.guidelines-do h5 {
color: #51cf66;
}
.guidelines-grid ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
}
.guidelines-grid li {
padding: 0.3rem 0;
font-size: 0.9rem;
opacity: 0.8;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.required {
color: #ff6b6b;
}
.drop-zone {
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.02);
}
.drop-zone input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.drop-zone-prompt {
color: rgba(255, 255, 255, 0.5);
}
.drop-zone-prompt svg {
margin-bottom: 0.5rem;
}
.file-preview {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.file-name {
font-weight: 500;
flex: 1;
}
.file-size {
opacity: 0.6;
font-size: 0.9rem;
}
.btn-remove {
background: rgba(255, 107, 107, 0.2);
border: none;
color: #ff6b6b;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
}
.rating-options {
display: flex;
gap: 1rem;
}
.rating-option {
cursor: pointer;
}
.rating-option input {
display: none;
}
.rating-label {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 4px;
border: 2px solid transparent;
transition: all 0.2s;
}
.rating-label.sfw {
background: rgba(81, 207, 102, 0.1);
border-color: rgba(81, 207, 102, 0.3);
}
.rating-label.nsfw {
background: rgba(255, 107, 107, 0.1);
border-color: rgba(255, 107, 107, 0.3);
}
.rating-option input:checked+.rating-label.sfw {
background: rgba(81, 207, 102, 0.3);
border-color: #51cf66;
}
.rating-option input:checked+.rating-label.nsfw {
background: rgba(255, 107, 107, 0.3);
border-color: #ff6b6b;
}
.tag-input-container {
position: relative;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--accent);
color: #000;
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.9rem;
}
.tag-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
#tag-input {
flex: 1;
min-width: 120px;
background: transparent;
border: none;
color: inherit;
padding: 0.5rem;
outline: none;
}
.tag-count {
font-weight: normal;
font-size: 0.85rem;
opacity: 0.7;
}
.tag-count.valid {
color: #51cf66;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--background, #1a1a1a);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 100;
}
.tag-suggestions.show {
display: block;
}
.tag-suggestion {
padding: 0.5rem 1rem;
cursor: pointer;
}
.tag-suggestion:hover {
background: rgba(255, 255, 255, 0.05);
}
.btn-upload {
background: var(--accent);
color: #000;
border: none;
padding: 1rem 2rem;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-upload:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
}
.btn-upload:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.upload-progress {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.2s;
}
.upload-status {
text-align: center;
padding: 1rem;
}
.upload-status.success {
color: #51cf66;
}
.upload-status.error {
color: #ff6b6b;
}
.login-required {
text-align: center;
padding: 3rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.btn-login {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 2rem;
background: var(--accent);
color: #000;
text-decoration: none;
border-radius: 4px;
font-weight: 600;
}
</style>
<script src="/s/js/upload.js"></script>
@include(snippets/footer) @include(snippets/footer)