Compare commits
38 Commits
c198566474
...
f0bm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54f266ff3d | ||
|
|
a9871187ab | ||
|
|
43da214f73 | ||
|
|
c822a4f4e7 | ||
|
|
a8bb3e67f5 | ||
|
|
85912f4ba1 | ||
|
|
f3a1fde23d | ||
|
|
8085b0166c | ||
|
|
85578b179b | ||
|
|
1a3514effa | ||
|
|
a439683caf | ||
|
|
577d73af11 | ||
|
|
42f4e19897 | ||
|
|
0a5f57b5a9 | ||
|
|
03f2630090 | ||
|
|
6692f32c4b | ||
|
|
8af49b6ec1 | ||
|
|
9c25f89adc | ||
|
|
ee6fda8f06 | ||
|
|
e9c377dc87 | ||
|
|
f5e386593d | ||
|
|
1dd4b54b48 | ||
|
|
4de2652ffe | ||
| 7b1e0af0cb | |||
|
|
224064d0ca | ||
| 52533486a2 | |||
|
|
3ee28fd0b7 | ||
|
|
964284d5c9 | ||
|
|
9a03d5f697 | ||
|
|
9b1041dda7 | ||
| 4bc8b8f436 | |||
|
|
45f9345e9c | ||
|
|
c74e5a7402 | ||
|
|
6799ec1567 | ||
| 007cf3189c | |||
| 4a2925b141 | |||
|
|
b72fcaa426 | ||
|
|
a4f9c48e13 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ deleted/b
|
||||
deleted/ca
|
||||
deleted/t
|
||||
tmp/*
|
||||
tools
|
||||
@@ -1197,7 +1197,9 @@ body {
|
||||
overscroll-behavior-y: contain;
|
||||
overflow: unset;
|
||||
font-size: 14px;
|
||||
height: /* 100%; */auto;
|
||||
height:
|
||||
/* 100%; */
|
||||
auto;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@@ -1318,6 +1320,13 @@ div.posts>a:hover::after {
|
||||
grid-template-columns: auto 1fr 0fr;
|
||||
justify-content: start;
|
||||
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 {
|
||||
@@ -1424,7 +1433,7 @@ ul.navbar-nav li.nav-item {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -1576,14 +1585,10 @@ span.placeholder {
|
||||
}
|
||||
|
||||
@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 {
|
||||
grid-area: f0ck;
|
||||
/* maintained for potential other uses or reset */
|
||||
}
|
||||
|
||||
.pagination-container-fluid {
|
||||
@@ -1608,7 +1613,7 @@ span.placeholder {
|
||||
}
|
||||
|
||||
@media (max-width: 1325px) {
|
||||
/* ranking page - idea */
|
||||
/* ranking page - idea */
|
||||
/* .ranking {
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
} */
|
||||
@@ -1783,8 +1788,9 @@ span.placeholder {
|
||||
.index-container {
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
/* background-color: var(--navigation-links-bg);
|
||||
*/}
|
||||
/* background-color: var(--navigation-links-bg);
|
||||
*/
|
||||
}
|
||||
|
||||
@media (min-width: 361px) {
|
||||
.embed-responsive-image {
|
||||
@@ -2779,7 +2785,9 @@ ul.navbar-nav-guests li.nav-item {
|
||||
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-left: .5rem;
|
||||
}
|
||||
@@ -2792,7 +2800,7 @@ ul.navbar-nav-guests li.nav-item {
|
||||
margin: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
ul.navbar-nav-guests li.nav-item {
|
||||
margin-right: unset;
|
||||
}
|
||||
@@ -2812,16 +2820,20 @@ ul.navbar-nav-guests li.nav-item {
|
||||
|
||||
/* Pagination Responsiveness */
|
||||
@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-left: 2px;
|
||||
}
|
||||
|
||||
.pagination > a, .pagination > span {
|
||||
|
||||
.pagination>a,
|
||||
.pagination>span {
|
||||
margin-right: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
|
||||
.pagination {
|
||||
justify-content: center !important;
|
||||
}
|
||||
@@ -2830,15 +2842,19 @@ ul.navbar-nav-guests li.nav-item {
|
||||
/* fadeIn effect */
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img#f0ck-image, div.imageDoor, div.posts a, video {
|
||||
animation: 1s ease-out 0s 1 fadeInFX;
|
||||
img#f0ck-image,
|
||||
div.imageDoor,
|
||||
div.posts a,
|
||||
video {
|
||||
animation: 1s ease-out 0s 1 fadeInFX;
|
||||
}
|
||||
|
||||
/* f0ckgle */
|
||||
@@ -2867,7 +2883,7 @@ img#f0ck-image, div.imageDoor, div.posts a, video {
|
||||
background: var(--nav-bg);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
|
||||
.profile_head_avatar {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -2892,11 +2908,13 @@ img#f0ck-image, div.imageDoor, div.posts a, video {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.f0cks h5, .favs h5 {
|
||||
.f0cks h5,
|
||||
.favs h5 {
|
||||
background: var(--dropdown-bg);
|
||||
}
|
||||
|
||||
.f0cks-header, .favs-header {
|
||||
.f0cks-header,
|
||||
.favs-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
background: var(--img-border-color);
|
||||
@@ -2925,7 +2943,7 @@ div.favs div.posts {
|
||||
filter: blur(100px);
|
||||
transform: translate3d(0, 0, 0);
|
||||
z-index: 0;
|
||||
transition: 2s ease;
|
||||
transition: opacity 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@@ -2940,46 +2958,50 @@ button#togglebg {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutFX {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fader-in {
|
||||
animation: fadeIn .8s steps(100) forwards;
|
||||
opacity: 0.4 !important;
|
||||
}
|
||||
|
||||
.fader-out {
|
||||
animation: fadeOut .8s steps(100) forwards
|
||||
}
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: grid;
|
||||
@@ -3004,6 +3026,7 @@ input#s_avatar {
|
||||
#s_avatar:hover {
|
||||
background: #ffffff0f;
|
||||
}
|
||||
|
||||
.theforceofthree {
|
||||
display: grid;
|
||||
grid-template-columns: 0.4fr 1fr 0.4fr;
|
||||
@@ -3011,4 +3034,331 @@ input#s_avatar {
|
||||
|
||||
.upload {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ video {
|
||||
}
|
||||
|
||||
#main {
|
||||
padding: 25px;
|
||||
padding: 0px 25px 0px 25px;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -262,7 +262,7 @@ video {
|
||||
background: #0000008a !important;
|
||||
}
|
||||
|
||||
.pagination > a {
|
||||
.pagination>a {
|
||||
background: #232323b2;
|
||||
}
|
||||
|
||||
@@ -285,32 +285,33 @@ div.search {
|
||||
div.sbt {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
#sbtButton {
|
||||
visibility: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#sbtInput {
|
||||
background: #00000021;
|
||||
box-shadow: -1px -1px 0px #252525;
|
||||
border: inset 1px #0000001c;
|
||||
padding: revert;
|
||||
box-shadow: inset 0px 0px 5px 1px #0000005e;
|
||||
width: 100%;
|
||||
background: #00000021;
|
||||
box-shadow: -1px -1px 0px #252525;
|
||||
border: inset 1px #0000001c;
|
||||
padding: revert;
|
||||
box-shadow: inset 0px 0px 5px 1px #0000005e;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navigation-links {
|
||||
display: grid;
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
}
|
||||
.navigation-links {
|
||||
display: grid;
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
}
|
||||
|
||||
.navigation-links-guest, ol {
|
||||
margin: 5px;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
padding-inline-start: 0;
|
||||
.navigation-links-guest,
|
||||
ol {
|
||||
margin: 5px;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
@@ -1,331 +1,375 @@
|
||||
(async () => {
|
||||
if(_addtag = document.querySelector("a#a_addtag")) {
|
||||
const postid = +document.querySelector("a.id-link").innerText;
|
||||
const poster = document.querySelector("a#a_username").innerText;
|
||||
let tags = [...document.querySelectorAll("#tags > .badge")].map(t => t.innerText.slice(0, -2));
|
||||
|
||||
const deleteEvent = async e => {
|
||||
e.preventDefault();
|
||||
if(!confirm("Do you really want to delete this tag?"))
|
||||
return;
|
||||
const tagname = e.target.parentElement.querySelector('a:first-child').innerText;
|
||||
|
||||
const res = await (await fetch("/api/v2/admin/" + postid + "/tags/" + encodeURIComponent(tagname), {
|
||||
method: 'DELETE'
|
||||
})).json();
|
||||
|
||||
if(!res.success)
|
||||
return alert("uff");
|
||||
tags = res.tags.map(t => t.tag);
|
||||
renderTags(res.tags);
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: [...document.querySelectorAll("#tags > .badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if(method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for(const [ key, val ] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = _tags => {
|
||||
[...document.querySelectorAll("#tags > .badge")].forEach(tag => tag.parentElement.removeChild(tag));
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.innerHTML = tag.tag;
|
||||
a.addEventListener("click", editTagEvent); // tmp
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "mr-2");
|
||||
span.setAttribute('tooltip', tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = " ×";
|
||||
delbutton.href = "#";
|
||||
delbutton.addEventListener("click", deleteEvent);
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
span.innerHTML += ' ';
|
||||
span.insertAdjacentElement("beforeend", delbutton);
|
||||
|
||||
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
};
|
||||
|
||||
const addtagClick = (ae = false) => {
|
||||
if(ae)
|
||||
ae.preventDefault();
|
||||
|
||||
const insert = document.querySelector("a#a_addtag");
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "badge-light", "mr-2");
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.size = "10";
|
||||
input.value = "";
|
||||
input.setAttribute("list", "testlist");
|
||||
input.setAttribute("autoComplete", "off");
|
||||
|
||||
span.insertAdjacentElement("afterbegin", input);
|
||||
insert.insertAdjacentElement("beforebegin", span);
|
||||
|
||||
input.focus();
|
||||
|
||||
let tt = null;
|
||||
let lastInput = '';
|
||||
const testList = document.querySelector('#testlist');
|
||||
|
||||
input.addEventListener("keyup", async e => {
|
||||
if(e.key === "Enter") {
|
||||
const tmptag = input.value?.trim();
|
||||
if(tags.includes(tmptag))
|
||||
return alert("tag already exists");
|
||||
const res = await post("/api/v2/admin/" + postid + "/tags", {
|
||||
tagname: tmptag
|
||||
});
|
||||
if(!res.success) {
|
||||
alert(res.msg);
|
||||
return false;
|
||||
}
|
||||
tags = res.tags.map(t => t.tag);
|
||||
renderTags(res.tags);
|
||||
addtagClick();
|
||||
testList.innerText = "";
|
||||
}
|
||||
else if(e.key === "Escape") {
|
||||
span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
}
|
||||
else {
|
||||
if(tt != null)
|
||||
clearTimeout(tt);
|
||||
|
||||
tt = setTimeout(async () => {
|
||||
tt = null;
|
||||
|
||||
const tmptag = input.value?.trim();
|
||||
|
||||
if(tmptag == lastInput || tmptag.length <= 1)
|
||||
return false;
|
||||
|
||||
testList.innerText = "";
|
||||
lastInput = tmptag;
|
||||
|
||||
const res = await get('/api/v2/admin/tags/suggest', {
|
||||
q: tmptag
|
||||
});
|
||||
|
||||
for(const entry of res.suggestions) {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.tag;
|
||||
|
||||
if(!/fox/.test(navigator.userAgent))
|
||||
option.innerText = `tagged ${entry.tagged} times (score: ${entry.score.toFixed(2)})`;
|
||||
|
||||
testList.insertAdjacentElement('beforeEnd', option);
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
input.addEventListener("focusout", ie => {
|
||||
if(input.value.length === 0)
|
||||
input.parentElement.parentElement.removeChild(input.parentElement);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e = false) => {
|
||||
if(e)
|
||||
e.preventDefault();
|
||||
|
||||
const res = await (await fetch('/api/v2/admin/' + encodeURIComponent(postid) + '/tags/toggle', {
|
||||
method: 'PUT'
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
};
|
||||
|
||||
const deleteButtonEvent = async e => {
|
||||
if(e)
|
||||
e.preventDefault();
|
||||
if(!confirm(`Reason for deleting f0ckpost ${postid} by ${poster} (Weihnachten™)`))
|
||||
return;
|
||||
const res = await post("/api/v2/admin/deletepost", {
|
||||
postid: postid
|
||||
});
|
||||
if(!res.success) {
|
||||
alert(res.msg);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavEvent = async e => {
|
||||
const res = await post('/api/v2/admin/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if(res.success) {
|
||||
const fav = document.querySelector("svg#a_favo > use").href;
|
||||
fav.baseVal = '/s/img/iconset.svg#heart_' + (fav.baseVal.match(/heart_(regular|solid)$/)[1] == "solid" ? "regular" : "solid");
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('span#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
|
||||
favcontainer.hidden = !(favcontainer.hidden || res.favs.length > 0);
|
||||
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}/favs`;
|
||||
a.setAttribute('tooltip', f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/t/${f.avatar}.webp`;
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.insertAdjacentElement('beforeend', img);
|
||||
favcontainer.insertAdjacentElement('beforeend', a);
|
||||
favcontainer.innerHTML += " ";
|
||||
});
|
||||
}
|
||||
else {
|
||||
// lul
|
||||
}
|
||||
};
|
||||
|
||||
let tmptt = null;
|
||||
const editTagEvent = async e => { // mousedown
|
||||
e.preventDefault();
|
||||
|
||||
if(e.detail === 2) {
|
||||
clearTimeout(tmptt);
|
||||
const old = e.target;
|
||||
const parent = e.target.parentElement;
|
||||
const oldtag = e.target.innerText;
|
||||
|
||||
const textfield = document.createElement('input');
|
||||
textfield.value = e.target.innerText;
|
||||
textfield.size = 10;
|
||||
|
||||
parent.insertAdjacentElement('afterbegin', textfield);
|
||||
textfield.focus();
|
||||
parent.removeChild(e.target);
|
||||
parent.querySelector('a:last-child').style.display = 'none';
|
||||
|
||||
textfield.addEventListener("keyup", async e => {
|
||||
if(e.key === 'Enter') {
|
||||
parent.removeChild(textfield);
|
||||
// send
|
||||
let res = await fetch('/api/v2/admin/tags/' + encodeURIComponent(oldtag), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
newtag: textfield.value
|
||||
})
|
||||
});
|
||||
const status = res.status;
|
||||
res = await res.json();
|
||||
|
||||
switch(status) {
|
||||
case 200: // success, change
|
||||
case 201:
|
||||
//parent.removeChild(textfield);
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
parent.querySelector('a:last-child').style.display = '';
|
||||
old.href = `/tag/${res.tag}`;
|
||||
old.innerText = res.tag.trim();
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if(e.key === 'Escape') {
|
||||
parent.removeChild(textfield);
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
parent.querySelector('a:last-child').style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
tmptt = setTimeout(() => location.href = e.target.href, 250);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
_addtag.addEventListener("click", addtagClick);
|
||||
document.querySelector("a#a_toggle").addEventListener("click", toggleEvent);
|
||||
[...document.querySelectorAll("#tags > .badge > a:first-child")].map(t => t.addEventListener("click", editTagEvent));
|
||||
[...document.querySelectorAll("#tags > .badge > a:last-child")].map(t => t.addEventListener("click", deleteEvent));
|
||||
if(document.querySelector("svg#a_delete"))
|
||||
document.querySelector("svg#a_delete").addEventListener("click", deleteButtonEvent);
|
||||
document.querySelector("svg#a_favo").addEventListener("click", toggleFavEvent);
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
if(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||
return;
|
||||
if(e.key === "p")
|
||||
toggleEvent();
|
||||
else if(e.key === "i")
|
||||
addtagClick();
|
||||
else if(e.key === "x")
|
||||
deleteButtonEvent();
|
||||
else if(e.key === "f")
|
||||
toggleFavEvent();
|
||||
});
|
||||
}
|
||||
|
||||
if(document.location.pathname === '/settings') {
|
||||
const saveAvatar = async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const avatar = +document.querySelector('input[name="i_avatar"]').value;
|
||||
let res = await fetch('/api/v2/settings/setAvatar', {
|
||||
method: 'PUT',
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if (method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for (const [key, val] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = _tags => {
|
||||
[...document.querySelectorAll("#tags > .badge")].forEach(tag => tag.parentElement.removeChild(tag));
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.innerHTML = tag.tag;
|
||||
// Admin specific: edit event
|
||||
// Note: delegation handles this now if we set it up, OR we can attach here since elements are new.
|
||||
// But delegation is cleaner if possible. However, editTagEvent relies on 'e.target'.
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "mr-2");
|
||||
span.setAttribute('tooltip', tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = " ×";
|
||||
delbutton.href = "#";
|
||||
// Class for delegation
|
||||
delbutton.classList.add("admin-deltag");
|
||||
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
span.innerHTML += ' ';
|
||||
span.insertAdjacentElement("beforeend", delbutton);
|
||||
|
||||
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEvent = async e => {
|
||||
e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
if (!confirm("Do you really want to delete this tag?"))
|
||||
return;
|
||||
const tagname = e.target.parentElement.querySelector('a:first-child').innerText;
|
||||
|
||||
const res = await (await fetch("/api/v2/admin/" + postid + "/tags/" + encodeURIComponent(tagname), {
|
||||
method: 'DELETE'
|
||||
})).json();
|
||||
|
||||
if (!res.success)
|
||||
return alert("uff");
|
||||
|
||||
renderTags(res.tags);
|
||||
};
|
||||
|
||||
const addtagClick = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, tags } = ctx;
|
||||
|
||||
const insert = document.querySelector("a#a_addtag");
|
||||
if (insert.previousElementSibling && insert.previousElementSibling.querySelector('input')) {
|
||||
insert.previousElementSibling.querySelector('input').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "badge-light", "mr-2");
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.size = "10";
|
||||
input.value = "";
|
||||
input.setAttribute("list", "testlist");
|
||||
input.setAttribute("autoComplete", "off");
|
||||
|
||||
span.insertAdjacentElement("afterbegin", input);
|
||||
insert.insertAdjacentElement("beforebegin", span);
|
||||
|
||||
input.focus();
|
||||
|
||||
let tt = null;
|
||||
let lastInput = '';
|
||||
const testList = document.querySelector('#testlist');
|
||||
|
||||
input.addEventListener("keyup", async e => {
|
||||
if (e.key === "Enter") {
|
||||
const tmptag = input.value?.trim();
|
||||
// We should re-check tags from DOM? Or trust captured tags?
|
||||
// Captured 'tags' is safe for immediate check.
|
||||
if (tags.includes(tmptag))
|
||||
return alert("tag already exists");
|
||||
|
||||
const res = await post("/api/v2/admin/" + postid + "/tags", {
|
||||
tagname: tmptag
|
||||
});
|
||||
if (!res.success) {
|
||||
alert(res.msg);
|
||||
return false;
|
||||
}
|
||||
renderTags(res.tags);
|
||||
if (span.parentElement) span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
addtagClick();
|
||||
}
|
||||
else if (e.key === "Escape") {
|
||||
if (span.parentElement) span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
}
|
||||
else {
|
||||
if (tt != null) clearTimeout(tt);
|
||||
tt = setTimeout(async () => {
|
||||
tt = null;
|
||||
const tmptag = input.value?.trim();
|
||||
if (tmptag == lastInput || tmptag.length <= 1) return false;
|
||||
testList.innerText = "";
|
||||
lastInput = tmptag;
|
||||
const res = await get('/api/v2/admin/tags/suggest', { q: tmptag });
|
||||
for (const entry of res.suggestions) {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.tag;
|
||||
if (!/fox/.test(navigator.userAgent))
|
||||
option.innerText = `tagged ${entry.tagged} times (score: ${entry.score.toFixed(2)})`;
|
||||
testList.insertAdjacentElement('beforeEnd', option);
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
input.addEventListener("focusout", ie => {
|
||||
if (input.value.length === 0)
|
||||
input.parentElement.parentElement.removeChild(input.parentElement);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await (await fetch('/api/v2/admin/' + encodeURIComponent(postid) + '/tags/toggle', {
|
||||
method: 'PUT'
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
};
|
||||
|
||||
const deleteButtonEvent = async e => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, poster } = ctx;
|
||||
|
||||
const modal = document.getElementById('delete-item-modal');
|
||||
const idEl = document.getElementById('delete-item-id');
|
||||
const posterEl = document.getElementById('delete-item-poster');
|
||||
const confirmBtn = document.getElementById('delete-item-confirm');
|
||||
const cancelBtn = document.getElementById('delete-item-cancel');
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await post('/api/v2/admin/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
const fav = document.querySelector("svg#a_favo > use").href;
|
||||
fav.baseVal = '/s/img/iconset.svg#heart_' + (fav.baseVal.match(/heart_(regular|solid)$/)[1] == "solid" ? "regular" : "solid");
|
||||
|
||||
const favcontainer = document.querySelector('span#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
favcontainer.hidden = !(favcontainer.hidden || res.favs.length > 0);
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}/favs`;
|
||||
a.setAttribute('tooltip', f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
const img = document.createElement('img');
|
||||
img.src = `/t/${f.avatar}.webp`;
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
a.insertAdjacentElement('beforeend', img);
|
||||
favcontainer.insertAdjacentElement('beforeend', a);
|
||||
favcontainer.innerHTML += " ";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let tmptt = null;
|
||||
const editTagEvent = async e => {
|
||||
e.preventDefault();
|
||||
if (e.detail === 2) { // Double click
|
||||
clearTimeout(tmptt);
|
||||
const old = e.target;
|
||||
const parent = e.target.parentElement;
|
||||
const oldtag = e.target.innerText;
|
||||
|
||||
const textfield = document.createElement('input');
|
||||
textfield.value = e.target.innerText;
|
||||
textfield.size = 10;
|
||||
|
||||
parent.insertAdjacentElement('afterbegin', textfield);
|
||||
textfield.focus();
|
||||
parent.removeChild(e.target);
|
||||
// Hide delete button while editing
|
||||
const delBtn = parent.querySelector('a:last-child');
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
|
||||
textfield.addEventListener("keyup", async e => {
|
||||
if (e.key === 'Enter') {
|
||||
parent.removeChild(textfield);
|
||||
let res = await fetch('/api/v2/admin/tags/' + encodeURIComponent(oldtag), {
|
||||
method: 'PUT',
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newtag: textfield.value })
|
||||
});
|
||||
const status = res.status;
|
||||
res = await res.json();
|
||||
switch (status) {
|
||||
case 200:
|
||||
case 201:
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
old.href = `/tag/${res.tag}`;
|
||||
old.innerText = res.tag.trim();
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
parent.removeChild(textfield);
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
tmptt = setTimeout(() => location.href = e.target.href, 250);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.matches("a#a_addtag")) {
|
||||
addtagClick(e);
|
||||
} else if (e.target.matches("a#a_toggle")) {
|
||||
toggleEvent(e);
|
||||
} else if (e.target.closest("svg#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
} else if (e.target.closest("svg#a_delete")) {
|
||||
deleteButtonEvent(e);
|
||||
} else if (e.target.matches("#tags > .badge > a:first-child")) {
|
||||
editTagEvent(e);
|
||||
} else if (e.target.innerText === " \u00d7" && e.target.closest(".badge")) { // check text " x" or similar for delete?
|
||||
// Original was " ×" which is × (\u00d7).
|
||||
// Logic in deleteEvent expects match.
|
||||
// Let's rely on class or structure.
|
||||
// In renderTags I added class 'admin-deltag'.
|
||||
// Existing tags in HTML might NOT have this class unless rendered by JS?
|
||||
// But existing tags are just HTML. We should match structure.
|
||||
// selector: "#tags > .badge > a:last-child"
|
||||
if (e.target.matches("#tags > .badge > a:last-child")) {
|
||||
deleteEvent(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
|
||||
if (e.key === "p") toggleEvent();
|
||||
else if (e.key === "i") addtagClick();
|
||||
else if (e.key === "x") deleteButtonEvent();
|
||||
else if (e.key === "f") toggleFavEvent();
|
||||
});
|
||||
|
||||
// Settings page
|
||||
if (document.location.pathname === '/settings') {
|
||||
const saveAvatar = async e => {
|
||||
e.preventDefault();
|
||||
const avatar = +document.querySelector('input[name="i_avatar"]').value;
|
||||
let res = await fetch('/api/v2/settings/setAvatar', {
|
||||
method: 'PUT',
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar })
|
||||
});
|
||||
const code = res.status;
|
||||
res = await res.json();
|
||||
|
||||
switch(code) {
|
||||
case 200:
|
||||
document.querySelector('#img_avatar').src = `/t/${avatar}.webp`;
|
||||
document.querySelector('img.avatar').src = `/t/${avatar}.webp`;
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
break;
|
||||
if (code === 200) {
|
||||
document.querySelector('#img_avatar').src = `/t/${avatar}.webp`;
|
||||
document.querySelector('img.avatar').src = `/t/${avatar}.webp`;
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelector('input#s_avatar').addEventListener('click', saveAvatar);
|
||||
document.querySelector('input[name="i_avatar"]').addEventListener('keyup', async e => {
|
||||
if(e.key === 'Enter')
|
||||
await saveAvatar(e);
|
||||
});
|
||||
const sAvatar = document.querySelector('input#s_avatar');
|
||||
if (sAvatar) sAvatar.addEventListener('click', saveAvatar);
|
||||
const iAvatar = document.querySelector('input[name="i_avatar"]');
|
||||
if (iAvatar) iAvatar.addEventListener('keyup', async e => { if (e.key === 'Enter') await saveAvatar(e); });
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,65 +1,511 @@
|
||||
|
||||
window.requestAnimFrame = (function(){
|
||||
window.requestAnimFrame = (function () {
|
||||
return window.requestAnimationFrame
|
||||
|| window.webkitRequestAnimationFrame
|
||||
|| window.mozRequestAnimationFrame
|
||||
|| function(callback) { window.setTimeout(callback, 1000 / 60);};
|
||||
|| window.webkitRequestAnimationFrame
|
||||
|| window.mozRequestAnimationFrame
|
||||
|| function (callback) { window.setTimeout(callback, 1000 / 60); };
|
||||
})();
|
||||
|
||||
(() => {
|
||||
let video;
|
||||
if(elem = document.querySelector("#my-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")) {
|
||||
video = new v0ck(elem);
|
||||
document.addEventListener("keydown", e => {
|
||||
if(e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||
if (e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||
video[video.paused ? 'play' : 'pause']();
|
||||
document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('togglebg').addEventListener('click', function (e) {
|
||||
|
||||
|
||||
if (elem !== null) {
|
||||
// ... existing code ...
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
canvas.classList.add('fader-in');
|
||||
canvas.classList.remove('fader-out');
|
||||
} else {
|
||||
canvas.classList.add('fader-out');
|
||||
canvas.classList.remove('fader-in');
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
const cw = canvas.width = canvas.clientWidth | 0;
|
||||
const ch = canvas.height = canvas.clientHeight | 0;
|
||||
|
||||
const animationLoop = () => {
|
||||
if (elem.paused || elem.ended || !background)
|
||||
return;
|
||||
context.drawImage(elem, 0, 0, cw, ch);
|
||||
window.requestAnimFrame(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;
|
||||
const stimeout = 500;
|
||||
|
||||
const setupMedia = () => {
|
||||
if (elem = document.querySelector("#my-video")) {
|
||||
video = new v0ck(elem);
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
console.log("loadItemAjax called with:", url, "inheritContext:", inheritContext);
|
||||
// Show loading indicator
|
||||
const navbar = document.querySelector("nav.navbar");
|
||||
if (navbar) navbar.classList.add("pbwork");
|
||||
|
||||
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||
// Split path, filter numeric, pop last.
|
||||
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
||||
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
||||
|
||||
// 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);
|
||||
// fallback for weird/external links
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
const itemid = numericSegments.pop();
|
||||
|
||||
// <context-preservation>
|
||||
// Extract context from Target URL first
|
||||
let tag = null, user = null, isFavs = false;
|
||||
const tagMatch = url.match(/\/tag\/([^/]+)/);
|
||||
if (tagMatch) tag = decodeURIComponent(tagMatch[1]);
|
||||
|
||||
const userMatch = url.match(/\/user\/([^/]+)/);
|
||||
if (userMatch) {
|
||||
user = decodeURIComponent(userMatch[1]);
|
||||
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
||||
}
|
||||
|
||||
// If missing and inheritContext is true, check Window Location
|
||||
if (inheritContext) {
|
||||
if (!tag) {
|
||||
const wTagMatch = window.location.href.match(/\/tag\/([^/]+)/);
|
||||
if (wTagMatch) tag = decodeURIComponent(wTagMatch[1]);
|
||||
}
|
||||
if (!user) {
|
||||
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
||||
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>
|
||||
|
||||
try {
|
||||
// Construct AJAX URL
|
||||
let ajaxUrl = `/ajax/item/${itemid}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (tag) params.append('tag', tag);
|
||||
if (user) params.append('user', user);
|
||||
if (isFavs) params.append('fav', 'true');
|
||||
|
||||
if ([...params].length > 0) {
|
||||
ajaxUrl += '?' + params.toString();
|
||||
}
|
||||
|
||||
console.log("Fetching:", ajaxUrl);
|
||||
const response = await fetch(ajaxUrl, { credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Network response was not ok: ${response.status}`);
|
||||
|
||||
const rawText = await response.text();
|
||||
let html, paginationHtml;
|
||||
|
||||
try {
|
||||
// Optimistically try to parse as JSON first
|
||||
const data = JSON.parse(rawText);
|
||||
if (data && typeof data.html === 'string') {
|
||||
html = data.html;
|
||||
paginationHtml = data.pagination;
|
||||
} else {
|
||||
html = rawText;
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parse fails, assume it's HTML text
|
||||
html = rawText;
|
||||
}
|
||||
|
||||
let container = document.querySelector('#main .container');
|
||||
|
||||
if (!container && document.querySelector('.index-container')) {
|
||||
// Transition from Index to Item View
|
||||
const main = document.getElementById('main');
|
||||
main.innerHTML = '<div class="container"></div>';
|
||||
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) {
|
||||
// Check if we are on Tags Overview logic (which reuses .container)
|
||||
const tagsOverview = container.querySelector('.tags');
|
||||
if (tagsOverview) {
|
||||
container.innerHTML = '';
|
||||
} else {
|
||||
// Already in Item View, clear usage
|
||||
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);
|
||||
|
||||
// Update pagination if present
|
||||
if (paginationHtml) {
|
||||
const pagWrappers = document.querySelectorAll('.pagination-wrapper');
|
||||
pagWrappers.forEach(el => el.innerHTML = paginationHtml);
|
||||
}
|
||||
|
||||
// Construct proper History URL (Context Aware)
|
||||
// If we inherited context, we should reflect it in the URL
|
||||
let pushUrl = `/${itemid}`;
|
||||
// Logic from ajax.mjs context reconstruction:
|
||||
if (user) {
|
||||
pushUrl = `/user/${user}/${itemid}`;
|
||||
if (isFavs) pushUrl = `/user/${user}/favs/${itemid}`;
|
||||
}
|
||||
else if (tag) pushUrl = `/tag/${tag}/${itemid}`;
|
||||
|
||||
// We overwrite proper URL even if the link clicked was "naked"
|
||||
history.pushState({}, '', pushUrl);
|
||||
|
||||
setupMedia();
|
||||
if (window.initBackground) window.initBackground();
|
||||
// Try to extract ID from response if possible or just use itemid
|
||||
document.title = `f0bm - ${itemid}`;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
console.log("AJAX load complete");
|
||||
|
||||
} catch (err) {
|
||||
console.error("AJAX load failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const changePage = (e, pbwork = true) => {
|
||||
if (pbwork) {
|
||||
const nav = document.querySelector("nav.navbar");
|
||||
if (nav) nav.classList.add("pbwork");
|
||||
}
|
||||
// Trigger native click for navigation
|
||||
e.click();
|
||||
};
|
||||
|
||||
// Intercept clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
|
||||
// Check for thumbnail links on index page
|
||||
const thumbnail = e.target.closest('.posts > a');
|
||||
if (thumbnail && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
// Thumbnails inherit context (e.g. from Tag Index)
|
||||
loadItemAjax(thumbnail.href, true);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
// Special check for random
|
||||
if (link.id === 'random' || link.id === 'nav-random') {
|
||||
e.preventDefault();
|
||||
const nav = document.querySelector("nav.navbar");
|
||||
if (nav) nav.classList.add("pbwork");
|
||||
|
||||
// Extract current context from window location
|
||||
let randomUrl = '/api/v2/random';
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const wTagMatch = window.location.href.match(/\/tag\/([^/]+)/);
|
||||
if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1]));
|
||||
|
||||
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
||||
if (wUserMatch) {
|
||||
params.append('user', decodeURIComponent(wUserMatch[1]));
|
||||
if (window.location.href.match(/\/favs(\/|$|\?)/)) {
|
||||
params.append('fav', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
if ([...params].length > 0) {
|
||||
randomUrl += '?' + params.toString();
|
||||
}
|
||||
|
||||
fetch(randomUrl)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.items && data.items.id) {
|
||||
// Inherit context so URL matches current filter
|
||||
loadItemAjax(`/${data.items.id}`, true);
|
||||
} else {
|
||||
window.location.href = link.href;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Random fetch failed:", err);
|
||||
window.location.href = link.href;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Standard item links
|
||||
e.preventDefault();
|
||||
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 (background) {
|
||||
canvas.classList.add('fader-in');
|
||||
if (canvas) {
|
||||
if (background) {
|
||||
canvas.classList.remove('fader-out');
|
||||
} else {
|
||||
canvas.classList.add('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');
|
||||
}
|
||||
}
|
||||
animationLoop();
|
||||
});
|
||||
} else if (e.target.closest('.removetag')) {
|
||||
e.preventDefault();
|
||||
const removeBtn = e.target.closest('.removetag');
|
||||
const tagLink = removeBtn.previousElementSibling;
|
||||
|
||||
if(elem !== null) {
|
||||
if(localStorage.getItem('background') == undefined) {
|
||||
localStorage.setItem('background', 'true');
|
||||
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;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var background = localStorage.getItem('background') === 'true';
|
||||
var canvas = document.getElementById('bg');
|
||||
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;
|
||||
context.drawImage(video, 0, 0, cw, ch);
|
||||
window.requestAnimFrame(animationLoop);
|
||||
}
|
||||
|
||||
elem.addEventListener('play', animationLoop);
|
||||
}
|
||||
}
|
||||
|
||||
let tt = false;
|
||||
const stimeout = 500;
|
||||
const changePage = (e, pbwork = true) => {
|
||||
pbwork && document.querySelector("nav.navbar").classList.add("pbwork");
|
||||
!tt && (tt = setTimeout(() => e.click(), stimeout));
|
||||
};
|
||||
window.addEventListener('popstate', (e) => {
|
||||
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>
|
||||
const clickOnElementBinding = selector => () => (elem = document.querySelector(selector)) ? elem.click() : null;
|
||||
@@ -68,12 +514,12 @@ window.requestAnimFrame = (function(){
|
||||
"a": clickOnElementBinding("#next"),
|
||||
"ArrowRight": clickOnElementBinding("#prev"),
|
||||
"d": clickOnElementBinding("#prev"),
|
||||
"r": clickOnElementBinding("#random"),
|
||||
"r": clickOnElementBinding("#random, #nav-random"),
|
||||
" ": clickOnElementBinding("#f0ck-image")
|
||||
};
|
||||
document.addEventListener("keydown", e => {
|
||||
if(e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||
if(e.shiftKey || e.ctrlKey || e.metaKey || e.altKey)
|
||||
if (e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey)
|
||||
return;
|
||||
e.preventDefault();
|
||||
keybindings[e.key]();
|
||||
@@ -84,19 +530,21 @@ window.requestAnimFrame = (function(){
|
||||
// <image-responsive>
|
||||
const imgSize = e => new Promise((res, _) => {
|
||||
const i = new Image();
|
||||
i.addEventListener('load', function() {
|
||||
i.addEventListener('load', function () {
|
||||
res({ width: this.width, height: this.height });
|
||||
});
|
||||
i.src = e.src;
|
||||
});
|
||||
|
||||
// <wheeler>
|
||||
const wheelEventListener = function(event) {
|
||||
const wheelEventListener = function (event) {
|
||||
if (event.target.closest('.media-object, .steuerung')) {
|
||||
if (event.deltaY < 0) {
|
||||
document.getElementById('next').click();
|
||||
const el = document.getElementById('next');
|
||||
if (el && el.href && !el.href.endsWith('#')) el.click();
|
||||
} else if (event.deltaY > 0) {
|
||||
document.getElementById('prev').click();
|
||||
const el = document.getElementById('prev');
|
||||
if (el && el.href && !el.href.endsWith('#')) el.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -105,7 +553,7 @@ window.requestAnimFrame = (function(){
|
||||
// </wheeler>
|
||||
|
||||
|
||||
if(f0ckimage = document.querySelector("img#f0ck-image")) {
|
||||
if (f0ckimage = document.querySelector("img#f0ck-image")) {
|
||||
const f0ckimagescroll = document.querySelector("#image-scroll");
|
||||
|
||||
let isImageExpanded = false;
|
||||
@@ -132,48 +580,117 @@ window.requestAnimFrame = (function(){
|
||||
}
|
||||
// </image-responsive>
|
||||
|
||||
// <scroller>
|
||||
let tts = 0;
|
||||
const scroll_treshold = 1;
|
||||
if([...document.querySelectorAll("div.posts")].length === 1) {
|
||||
document.addEventListener("wheel", e => {
|
||||
if(Math.ceil(window.innerHeight + window.scrollY) >= document.querySelector('#main').offsetHeight && e.deltaY > 0) { // down
|
||||
if(elem = document.querySelector(".pagination > .next:not(.disabled)")) {
|
||||
if(tts < scroll_treshold) {
|
||||
document.querySelector("div#footbar").style.boxShadow = "inset 0px 4px 0px var(--footbar-color)";
|
||||
document.querySelector("div#footbar").style.color = "var(--footbar-color)";
|
||||
tts++;
|
||||
}
|
||||
else
|
||||
changePage(elem);
|
||||
// <infinite-scroll>
|
||||
const postsContainer = document.querySelector("div.posts");
|
||||
if (postsContainer) {
|
||||
// Infinite scroll state
|
||||
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 ? '▼' : '—';
|
||||
foot.style.color = 'transparent';
|
||||
}
|
||||
}
|
||||
else if(window.scrollY <= 0 && e.deltaY < 0) { // up
|
||||
if(elem = document.querySelector(".pagination > .prev:not(.disabled)")) {
|
||||
if(tts < scroll_treshold) {
|
||||
document.querySelector("nav.navbar").style.boxShadow = "0px 2px 0px var(--loading-indicator-color)";
|
||||
document.querySelector("nav.navbar").style.transition = ".2s ease-in-out";
|
||||
tts++;
|
||||
}
|
||||
else
|
||||
changePage(elem);
|
||||
}
|
||||
}
|
||||
else {
|
||||
tts = 0;
|
||||
document.querySelector("div#footbar").style.boxShadow = "unset";
|
||||
document.querySelector("div#footbar").style.color = "transparent";
|
||||
document.querySelector("nav.navbar").style.boxShadow = "unset";
|
||||
};
|
||||
|
||||
// Scroll detection - preload before reaching bottom
|
||||
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
|
||||
|
||||
window.addEventListener("scroll", () => {
|
||||
if (!document.querySelector('#main')) return;
|
||||
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const pageHeight = document.querySelector('#main').offsetHeight;
|
||||
const distanceFromBottom = pageHeight - scrollPosition;
|
||||
|
||||
// Load more when within PRELOAD_OFFSET pixels of bottom
|
||||
if (distanceFromBottom < PRELOAD_OFFSET && infiniteState.hasMore && !infiniteState.loading) {
|
||||
loadMoreItems();
|
||||
}
|
||||
});
|
||||
}
|
||||
// </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>
|
||||
const swipeRT = {
|
||||
xDown: null,
|
||||
@@ -198,33 +715,33 @@ window.requestAnimFrame = (function(){
|
||||
}, false);
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if(!swipeRT.xDown || !swipeRT.yDown)
|
||||
if (!swipeRT.xDown || !swipeRT.yDown)
|
||||
return;
|
||||
swipeRT.xDiff = swipeRT.xDown - e.touches[0].clientX;
|
||||
swipeRT.yDiff = swipeRT.yDown - e.touches[0].clientY;
|
||||
}, false);
|
||||
|
||||
document.addEventListener('touchend', e => {
|
||||
if(swipeRT.startEl !== e.target)
|
||||
if (swipeRT.startEl !== e.target)
|
||||
return;
|
||||
|
||||
const timeDiff = Date.now() - swipeRT.timeDown;
|
||||
let elem;
|
||||
|
||||
if(Math.abs(swipeRT.xDiff) > Math.abs(swipeRT.yDiff)) {
|
||||
if(Math.abs(swipeRT.xDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
||||
if(swipeRT.xDiff > 0) // left
|
||||
if (Math.abs(swipeRT.xDiff) > Math.abs(swipeRT.yDiff)) {
|
||||
if (Math.abs(swipeRT.xDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
||||
if (swipeRT.xDiff > 0) // left
|
||||
elem = document.querySelector(".pagination > .next:not(.disabled)");
|
||||
else // right
|
||||
elem = document.querySelector(".pagination > .prev:not(.disabled)");
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(Math.abs(swipeRT.yDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
||||
if(navbar = document.querySelector("nav.navbar") && document.querySelector("div.posts")) {
|
||||
if(swipeRT.yDiff > 0 && (window.innerHeight + window.scrollY) >= document.body.offsetHeight) // up
|
||||
if (Math.abs(swipeRT.yDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
||||
if (navbar = document.querySelector("nav.navbar") && document.querySelector("div.posts")) {
|
||||
if (swipeRT.yDiff > 0 && (window.innerHeight + window.scrollY) >= document.body.offsetHeight) // up
|
||||
elem = document.querySelector(".pagination > .next:not(.disabled)");
|
||||
else if(swipeRT.yDiff <= 0 && window.scrollY <= 0 && document.querySelector("div.posts")) // down
|
||||
else if (swipeRT.yDiff <= 0 && window.scrollY <= 0 && document.querySelector("div.posts")) // down
|
||||
elem = document.querySelector(".pagination > .prev:not(.disabled)");
|
||||
}
|
||||
}
|
||||
@@ -234,13 +751,13 @@ window.requestAnimFrame = (function(){
|
||||
swipeRT.yDown = null;
|
||||
swipeRT.timeDown = null;
|
||||
|
||||
if(elem)
|
||||
if (elem)
|
||||
changePage(elem);
|
||||
}, false);
|
||||
// </swipe>
|
||||
|
||||
// <visualizer>
|
||||
if(audioElement = document.querySelector("audio")) {
|
||||
if (audioElement = document.querySelector("audio")) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = 1920;
|
||||
@@ -267,7 +784,7 @@ window.requestAnimFrame = (function(){
|
||||
draw(data);
|
||||
}
|
||||
function draw(data) {
|
||||
data = [ ...data ];
|
||||
data = [...data];
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--accent");
|
||||
data.forEach((value, i) => {
|
||||
@@ -285,7 +802,7 @@ window.requestAnimFrame = (function(){
|
||||
// </visualizer>
|
||||
|
||||
// <mediakeys>
|
||||
if(elem = document.querySelector("#my-video") && "mediaSession" in navigator) {
|
||||
if (elem = document.querySelector("#my-video") && "mediaSession" in navigator) {
|
||||
const playpauseEvent = () => {
|
||||
video[video.paused ? 'play' : 'pause']();
|
||||
document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
|
||||
@@ -294,11 +811,11 @@ window.requestAnimFrame = (function(){
|
||||
navigator.mediaSession.setActionHandler('pause', playpauseEvent);
|
||||
navigator.mediaSession.setActionHandler('stop', playpauseEvent);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
if(link = document.querySelector(".pagination > .prev:not(.disabled)"))
|
||||
if (link = document.querySelector(".pagination > .prev:not(.disabled)"))
|
||||
changePage(link);
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
if(link = document.querySelector(".pagination > .next:not(.disabled)"))
|
||||
if (link = document.querySelector(".pagination > .next:not(.disabled)"))
|
||||
changePage(link);
|
||||
});
|
||||
}
|
||||
@@ -306,9 +823,77 @@ window.requestAnimFrame = (function(){
|
||||
|
||||
// <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">×</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>
|
||||
})();
|
||||
|
||||
|
||||
// disable default scroll event when mouse is on content div
|
||||
// this is useful for items that have a lot of tags for example: 12536
|
||||
const targetSelector = '.content';
|
||||
@@ -326,18 +911,21 @@ function onWheel(e) {
|
||||
|
||||
function init() {
|
||||
const el = document.querySelector(targetSelector);
|
||||
if (!el) return;
|
||||
el.addEventListener('mouseenter', () => isMouseOver = true);
|
||||
el.addEventListener('mouseleave', () => isMouseOver = false);
|
||||
window.addEventListener('wheel', onWheel, { passive: false });
|
||||
if (!el) return;
|
||||
el.addEventListener('mouseenter', () => isMouseOver = true);
|
||||
el.addEventListener('mouseleave', () => isMouseOver = false);
|
||||
window.addEventListener('wheel', onWheel, { passive: false });
|
||||
}
|
||||
|
||||
window.addEventListener('load', init);
|
||||
|
||||
document.getElementById('sbtForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('sbtInput').value.trim();
|
||||
if (input) {
|
||||
window.location.href = `/tag/${encodeURIComponent(input)}`;
|
||||
}
|
||||
});
|
||||
const sbtForm = document.getElementById('sbtForm');
|
||||
if (sbtForm) {
|
||||
sbtForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const input = document.getElementById('sbtInput').value.trim();
|
||||
if (input) {
|
||||
window.location.href = `/tag/${encodeURIComponent(input)}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const Cookie = {
|
||||
get: name => {
|
||||
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||
if(c) return decodeURIComponent(c);
|
||||
if (c) return decodeURIComponent(c);
|
||||
},
|
||||
set: (name, value, opts = {}) => {
|
||||
if(opts.days) {
|
||||
if (opts.days) {
|
||||
opts['max-age'] = opts.days * 60 * 60 * 24;
|
||||
delete opts.days;
|
||||
}
|
||||
@@ -17,8 +17,11 @@ const Cookie = {
|
||||
(() => {
|
||||
const acttheme = Cookie.get('theme') ?? "w0bm";
|
||||
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());
|
||||
if(acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
|
||||
if (acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
|
||||
document.documentElement.setAttribute("theme", acttheme);
|
||||
[...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
@@ -30,15 +33,15 @@ const Cookie = {
|
||||
}));
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if(e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||
return;
|
||||
const acttheme = Cookie.get('theme') ?? "w0bm";
|
||||
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
|
||||
const k = e.key;
|
||||
if(k === "t") {
|
||||
if (k === "t") {
|
||||
e.preventDefault();
|
||||
let i = themes.indexOf(acttheme);
|
||||
if(++i >= themes.length)
|
||||
if (++i >= themes.length)
|
||||
i = 0;
|
||||
document.documentElement.setAttribute("theme", 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 => {
|
||||
let f = Cookie.get('fullscreen');
|
||||
if(f == 1) {
|
||||
if (f == 1) {
|
||||
Cookie.set('fullscreen', 0);
|
||||
document.querySelector('html').setAttribute('res', '');
|
||||
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`;
|
||||
|
||||
348
public/s/js/upload.js
Normal file
348
public/s/js/upload.js
Normal 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">×</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();
|
||||
})();
|
||||
@@ -1,200 +1,239 @@
|
||||
(async () => {
|
||||
if(_addtag = document.querySelector("a#a_addtag")) {
|
||||
const postid = +document.querySelector("a.id-link").innerText;
|
||||
const poster = document.querySelector("a#a_username").innerText;
|
||||
let tags = [...document.querySelectorAll("#tags > .badge")].map(t => t.innerText.slice(0, -2));
|
||||
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if(method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for(const [ key, val ] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: [...document.querySelectorAll("#tags > .badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = _tags => {
|
||||
[...document.querySelectorAll("#tags > .badge")].forEach(tag => tag.parentElement.removeChild(tag));
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.innerHTML = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "mr-2");
|
||||
span.setAttribute('tooltip', tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
|
||||
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if (method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
};
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for (const [key, val] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const addtagClick = (ae = false) => {
|
||||
if(ae)
|
||||
ae.preventDefault();
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = _tags => {
|
||||
[...document.querySelectorAll("#tags > .badge")].forEach(tag => tag.parentElement.removeChild(tag));
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.innerHTML = tag.tag;
|
||||
|
||||
const insert = document.querySelector("a#a_addtag");
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "badge-light", "mr-2");
|
||||
span.classList.add("badge", "mr-2");
|
||||
span.setAttribute('tooltip', tag.user);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.size = "10";
|
||||
input.value = "";
|
||||
input.setAttribute("list", "testlist");
|
||||
input.setAttribute("autoComplete", "off");
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
span.insertAdjacentElement("afterbegin", input);
|
||||
insert.insertAdjacentElement("beforebegin", span);
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
|
||||
input.focus();
|
||||
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
};
|
||||
|
||||
let tt = null;
|
||||
let lastInput = '';
|
||||
const testList = document.querySelector('#testlist');
|
||||
const addtagClick = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, tags } = ctx;
|
||||
|
||||
input.addEventListener("keyup", async e => {
|
||||
if(e.key === "Enter") {
|
||||
const tmptag = input.value?.trim();
|
||||
if(tags.includes(tmptag))
|
||||
return alert("tag already exists");
|
||||
const res = await post("/api/v2/admin/" + postid + "/tags", {
|
||||
tagname: tmptag
|
||||
});
|
||||
if(!res.success) {
|
||||
alert(res.msg);
|
||||
return false;
|
||||
}
|
||||
tags = res.tags.map(t => t.tag);
|
||||
renderTags(res.tags);
|
||||
addtagClick();
|
||||
testList.innerText = "";
|
||||
}
|
||||
else if(e.key === "Escape") {
|
||||
span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
}
|
||||
else {
|
||||
if(tt != null)
|
||||
clearTimeout(tt);
|
||||
const insert = document.querySelector("a#a_addtag");
|
||||
// Check if input already exists to prevent duplicates
|
||||
if (insert.previousElementSibling && insert.previousElementSibling.querySelector('input')) {
|
||||
insert.previousElementSibling.querySelector('input').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
tt = setTimeout(async () => {
|
||||
tt = null;
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "badge-light", "mr-2");
|
||||
|
||||
const tmptag = input.value?.trim();
|
||||
const input = document.createElement("input");
|
||||
input.size = "10";
|
||||
input.value = "";
|
||||
input.setAttribute("list", "testlist");
|
||||
input.setAttribute("autoComplete", "off");
|
||||
|
||||
if(tmptag == lastInput || tmptag.length <= 1)
|
||||
return false;
|
||||
span.insertAdjacentElement("afterbegin", input);
|
||||
insert.insertAdjacentElement("beforebegin", span);
|
||||
|
||||
testList.innerText = "";
|
||||
lastInput = tmptag;
|
||||
|
||||
const res = await get('/api/v2/admin/tags/suggest', {
|
||||
q: tmptag
|
||||
});
|
||||
|
||||
for(const entry of res.suggestions) {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.tag;
|
||||
input.focus();
|
||||
|
||||
if(!/fox/.test(navigator.userAgent))
|
||||
option.innerText = `tagged ${entry.tagged} times (score: ${entry.score.toFixed(2)})`;
|
||||
let tt = null;
|
||||
let lastInput = '';
|
||||
const testList = document.querySelector('#testlist');
|
||||
|
||||
testList.insertAdjacentElement('beforeEnd', option);
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
input.addEventListener("keyup", async e => {
|
||||
if (e.key === "Enter") {
|
||||
const tmptag = input.value?.trim();
|
||||
// Check fresh tags from DOM just in case? Or use captured tags?
|
||||
// Using captured 'tags' from when clicked is safe enough for immediate check.
|
||||
if (tags.includes(tmptag))
|
||||
return alert("tag already exists");
|
||||
|
||||
input.addEventListener("focusout", ie => {
|
||||
if(input.value.length === 0)
|
||||
input.parentElement.parentElement.removeChild(input.parentElement);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e = false) => {
|
||||
if(e)
|
||||
e.preventDefault();
|
||||
|
||||
const res = await (await fetch('/api/v2/admin/' + encodeURIComponent(postid) + '/tags/toggle', {
|
||||
method: 'PUT'
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
};
|
||||
|
||||
const toggleFavEvent = async e => {
|
||||
const res = await post('/api/v2/admin/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if(res.success) {
|
||||
const fav = document.querySelector("svg#a_favo > use").href;
|
||||
fav.baseVal = '/s/img/iconset.svg#heart_' + (fav.baseVal.match(/heart_(regular|solid)$/)[1] == "solid" ? "regular" : "solid");
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('span#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
|
||||
favcontainer.hidden = !(favcontainer.hidden || res.favs.length > 0);
|
||||
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}/favs`;
|
||||
a.setAttribute('tooltip', f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/t/${f.avatar}.webp`;
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.insertAdjacentElement('beforeend', img);
|
||||
favcontainer.insertAdjacentElement('beforeend', a);
|
||||
favcontainer.innerHTML += " ";
|
||||
const res = await post("/api/v2/admin/" + postid + "/tags", {
|
||||
tagname: tmptag
|
||||
});
|
||||
if (!res.success) {
|
||||
alert(res.msg);
|
||||
return false;
|
||||
}
|
||||
// No need to update 'tags' local var, renderTags updates DOM, and next click reads DOM.
|
||||
renderTags(res.tags);
|
||||
|
||||
// Remove input and reset
|
||||
span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
|
||||
// Re-open input? Original code called addtagClick() again.
|
||||
addtagClick();
|
||||
}
|
||||
else if (e.key === "Escape") {
|
||||
span.parentElement.removeChild(span);
|
||||
testList.innerText = "";
|
||||
}
|
||||
else {
|
||||
// lul
|
||||
if (tt != null)
|
||||
clearTimeout(tt);
|
||||
|
||||
tt = setTimeout(async () => {
|
||||
tt = null;
|
||||
|
||||
const tmptag = input.value?.trim();
|
||||
|
||||
if (tmptag == lastInput || tmptag.length <= 1)
|
||||
return false;
|
||||
|
||||
testList.innerText = "";
|
||||
lastInput = tmptag;
|
||||
|
||||
const res = await get('/api/v2/admin/tags/suggest', {
|
||||
q: tmptag
|
||||
});
|
||||
|
||||
for (const entry of res.suggestions) {
|
||||
const option = document.createElement('option');
|
||||
option.value = entry.tag;
|
||||
|
||||
if (!/fox/.test(navigator.userAgent))
|
||||
option.innerText = `tagged ${entry.tagged} times (score: ${entry.score.toFixed(2)})`;
|
||||
|
||||
testList.insertAdjacentElement('beforeEnd', option);
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
_addtag.addEventListener("click", addtagClick);
|
||||
document.querySelector("a#a_toggle").addEventListener("click", toggleEvent);
|
||||
document.querySelector("svg#a_favo").addEventListener("click", toggleFavEvent);
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
if(e.target.tagName === "INPUT")
|
||||
return;
|
||||
if(e.key === "p")
|
||||
toggleEvent();
|
||||
else if(e.key === "i")
|
||||
addtagClick();
|
||||
else if(e.key === "x")
|
||||
deleteButtonEvent();
|
||||
else if(e.key === "f")
|
||||
toggleFavEvent();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if(document.location.pathname === '/settings') {
|
||||
input.addEventListener("focusout", ie => {
|
||||
// Small delay to allow click events on suggestions or other checks?
|
||||
// Original code:
|
||||
if (input.value.length === 0)
|
||||
input.parentElement.parentElement.removeChild(input.parentElement);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await (await fetch('/api/v2/admin/' + encodeURIComponent(postid) + '/tags/toggle', {
|
||||
method: 'PUT'
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
};
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
// e is the click event or undefined
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await post('/api/v2/admin/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
const fav = document.querySelector("svg#a_favo > use").href;
|
||||
fav.baseVal = '/s/img/iconset.svg#heart_' + (fav.baseVal.match(/heart_(regular|solid)$/)[1] == "solid" ? "regular" : "solid");
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('span#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
|
||||
favcontainer.hidden = !(favcontainer.hidden || res.favs.length > 0);
|
||||
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}/favs`;
|
||||
a.setAttribute('tooltip', f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `/t/${f.avatar}.webp`;
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.insertAdjacentElement('beforeend', img);
|
||||
favcontainer.insertAdjacentElement('beforeend', a);
|
||||
favcontainer.innerHTML += " ";
|
||||
});
|
||||
}
|
||||
else {
|
||||
// lul
|
||||
}
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.matches("a#a_addtag")) {
|
||||
addtagClick(e);
|
||||
} else if (e.target.matches("a#a_toggle")) {
|
||||
toggleEvent(e);
|
||||
} else if (e.target.closest("svg#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||
return;
|
||||
const ctx = getContext();
|
||||
if (!ctx) return; // Only trigger if on an item page
|
||||
|
||||
if (e.key === "p")
|
||||
toggleEvent();
|
||||
else if (e.key === "i")
|
||||
addtagClick();
|
||||
else if (e.key === "f")
|
||||
toggleFavEvent();
|
||||
});
|
||||
|
||||
// Settings page logic (unchanged essentially, but kept inside IIFE scope)
|
||||
if (document.location.pathname === '/settings') {
|
||||
const saveAvatar = async e => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -209,20 +248,23 @@
|
||||
const code = res.status;
|
||||
res = await res.json();
|
||||
|
||||
switch(code) {
|
||||
switch (code) {
|
||||
case 200:
|
||||
document.querySelector('#img_avatar').src = `/t/${avatar}.webp`;
|
||||
document.querySelector('img.avatar').src = `/t/${avatar}.webp`;
|
||||
break;
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
break;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelector('input#s_avatar').addEventListener('click', saveAvatar);
|
||||
document.querySelector('input[name="i_avatar"]').addEventListener('keyup', async e => {
|
||||
if(e.key === 'Enter')
|
||||
const sAvatar = document.querySelector('input#s_avatar');
|
||||
if (sAvatar) sAvatar.addEventListener('click', saveAvatar);
|
||||
|
||||
const iAvatar = document.querySelector('input[name="i_avatar"]');
|
||||
if (iAvatar) iAvatar.addEventListener('keyup', async e => {
|
||||
if (e.key === 'Enter')
|
||||
await saveAvatar(e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ const epochs = [
|
||||
["second", 1]
|
||||
];
|
||||
const getDuration = timeAgoInSeconds => {
|
||||
for(let [name, seconds] of epochs) {
|
||||
for (let [name, seconds] of epochs) {
|
||||
const interval = ~~(timeAgoInSeconds / seconds);
|
||||
if(interval >= 1) return {
|
||||
if (interval >= 1) return {
|
||||
interval: interval,
|
||||
epoch: name
|
||||
};
|
||||
@@ -32,7 +32,9 @@ export default new class {
|
||||
return (Math.round((b * 8 / s / 1e6) * 1e4) / 1e4);
|
||||
};
|
||||
timeAgo(date) {
|
||||
const { interval, epoch } = getDuration(~~((new Date() - new Date(date)) / 1e3));
|
||||
const duration = getDuration(~~((new Date() - new Date(date)) / 1e3));
|
||||
if (!duration) return "just now";
|
||||
const { interval, epoch } = duration;
|
||||
return `${interval} ${epoch}${interval === 1 ? "" : "s"} ago`;
|
||||
};
|
||||
md5(str) {
|
||||
@@ -40,19 +42,19 @@ export default new class {
|
||||
};
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
switch(mode) {
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2 group by item_id)";
|
||||
break;
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = "items.id not in (select item_id from tags_assign group by item_id)";
|
||||
break;
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1 group by item_id)";
|
||||
break;
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
};
|
||||
@@ -61,14 +63,14 @@ export default new class {
|
||||
};
|
||||
genLink(env) {
|
||||
const link = [];
|
||||
if(env.tag) link.push("tag", env.tag);
|
||||
if(env.user) link.push("user", env.user, env.type ?? 'f0cks');
|
||||
if(env.mime?.length > 2) link.push(env.mime);
|
||||
if (env.tag) link.push("tag", env.tag);
|
||||
if (env.user) link.push("user", env.user, env.type ?? 'f0cks');
|
||||
if (env.mime?.length > 2) link.push(env.mime);
|
||||
|
||||
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||
if(!tmp.endsWith('/'))
|
||||
if (!tmp.endsWith('/'))
|
||||
tmp = tmp + '/';
|
||||
if(!tmp.startsWith('/'))
|
||||
if (!tmp.startsWith('/'))
|
||||
tmp = '/' + tmp;
|
||||
|
||||
return {
|
||||
@@ -77,7 +79,7 @@ export default new class {
|
||||
};
|
||||
};
|
||||
parseTag(tag) {
|
||||
if(!tag)
|
||||
if (!tag)
|
||||
return null;
|
||||
return decodeURI(tag);
|
||||
}
|
||||
@@ -129,7 +131,7 @@ export default new class {
|
||||
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
||||
};
|
||||
async verify(str, hash) {
|
||||
const [ salt, key ] = hash.substring(6).split(":");
|
||||
const [salt, key] = hash.substring(6).split(":");
|
||||
const keyBuffer = Buffer.from(key, "hex");
|
||||
const derivedKey = await scrypt(str, salt, 64);
|
||||
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
||||
@@ -143,20 +145,20 @@ export default new class {
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by "tags".id asc
|
||||
`;
|
||||
for(let t = 0; t < tags.length; t++) {
|
||||
if(tags[t].tag.startsWith(">"))
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
if (tags[t].tag.startsWith(">"))
|
||||
tags[t].badge = "badge-greentext badge-light";
|
||||
else if(tags[t].normalized === "ukraine")
|
||||
else if (tags[t].normalized === "ukraine")
|
||||
tags[t].badge = "badge-ukraine badge-light";
|
||||
else if(/[а-яё]/.test(tags[t].normalized) || tags[t].normalized === "russia")
|
||||
else if (/[а-яё]/.test(tags[t].normalized) || tags[t].normalized === "russia")
|
||||
tags[t].badge = "badge-russia badge-light";
|
||||
else if(tags[t].normalized === "german")
|
||||
else if (tags[t].normalized === "german")
|
||||
tags[t].badge = "badge-german badge-light";
|
||||
else if(tags[t].normalized === "dutch")
|
||||
else if (tags[t].normalized === "dutch")
|
||||
tags[t].badge = "badge-dutch badge-light";
|
||||
else if(tags[t].normalized === "sfw")
|
||||
else if (tags[t].normalized === "sfw")
|
||||
tags[t].badge = "badge-success";
|
||||
else if(tags[t].normalized === "nsfw")
|
||||
else if (tags[t].normalized === "nsfw")
|
||||
tags[t].badge = "badge-danger";
|
||||
else
|
||||
tags[t].badge = "badge-light";
|
||||
@@ -183,11 +185,11 @@ export default new class {
|
||||
const tmp = Object.values(res)[0];
|
||||
|
||||
let nsfw = false;
|
||||
if(tmp.neutral >= .7)
|
||||
if (tmp.neutral >= .7)
|
||||
nsfw = false;
|
||||
else if((tmp.sexy + tmp.porn + tmp.hentai) >= .7)
|
||||
else if ((tmp.sexy + tmp.porn + tmp.hentai) >= .7)
|
||||
nsfw = true;
|
||||
else if(tmp.drawings >= .4)
|
||||
else if (tmp.drawings >= .4)
|
||||
nsfw = false;
|
||||
else
|
||||
nsfw = false;
|
||||
@@ -197,7 +199,7 @@ export default new class {
|
||||
score: tmp.sexy + tmp.porn + tmp.hentai,
|
||||
scores: tmp
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
async getDefaultAvatar() {
|
||||
return (await db`
|
||||
@@ -212,7 +214,7 @@ export default new class {
|
||||
|
||||
// meddlware admin
|
||||
async auth(req, res, next) {
|
||||
if(!req.session || !req.session.admin) {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
@@ -223,7 +225,7 @@ export default new class {
|
||||
|
||||
// meddlware user
|
||||
async userauth(req, res, next) {
|
||||
if(!req.session) {
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
@@ -232,14 +234,14 @@ export default new class {
|
||||
return next();
|
||||
};
|
||||
|
||||
async loggedin(req, res, next) {
|
||||
if(!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
return next();
|
||||
async loggedin(req, res, next) {
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
@@ -8,13 +8,13 @@ const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
|
||||
export default {
|
||||
getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => {
|
||||
const user = o.user ? decodeURI(o.user) : null;
|
||||
const tag = lib.parseTag(o.tag ?? null);
|
||||
const mime = o.mime ?? null;
|
||||
const page = +(o.page ?? 1);
|
||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||
const eps = o.limit ?? cfg.websrv.eps;
|
||||
|
||||
const user = o.user ? decodeURI(o.user) : null;
|
||||
const tag = lib.parseTag(o.tag ?? null);
|
||||
const mime = o.mime ?? null;
|
||||
const page = +(o.page ?? 1);
|
||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||
const eps = o.limit ?? cfg.websrv.eps;
|
||||
|
||||
const tmp = { user, tag, mime, smime, page, mode: o.mode };
|
||||
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 "user" on "user".id = favorites.user_id
|
||||
where
|
||||
${ db.unsafe(modequery) }
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = 'true'
|
||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : 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`` }
|
||||
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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, tags.tag
|
||||
`)?.length || 0;
|
||||
|
||||
if(!total || total === 0) {
|
||||
if (!total || total === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - no f0cks given"
|
||||
@@ -61,13 +61,13 @@ export default {
|
||||
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)
|
||||
where
|
||||
${ db.unsafe(modequery) }
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = 'true'
|
||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : 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`` }
|
||||
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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, tags.tag, ta.tag_id
|
||||
order by items.id desc
|
||||
offset ${offset}
|
||||
@@ -75,11 +75,11 @@ export default {
|
||||
`;
|
||||
|
||||
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);
|
||||
|
||||
|
||||
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' });
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
items: rows,
|
||||
@@ -96,54 +96,61 @@ export default {
|
||||
};
|
||||
},
|
||||
getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => {
|
||||
const user = o.user ? decodeURI(o.user) : null;
|
||||
const tag = lib.parseTag(o.tag ?? null);
|
||||
const mime = (o.mime ?? "");
|
||||
const itemid = +(o.itemid ?? 404);
|
||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||
|
||||
const user = o.user ? decodeURI(o.user) : null;
|
||||
const tag = lib.parseTag(o.tag ?? null);
|
||||
const mime = (o.mime ?? "");
|
||||
const itemid = +(o.itemid ?? 404);
|
||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||
|
||||
const tmp = { user, tag, mime, smime, itemid };
|
||||
|
||||
|
||||
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
||||
|
||||
if(itemid === 404) {
|
||||
|
||||
if (itemid === 404) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - f0ck not found"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const items = await db`
|
||||
select distinct on (items.id)
|
||||
items.*
|
||||
from items
|
||||
left join tags_assign on tags_assign.item_id = items.id
|
||||
left join tags on tags.id = tags_assign.tag_id
|
||||
left join favorites on favorites.item_id = items.id
|
||||
left join "user" on "user".id = favorites.user_id
|
||||
${o.fav
|
||||
? 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)
|
||||
where
|
||||
${ db.unsafe(modequery) }
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = 'true'
|
||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : 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`` }
|
||||
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||
${o.fav ? db`and "user"."user" ilike ${user}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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, tags.tag, ta.tag_id
|
||||
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 actitem = items[item];
|
||||
|
||||
if(!actitem) { // sfw-check!
|
||||
|
||||
console.log('[GETF0CK DEBUG] findIndex result:', item, 'actitem exists:', !!actitem);
|
||||
|
||||
if (!actitem) { // sfw-check!
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const tags = await lib.getTags(itemid);
|
||||
const cheat = [...new Set(items.slice(Math.max(0, item - 3), item + 4).map(i => i.id))];
|
||||
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: '' });
|
||||
@@ -154,14 +161,14 @@ export default {
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
where "favorites".item_id = ${itemid}
|
||||
`;
|
||||
|
||||
|
||||
let coverart = true;
|
||||
try {
|
||||
await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`);
|
||||
} catch(err) {
|
||||
} catch (err) {
|
||||
coverart = false;
|
||||
}
|
||||
|
||||
|
||||
const data = {
|
||||
success: true,
|
||||
user: {
|
||||
@@ -201,16 +208,16 @@ export default {
|
||||
tmp
|
||||
};
|
||||
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 tag = lib.parseTag(o.tag ?? null);
|
||||
const mime = (o.mime ?? "");
|
||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||
|
||||
|
||||
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
||||
|
||||
|
||||
let item;
|
||||
|
||||
|
||||
if (o.fav && user) {
|
||||
// Special case: random from user's favorites
|
||||
item = await db`
|
||||
@@ -219,10 +226,15 @@ export default {
|
||||
from favorites
|
||||
inner join items on favorites.item_id = items.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
|
||||
"user".user ilike ${'%' + user + '%'}
|
||||
${db.unsafe(modequery)}
|
||||
and "user".user ilike ${'%' + user + '%'}
|
||||
and items.active = 'true'
|
||||
${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()
|
||||
limit 1
|
||||
`;
|
||||
@@ -246,20 +258,20 @@ export default {
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
if (item.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "no f0cks found :("
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks' });
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
link: link,
|
||||
itemid: item[0].id
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { promises as fs } from "fs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
if(req.cookies.session) {
|
||||
if (req.cookies.session) {
|
||||
return res.reply({
|
||||
body: tpl.render('error', {
|
||||
message: "you're already logged in lol",
|
||||
@@ -17,7 +17,7 @@ export default (router, tpl) => {
|
||||
body: tpl.render("login", { theme: req.cookies.theme ?? "f0ck" })
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.post(/^\/login(\/)?$/, async (req, res) => {
|
||||
const user = await db`
|
||||
select *
|
||||
@@ -25,9 +25,9 @@ export default (router, tpl) => {
|
||||
where "login" = ${req.post.username.toLowerCase()}
|
||||
limit 1
|
||||
`;
|
||||
if(user.length === 0)
|
||||
if (user.length === 0)
|
||||
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" });
|
||||
const stamp = ~~(Date.now() / 1e3);
|
||||
|
||||
@@ -36,7 +36,7 @@ export default (router, tpl) => {
|
||||
where last_action <= ${(Date.now() - 6048e5)}
|
||||
and kmsi = 0
|
||||
`;
|
||||
|
||||
|
||||
const session = lib.md5(lib.createID());
|
||||
const blah = {
|
||||
user_id: user[0].id,
|
||||
@@ -49,8 +49,7 @@ export default (router, tpl) => {
|
||||
};
|
||||
|
||||
await db`
|
||||
insert into "user_sessions" ${
|
||||
db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
|
||||
insert into "user_sessions" ${db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -60,16 +59,16 @@ export default (router, tpl) => {
|
||||
"Location": "/"
|
||||
}).end();
|
||||
});
|
||||
|
||||
|
||||
router.get(/^\/logout$/, lib.loggedin, async (req, res) => {
|
||||
const usersession = await db`
|
||||
select *
|
||||
from "user_sessions"
|
||||
where id = ${+req.session.sess_id}
|
||||
`;
|
||||
if(usersession.length === 0)
|
||||
if (usersession.length === 0)
|
||||
return res.reply({ body: "nope 2" });
|
||||
|
||||
|
||||
await db`
|
||||
delete from "user_sessions"
|
||||
where id = ${+req.session.sess_id}
|
||||
@@ -80,7 +79,7 @@ export default (router, tpl) => {
|
||||
"Location": "/"
|
||||
}).end();
|
||||
});
|
||||
|
||||
|
||||
router.get(/^\/login\/pwdgen$/, async (req, res) => {
|
||||
res.reply({
|
||||
body: "<form action=\"/login/pwdgen\" method=\"post\"><input type=\"text\" name=\"pwd\" placeholder=\"pwd\" /><input type=\"submit\" value=\"f0ck it\" /></form>"
|
||||
@@ -102,7 +101,7 @@ export default (router, tpl) => {
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
router.get(/^\/admin\/sessions(\/)?$/, lib.auth, async (req, res) => {
|
||||
const rows = await db`
|
||||
select "user_sessions".*, "user".user
|
||||
@@ -110,7 +109,7 @@ export default (router, tpl) => {
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
order by "user_sessions".last_used desc
|
||||
`;
|
||||
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("admin/sessions", {
|
||||
session: req.session,
|
||||
@@ -121,79 +120,176 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
// router.get(/^\/admin\/log(\/)?$/, lib.auth, async (req, res) => {
|
||||
// // Funktioniert ohne systemd service natürlich nicht.
|
||||
// exec("journalctl -qeu f0ck --no-pager", (err, stdout) => {
|
||||
// res.reply({
|
||||
// body: tpl.render("admin/log", {
|
||||
// log: stdout.split("\n").slice(0, -1),
|
||||
// tmp: null
|
||||
// }, req)
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
router.get(/^\/admin\/approve\/?/, lib.auth, async (req, res) => {
|
||||
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`
|
||||
});
|
||||
}
|
||||
|
||||
// router.get(/^\/admin\/recover\/?/, lib.auth, async (req, res) => {
|
||||
// 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}`;
|
||||
|
||||
// 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(_=>{});
|
||||
// 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(_=>{});
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
|
||||
await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
|
||||
}
|
||||
}
|
||||
|
||||
// if(f0ck[0].mime.startsWith('audio')) {
|
||||
// await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{});
|
||||
// await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{});
|
||||
// }
|
||||
return res.writeHead(302, {
|
||||
"Location": `/${id}`
|
||||
}).end();
|
||||
}
|
||||
|
||||
// return res.reply({
|
||||
// body: `f0ck ${id} recovered. <a href="/admin/recover">back</a>`
|
||||
// });
|
||||
// }
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// const _posts = await db`
|
||||
// select id, mime, username
|
||||
// from "items"
|
||||
// where
|
||||
// active = 'false'
|
||||
// order by id desc
|
||||
// `;
|
||||
const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c;
|
||||
const pages = Math.ceil(total / limit);
|
||||
|
||||
// if(_posts.length === 0) {
|
||||
// return res.reply({
|
||||
// body: 'blah'
|
||||
// });
|
||||
// }
|
||||
const _posts = await db`
|
||||
select id, mime, username, dest
|
||||
from "items"
|
||||
where
|
||||
active = 'false'
|
||||
order by id desc
|
||||
limit ${limit} offset ${offset}
|
||||
`;
|
||||
|
||||
// const posts = await Promise.all(_posts.map(async p => ({
|
||||
// ...p,
|
||||
// thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64')
|
||||
// })));
|
||||
if (_posts.length === 0 && page > 1) {
|
||||
// if page empty, maybe redirect to last page or page 1?
|
||||
// Just render empty for now
|
||||
}
|
||||
|
||||
// res.reply({
|
||||
// body: tpl.render('admin/recover', {
|
||||
// posts,
|
||||
// tmp: null
|
||||
// }, req)
|
||||
// });
|
||||
// });
|
||||
if (_posts.length === 0) {
|
||||
return res.reply({
|
||||
body: tpl.render('admin/approve', { posts: [], pages: 0, page: 1, tmp: null }, req)
|
||||
});
|
||||
}
|
||||
|
||||
const posts = await Promise.all(_posts.map(async p => {
|
||||
// Try to get thumbnail from public or deleted
|
||||
let thumb;
|
||||
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;
|
||||
};
|
||||
|
||||
135
src/inc/routes/ajax.mjs
Normal file
135
src/inc/routes/ajax.mjs
Normal file
@@ -0,0 +1,135 @@
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import url from "url";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
// flummpress uses req.url.qs for query string parameters
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
let contextUrl = `/${req.params.itemid}`;
|
||||
if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`;
|
||||
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({
|
||||
itemid: req.params.itemid,
|
||||
mode: req.session.mode,
|
||||
session: !!req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
mime: query.mime,
|
||||
fav: query.fav === 'true'
|
||||
});
|
||||
|
||||
console.log('[AJAX DEBUG] getf0ck result:', { success: data.success, message: data.message });
|
||||
|
||||
if (!data.success) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: "<h1>404 - Not f0cked</h1>"
|
||||
});
|
||||
}
|
||||
|
||||
// Inject session into data for the template
|
||||
// We clone session to avoid unintended side effects or collisions
|
||||
if (req.session) {
|
||||
data.session = { ...req.session };
|
||||
// data.user comes from f0cklib (uploader). req.session.user is logged-in user string.
|
||||
// Templates use session.user for matching favorites. We must preserve it.
|
||||
// if (data.session.user) delete data.session.user; // REMOVED THIS
|
||||
} else {
|
||||
data.session = false;
|
||||
}
|
||||
|
||||
// Inject missing variables normally provided by req or middleware
|
||||
data.url = { pathname: `/${req.params.itemid}` }; // Template expects url.pathname
|
||||
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
|
||||
|
||||
// Render both the item content and the pagination
|
||||
const itemHtml = tpl.render('ajax-item', data);
|
||||
const paginationHtml = tpl.render('snippets/pagination', data);
|
||||
|
||||
// Return JSON response
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import { promises as fs } from "fs";
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.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 => {
|
||||
router.group(/^\/api\/v2/, group => {
|
||||
group.get(/$/, (req, res) => {
|
||||
@@ -11,26 +14,43 @@ export default router => {
|
||||
});
|
||||
|
||||
group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => {
|
||||
const user = req.url.split[3] === "user" ? req.url.split[4] : "%";
|
||||
const mime = (allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "") + "%";
|
||||
|
||||
const pathUser = req.url.split[3] === "user" ? req.url.split[4] : null;
|
||||
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`
|
||||
select *
|
||||
select "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
|
||||
${db.unsafe(modequery)} and
|
||||
mime ilike ${mime} and
|
||||
username ilike ${user} and
|
||||
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()
|
||||
limit 1
|
||||
`;
|
||||
|
||||
|
||||
return res.json({
|
||||
success: rows.length > 0,
|
||||
items: rows.length > 0 ? rows[0] : []
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group.get(/\/items\/get/, async (req, res) => {
|
||||
let eps = 150;
|
||||
|
||||
@@ -51,17 +71,15 @@ export default router => {
|
||||
where
|
||||
${db.unsafe(modequery)} and
|
||||
active = 'true'
|
||||
${
|
||||
opt.older
|
||||
? db`and id <= ${opt.older}`
|
||||
: opt.newer
|
||||
? db`and id >= ${opt.newer}`
|
||||
: db``
|
||||
}
|
||||
order by id ${
|
||||
opt.newer
|
||||
? db`asc`
|
||||
: db`desc`
|
||||
${opt.older
|
||||
? db`and id <= ${opt.older}`
|
||||
: opt.newer
|
||||
? db`and id >= ${opt.newer}`
|
||||
: db``
|
||||
}
|
||||
order by id ${opt.newer
|
||||
? db`asc`
|
||||
: db`desc`
|
||||
}
|
||||
limit ${eps}
|
||||
`).sort((a, b) => b.id - a.id);
|
||||
@@ -73,10 +91,10 @@ export default router => {
|
||||
items: rows
|
||||
}, 200);
|
||||
});
|
||||
|
||||
|
||||
group.get(/\/item\/[0-9]+$/, async (req, res) => {
|
||||
const id = +req.url.split[3];
|
||||
|
||||
|
||||
const item = await db`
|
||||
select *
|
||||
from "items"
|
||||
@@ -97,14 +115,14 @@ export default router => {
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if(item.length === 0) {
|
||||
|
||||
if (item.length === 0) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no items found'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const rows = {
|
||||
...item[0],
|
||||
...{
|
||||
@@ -118,11 +136,11 @@ export default router => {
|
||||
rows
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
group.get(/\/user\/.*(\/\d+)?$/, async (req, res) => {
|
||||
const user = req.url.split[3];
|
||||
const eps = +req.url.split[4] || 50;
|
||||
|
||||
|
||||
const rows = db`
|
||||
select id, mime, size, src, stamp, userchannel, username, usernetwork
|
||||
from "items"
|
||||
@@ -130,7 +148,7 @@ export default router => {
|
||||
order by stamp desc
|
||||
limit ${+eps}
|
||||
`;
|
||||
|
||||
|
||||
return res.json({
|
||||
success: rows.length > 0,
|
||||
items: rows.length > 0 ? rows : []
|
||||
@@ -140,7 +158,7 @@ export default router => {
|
||||
// tags lol
|
||||
|
||||
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({
|
||||
success: false,
|
||||
msg: 'missing tagname or newtag',
|
||||
@@ -154,7 +172,7 @@ export default router => {
|
||||
const tagname = decodeURIComponent(req.params.tagname);
|
||||
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({
|
||||
msg: 'f0ck you'
|
||||
}, 405); // method not allowed
|
||||
@@ -166,8 +184,8 @@ export default router => {
|
||||
where tag = ${tagname}
|
||||
limit 1
|
||||
`)[0];
|
||||
|
||||
if(!tmptag) {
|
||||
|
||||
if (!tmptag) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no tag found'
|
||||
@@ -175,10 +193,9 @@ export default router => {
|
||||
}
|
||||
|
||||
const q = (await db`
|
||||
update "tags" set ${
|
||||
db({
|
||||
tag: newtag
|
||||
}, 'tag')
|
||||
update "tags" set ${db({
|
||||
tag: newtag
|
||||
}, 'tag')
|
||||
}
|
||||
where tag = ${tagname}
|
||||
returning *
|
||||
@@ -195,7 +212,7 @@ export default router => {
|
||||
|
||||
const searchString = req.url.qs.q;
|
||||
|
||||
if(searchString?.length <= 1) {
|
||||
if (searchString?.length <= 1) {
|
||||
reply.error = 'too short lol';
|
||||
return res.json(reply);
|
||||
}
|
||||
@@ -212,7 +229,7 @@ export default router => {
|
||||
`;
|
||||
reply.success = true;
|
||||
reply.suggestions = search(q, searchString);
|
||||
} catch(err) {
|
||||
} catch (err) {
|
||||
reply.error = err.msg;
|
||||
}
|
||||
|
||||
@@ -220,7 +237,7 @@ export default router => {
|
||||
});
|
||||
|
||||
group.post(/\/admin\/deletepost$/, lib.auth, async (req, res) => {
|
||||
if(!req.post.postid) {
|
||||
if (!req.post.postid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no postid'
|
||||
@@ -228,7 +245,7 @@ export default router => {
|
||||
}
|
||||
const id = +req.post.postid;
|
||||
|
||||
if(id <= 1) {
|
||||
if (id <= 1) {
|
||||
return res.json({
|
||||
success: false
|
||||
});
|
||||
@@ -243,7 +260,7 @@ export default router => {
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if(f0ck.length === 0) {
|
||||
if (f0ck.length === 0) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: `f0ck ${id}: f0ck not found`
|
||||
@@ -251,15 +268,15 @@ export default router => {
|
||||
}
|
||||
|
||||
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/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_=>{});
|
||||
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_=>{});
|
||||
await fs.unlink(`./public/t/${id}.webp`).catch(_=>{});
|
||||
|
||||
if(f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{});
|
||||
await fs.unlink(`./public/ca/${id}.webp`).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.unlink(`./public/b/${f0ck[0].dest}`).catch(_ => { });
|
||||
await fs.unlink(`./public/t/${id}.webp`).catch(_ => { });
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { });
|
||||
await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { });
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -269,14 +286,14 @@ export default router => {
|
||||
|
||||
group.post(/\/admin\/togglefav$/, lib.loggedin, async (req, res) => {
|
||||
const postid = +req.post.postid;
|
||||
|
||||
|
||||
let favs = await db`
|
||||
select user_id
|
||||
from "favorites"
|
||||
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
|
||||
await db`
|
||||
delete from "favorites"
|
||||
@@ -287,11 +304,10 @@ export default router => {
|
||||
else {
|
||||
// add fav
|
||||
await db`
|
||||
insert into "favorites" ${
|
||||
db({
|
||||
item_id: +postid,
|
||||
user_id: +req.session.id
|
||||
}, 'item_id', 'user_id')
|
||||
insert into "favorites" ${db({
|
||||
item_id: +postid,
|
||||
user_id: +req.session.id
|
||||
}, 'item_id', 'user_id')
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -310,7 +326,7 @@ export default router => {
|
||||
favs
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
260
src/inc/routes/apiv2/upload.mjs
Normal file
260
src/inc/routes/apiv2/upload.mjs
Normal 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;
|
||||
};
|
||||
56
src/inc/routes/tag_image.mjs
Normal file
56
src/inc/routes/tag_image.mjs
Normal 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 '<';
|
||||
case '>': return '>';
|
||||
case '&': return '&';
|
||||
case '\'': return ''';
|
||||
case '"': return '"';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -4,8 +4,10 @@ import lib from "./inc/lib.mjs";
|
||||
import cuffeo from "cuffeo";
|
||||
import { promises as fs } from "fs";
|
||||
import flummpress from "flummpress";
|
||||
import { handleUpload } from "./upload_handler.mjs";
|
||||
|
||||
process.on('unhandledRejection', err => {
|
||||
if (err.code === 'ERR_HTTP_HEADERS_SENT') return;
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
@@ -19,7 +21,7 @@ process.on('unhandledRejection', err => {
|
||||
this.level = args.level || 0;
|
||||
this.name = args.name;
|
||||
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;
|
||||
},
|
||||
bot: await new cuffeo(cfg.clients)
|
||||
@@ -27,7 +29,7 @@ process.on('unhandledRejection', err => {
|
||||
|
||||
console.time("loading");
|
||||
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"))
|
||||
};
|
||||
|
||||
@@ -41,7 +43,7 @@ process.on('unhandledRejection', err => {
|
||||
console.timeLog("loading", `${dir}/${mod}`);
|
||||
return res;
|
||||
}))).flat(2)
|
||||
})))).reduce((a, b) => ({...a, ...b}));
|
||||
})))).reduce((a, b) => ({ ...a, ...b }));
|
||||
|
||||
blah.events.forEach(event => {
|
||||
console.timeLog("loading", `registering event > ${event.name}`);
|
||||
@@ -61,15 +63,16 @@ process.on('unhandledRejection', err => {
|
||||
const router = app.router;
|
||||
const tpl = app.tpl;
|
||||
|
||||
|
||||
app.use(async (req, res) => {
|
||||
// sessionhandler
|
||||
req.session = false;
|
||||
if(req.url.pathname.match(/^\/(s|b|t|ca)\//))
|
||||
if (req.url.pathname.match(/^\/(s|b|t|ca)\//))
|
||||
return;
|
||||
req.theme = req.cookies.theme || 'amoled';
|
||||
req.fullscreen = req.cookies.fullscreen || 0;
|
||||
|
||||
if(req.cookies.session) {
|
||||
if (req.cookies.session) {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
|
||||
from "user_sessions"
|
||||
@@ -78,8 +81,8 @@ process.on('unhandledRejection', err => {
|
||||
where "user_sessions".session = ${lib.md5(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if(user.length === 0) {
|
||||
|
||||
if (user.length === 0) {
|
||||
return res.writeHead(307, { // delete session
|
||||
"Cache-Control": "no-cache, public",
|
||||
"Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
@@ -91,28 +94,26 @@ process.on('unhandledRejection', err => {
|
||||
|
||||
// log last action
|
||||
await db`
|
||||
update "user_sessions" set ${
|
||||
db({
|
||||
last_used: ~~(Date.now() / 1e3),
|
||||
last_action: req.url.pathname,
|
||||
browser: req.headers['user-agent']
|
||||
}, 'last_used', 'last_action', 'browser')
|
||||
update "user_sessions" set ${db({
|
||||
last_used: ~~(Date.now() / 1e3),
|
||||
last_action: req.url.pathname,
|
||||
browser: req.headers['user-agent']
|
||||
}, 'last_used', 'last_action', 'browser')
|
||||
}
|
||||
where id = ${+user[0].sess_id}
|
||||
`;
|
||||
|
||||
req.session.theme = req.cookies.theme;
|
||||
req.session.fullscreen = req.cookies.fullscreen;
|
||||
|
||||
|
||||
// update userprofile
|
||||
await db`
|
||||
insert into "user_options" ${
|
||||
db({
|
||||
user_id: +user[0].id,
|
||||
mode: user[0].mode ?? 0,
|
||||
theme: req.session.theme ?? 'amoled',
|
||||
fullscreen: req.session.fullscreen || 0
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen')
|
||||
insert into "user_options" ${db({
|
||||
user_id: +user[0].id,
|
||||
mode: user[0].mode ?? 0,
|
||||
theme: req.session.theme ?? 'amoled',
|
||||
fullscreen: req.session.fullscreen || 0
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen')
|
||||
}
|
||||
on conflict ("user_id") do update set
|
||||
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.debug = true;
|
||||
tpl.cache = false;
|
||||
|
||||
254
src/upload_handler.mjs
Normal file
254
src/upload_handler.mjs
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,22 @@
|
||||
@include(snippets/header)
|
||||
<div id="main">
|
||||
<div class="about">
|
||||
<p>Welcome stranger!</p>
|
||||
<p>bringing you some of the greatest webms from the past, the present and the future!</p>
|
||||
<p>Enjoy your stay.</p>
|
||||
<img style="width: 200px" src="/s/img/cockfag.png" alt="cockfag">
|
||||
<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 class="about">
|
||||
<p>Welcome stranger!</p>
|
||||
<p>bringing you some of the greatest webms from the past, the present and the future!</p>
|
||||
<p>How to use it?</p>
|
||||
<p>shortcuts</p>
|
||||
<ul>
|
||||
<li>k = search</li>
|
||||
<li>r = random</li>
|
||||
<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)
|
||||
@@ -6,17 +6,18 @@
|
||||
<span>Hier entsteht eine Internetpräsenz!</span><br>
|
||||
<hr>
|
||||
<p>f0ck stats: @if(typeof totals !== "undefined")
|
||||
total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}
|
||||
@endif</p>
|
||||
total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }}
|
||||
| nsfw: {{ totals.nsfw }}
|
||||
@endif</p>
|
||||
<hr>
|
||||
<div class="admintools">
|
||||
<p>Adminwerkzeuge</p>
|
||||
<ul>
|
||||
<!-- <li><a href="/admin/log">Logs</a></li>
|
||||
<li><a href="/admin/recover">Recover f0cks</a></li> -->
|
||||
<!-- <li><a href="/admin/log">Logs</a></li> -->
|
||||
<li><a href="/admin/approve">Approval Queue</a></li>
|
||||
<li><a href="/admin/sessions">Sessions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
@include(snippets/footer)
|
||||
192
views/admin/approve.html
Normal file
192
views/admin/approve.html
Normal 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">« Prev</a>
|
||||
@endif
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next
|
||||
»</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)
|
||||
1
views/ajax-item.html
Normal file
1
views/ajax-item.html
Normal file
@@ -0,0 +1 @@
|
||||
@include(item-partial)
|
||||
126
views/item-partial.html
Normal file
126
views/item-partial.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<div class="_204863">
|
||||
<div class="location">{{ (url.pathname) }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
@if(session)
|
||||
<div class="gapRight">
|
||||
<svg class="iconset" id="a_favo">
|
||||
<use
|
||||
href="/s/img/iconset.svg#heart_{{ Object.values(item.favorites).filter(u => u.user == session.user)[0] ? 'solid' : 'regular' }}">
|
||||
</use>
|
||||
</svg>
|
||||
<svg class="iconset" id="a_tfull">
|
||||
<use href="/s/img/iconset.svg#window-{{ fullscreen == 1 ? 'minimize' : 'maximize' }}"></use>
|
||||
</svg>
|
||||
@if(session.admin)<svg class="iconset" id="a_delete">
|
||||
<use href="/s/img/iconset.svg#cross"></use>
|
||||
</svg>@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="next-post">
|
||||
@if(pagination.prev)
|
||||
<div class="arrow-next">
|
||||
<a id="next" href="{{ link.main }}{{ pagination.prev }}"></a>
|
||||
</div>
|
||||
@else
|
||||
<div class="arrow-next">
|
||||
<a id="next" href="#" style="color: #ccc !important;"></a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="media-object">
|
||||
@if(item.mime.startsWith("video"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}"
|
||||
preload="auto" autoplay controls loop playsinline></video>
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("audio"))
|
||||
<div class="embed-responsive embed-responsive-16by9"
|
||||
style="background: url('@if(item.coverart)//w0bm.com{{ item.coverart }}@else/s/img/200.gif@endif') no-repeat center / contain black;">
|
||||
<audio id="my-video" class="embed-responsive-item" autoplay controls loop src="{{ item.dest }}"
|
||||
data-setup="{}" poster="@if(item.coverart){{ item.coverart }}@else/s/img/200.gif@endif"
|
||||
type="{{ item.mime }}"></audio>
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("image"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<div class="embed-responsive-image" id="image-scroll">
|
||||
<a href="{{ item.dest }}" id="elfe" target="_blank"><img id="f0ck-image" class="img-fluid"
|
||||
src="{{ item.dest }}" loading="lazy" decoding="async" /></a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<h1>404 - Not f0cked</h1>
|
||||
@endif
|
||||
</div>
|
||||
<div class="previous-post">
|
||||
@if(pagination.next)
|
||||
<div class="arrow-prev">
|
||||
<a id="prev" href="{{ link.main }}{{ pagination.next }}"></a>
|
||||
</div>
|
||||
@else
|
||||
<div class="arrow-prev">
|
||||
<a id="prev" href="#" style="color: #ccc !important;"></a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata">
|
||||
<div class="kontrollelement">
|
||||
<div class="einheit">
|
||||
@if(typeof pagination !== "undefined")
|
||||
<nav class="steuerung">
|
||||
@if(pagination.prev)
|
||||
<a class="nav-next" href="{{ link.main }}{{ pagination.prev }}">← next</a>
|
||||
@else
|
||||
<a class="nav-next" href="#" style="visibility: hidden">← next</a>
|
||||
@endif
|
||||
<span>|</span>
|
||||
<a id="random" class="" href="/random">
|
||||
<span>random</span>
|
||||
</a>
|
||||
<span>
|
||||
|
|
||||
</span>
|
||||
@if(pagination.next)
|
||||
<a class="nav-prev" href="{{ link.main }}{{ pagination.next }}">prev →</a>
|
||||
@else
|
||||
<a class="nav-prev" href="#" style="visibility: hidden">prev →</a>
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<span class="badge badge-dark">
|
||||
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>
|
||||
@if(session)
|
||||
(<a id="a_username" href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{ user.name }}</a>)
|
||||
@endif
|
||||
</span>
|
||||
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{ item.timestamp.timeago }}</time></span>
|
||||
<span class="badge badge-dark" id="tags">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(session.admin) <a class="removetag"
|
||||
href="#">×</a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@endif
|
||||
@if(session)
|
||||
<a href="#" id="a_addtag">add tag</a> - <a href="#" id="a_toggle">toggle</a>
|
||||
<datalist id="testlist"></datalist>
|
||||
@endif
|
||||
</span>
|
||||
<span class="badge" id="favs" @if(!item.favorites.length || !session) hidden@endif>
|
||||
@if(item.favorites.length && session)
|
||||
@each(item.favorites as fav)
|
||||
<a href="/user/{{ fav.user.toLowerCase() }}/favs" tooltip="{{ fav.user }}" flow="up"><img
|
||||
src="@if(fav.avatar)/t/{{ fav.avatar }}.webp@else/s/img/default.png@endif"
|
||||
style="height: 32px; width: 32px" /></a>
|
||||
@endeach
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
119
views/item.html
119
views/item.html
@@ -1,119 +1,10 @@
|
||||
@include(snippets/header)
|
||||
<canvas class="hidden-xs" id="bg"></canvas>
|
||||
|
||||
<div class="wrapper">
|
||||
<div id="main">
|
||||
|
||||
|
||||
<div class="container">
|
||||
<div class="_204863">
|
||||
<div class="location">{{ (url.pathname) }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
@if(session)
|
||||
<div class="gapRight">
|
||||
<svg class="iconset" id="a_favo"><use href="/s/img/iconset.svg#heart_{{ Object.values(item.favorites).filter(u => u.user == session.user)[0] ? 'solid' : 'regular' }}"></use></svg>
|
||||
<svg class="iconset" id="a_tfull"><use href="/s/img/iconset.svg#window-{{ fullscreen == 1 ? 'minimize' : 'maximize' }}"></use></svg>
|
||||
@if(session.admin)<svg class="iconset" id="a_delete"><use href="/s/img/iconset.svg#cross"></use></svg>@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="next-post">
|
||||
@if(pagination.prev)
|
||||
<div class="arrow-next">
|
||||
<a id="next" href="{{ link.main }}{{ pagination.prev }}"></a>
|
||||
</div>
|
||||
@else
|
||||
<div class="arrow-next">
|
||||
<a id="next" href="#" style="color: #ccc !important;"></a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="media-object">
|
||||
@if(item.mime.startsWith("video"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="auto" autoplay controls loop playsinline></video>
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("audio"))
|
||||
<div class="embed-responsive embed-responsive-16by9" style="background: url('@if(item.coverart)//w0bm.com{{ item.coverart }}@else/s/img/200.gif@endif') no-repeat center / contain black;">
|
||||
<audio id="my-video" class="embed-responsive-item" autoplay controls loop src="{{ item.dest }}" data-setup="{}" poster="@if(item.coverart){{ item.coverart }}@else/s/img/200.gif@endif" type="{{ item.mime }}"></audio>
|
||||
</div>
|
||||
@elseif(item.mime.startsWith("image"))
|
||||
<div class="embed-responsive embed-responsive-16by9">
|
||||
<div class="embed-responsive-image" id="image-scroll">
|
||||
<a href="{{ item.dest }}" id="elfe" target="_blank"><img id="f0ck-image" class="img-fluid" src="{{ item.dest }}" loading="lazy" decoding="async"/></a>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<h1>404 - Not f0cked</h1>
|
||||
@endif
|
||||
</div>
|
||||
<div class="previous-post">
|
||||
@if(pagination.next)
|
||||
<div class="arrow-prev">
|
||||
<a id="prev" href="{{ link.main }}{{ pagination.next }}"></a>
|
||||
</div>
|
||||
@else
|
||||
<div class="arrow-prev">
|
||||
<a id="prev" href="#" style="color: #ccc !important;"></a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata">
|
||||
<div class="kontrollelement">
|
||||
<div class="einheit">
|
||||
@if(typeof pagination !== "undefined")
|
||||
<nav class="steuerung">
|
||||
@if(pagination.prev)
|
||||
<a id="" href="{{ link.main }}{{ pagination.prev }}">← next</a>
|
||||
@else
|
||||
<a id="" href="#" style="visibility: hidden">← next</a>
|
||||
@endif
|
||||
<span>|</span>
|
||||
<a id="random" class="" href="/random">
|
||||
<span>random</span>
|
||||
</a>
|
||||
<span>
|
||||
|
|
||||
</span>
|
||||
@if(pagination.next)
|
||||
<a id="" href="{{ link.main }}{{ pagination.next }}">prev →</a>
|
||||
@else
|
||||
<a id="" href="#" style="visibility: hidden">prev →</a>
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<span class="badge badge-dark">
|
||||
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>
|
||||
@if(session)
|
||||
(<a id="a_username" href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{ user.name }}</a>)
|
||||
@endif
|
||||
</span>
|
||||
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{ item.timestamp.timeago }}</time></span>
|
||||
<span class="badge badge-dark" id="tags">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session)tooltip="{{ tag.user }}"@endif class="badge {{ tag.badge }} mr-2">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(session.admin) <a class="removetag" href="#">×</a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@endif
|
||||
@if(session)
|
||||
<a href="#" id="a_addtag">add tag</a> - <a href="#" id="a_toggle">toggle</a>
|
||||
<datalist id="testlist"></datalist>
|
||||
@endif
|
||||
</span>
|
||||
<span class="badge" id="favs"@if(!item.favorites.length || !session) hidden@endif>
|
||||
@if(item.favorites.length && session)
|
||||
@each(item.favorites as fav)
|
||||
<a href="/user/{{ fav.user.toLowerCase() }}/favs" tooltip="{{ fav.user }}" flow="up"><img src="@if(fav.avatar)/t/{{ fav.avatar }}.webp@else/s/img/default.png@endif" style="height: 32px; width: 32px" /></a>
|
||||
@endeach
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@include(item-partial)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
@include(snippets/footer)
|
||||
@@ -1,10 +1,32 @@
|
||||
<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
|
||||
<div id="delete-tag-modal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h3>Delete Tag?</h3>
|
||||
<p>Are you sure you want to delete the tag <strong id="delete-tag-name"></strong>?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-tag-confirm" class="btn-danger">Delete</button>
|
||||
<button id="delete-tag-cancel" class="btn-secondary">Cancel</button>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -1,14 +1,19 @@
|
||||
<!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>
|
||||
@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="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 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>
|
||||
|
||||
<body>
|
||||
<!-- hier splitting betreiben -->
|
||||
@include(snippets/navbar)
|
||||
<canvas class="hidden-xs" id="bg"></canvas>
|
||||
@include(snippets/navbar)
|
||||
7
views/snippets/items-grid.html
Normal file
7
views/snippets/items-grid.html
Normal 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
|
||||
@@ -1,37 +1,49 @@
|
||||
@if(session)
|
||||
<!-- 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>
|
||||
|
||||
<div class="navigation-links-guest">
|
||||
<ol>
|
||||
<a href="/tags">tags</a>
|
||||
<a href="/about">about</a>
|
||||
@if(!/^\/\d$/.test(url.pathname))
|
||||
<a href="/random">rand</a>
|
||||
@endif
|
||||
</ol>
|
||||
<div class="nav-left-group">
|
||||
<div class="nav-user-dropdown">
|
||||
<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="/about">about</a>
|
||||
@if(!/^\/\d$/.test(url.pathname))
|
||||
<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
|
||||
<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>
|
||||
<!-- 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="pagination-container-fluid">
|
||||
<div class="pagination-wrapper">
|
||||
@if(typeof pagination !== "undefined")
|
||||
<nav class="pagination">
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.start }}" class="page-item-1 btn start@if(!pagination.prev) disabled@endif">«</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.prev }}" class="page-item-2 btn prev@if(!pagination.prev) disabled@endif">‹</a>
|
||||
@each(pagination.cheat as i)
|
||||
@if(i == pagination.page)
|
||||
<span class="btn disabled">{{ i }}</span>
|
||||
@else
|
||||
<a href="{{ link.main }}{{ link.path }}{{ i }}" class="pagination-int-item btn">{{ i }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.next }}" class="page-item-3 btn next@if(!pagination.next) disabled@endif">›</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.end }}" class="page-item-4 btn start@if(!pagination.next) disabled@endif">»</a>
|
||||
</nav>
|
||||
@endif
|
||||
@include(snippets/pagination)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,36 +53,32 @@
|
||||
<!-- not logged in -->
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
|
||||
|
||||
<div class="navigation-links-guest">
|
||||
<ol>
|
||||
<a href="/tags">tags</a>
|
||||
<a href="/about">about</a>
|
||||
@if(!/^\/\d$/.test(url.pathname))
|
||||
<a href="/random">rand</a>
|
||||
@endif
|
||||
<a href="/tags">tags</a>
|
||||
<a href="/about">about</a>
|
||||
@if(!/^\/\d$/.test(url.pathname))
|
||||
<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
|
||||
<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>
|
||||
</div>
|
||||
<!-- 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="pagination-container-fluid">
|
||||
<div class="pagination-wrapper">
|
||||
@if(typeof pagination !== "undefined")
|
||||
<nav class="pagination">
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.start }}" class="page-item-1 btn start@if(!pagination.prev) disabled@endif">«</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.prev }}" class="page-item-2 btn prev@if(!pagination.prev) disabled@endif">‹</a>
|
||||
@each(pagination.cheat as i)
|
||||
@if(i == pagination.page)
|
||||
<span class="btn disabled">{{ i }}</span>
|
||||
@else
|
||||
<a href="{{ link.main }}{{ link.path }}{{ i }}" class="pagination-int-item btn">{{ i }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.next }}" class="page-item-3 btn next@if(!pagination.next) disabled@endif">›</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.end }}" class="page-item-4 btn start@if(!pagination.next) disabled@endif">»</a>
|
||||
</nav>
|
||||
@endif
|
||||
@include(snippets/pagination)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
views/snippets/pagination.html
Normal file
19
views/snippets/pagination.html
Normal file
@@ -0,0 +1,19 @@
|
||||
@if(typeof pagination !== "undefined")
|
||||
<nav class="pagination">
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.start }}"
|
||||
class="page-item-1 btn start@if(!pagination.prev) disabled@endif">«</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.prev }}"
|
||||
class="page-item-2 btn prev@if(!pagination.prev) disabled@endif">‹</a>
|
||||
@each(pagination.cheat as i)
|
||||
@if(i == pagination.page)
|
||||
<span class="btn disabled">{{ i }}</span>
|
||||
@else
|
||||
<a href="{{ link.main }}{{ link.path }}{{ i }}" class="pagination-int-item btn">{{ i }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.next }}"
|
||||
class="page-item-3 btn next@if(!pagination.next) disabled@endif">›</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.end }}"
|
||||
class="page-item-4 btn start@if(!pagination.next) disabled@endif">»</a>
|
||||
</nav>
|
||||
@endif
|
||||
@@ -2,27 +2,33 @@
|
||||
<div id="main">
|
||||
<div class="container">
|
||||
<h3 style="text-align: center;">☯</h3>
|
||||
<div class="tags">
|
||||
<div class="tags-grid">
|
||||
@if(session)
|
||||
@each(toptags_regged as toptag)
|
||||
<div class="tag badge badge-light mr-2">
|
||||
<div class="tagbox-body">
|
||||
<span class="toptag_id">{!! toptag.tag !!}</span>
|
||||
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
|
||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
||||
<div class="tag-card-image">
|
||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
||||
</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
|
||||
@else
|
||||
@each(toptags as toptag)
|
||||
<div class="tag badge badge-light mr-2">
|
||||
<div class="tagbox-body">
|
||||
<span class="toptag_id">{!! toptag.tag !!}</span>
|
||||
<span class="toptag_tag"><a href="/tag/{!! toptag.tag !!}">{{ toptag.total_items }}</a></span>
|
||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
||||
<div class="tag-card-image">
|
||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
||||
</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
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
@include(snippets/footer)
|
||||
@@ -1,37 +1,439 @@
|
||||
@include(snippets/header)
|
||||
<div class="upload">
|
||||
<h5>Upload</h5>
|
||||
<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>
|
||||
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
|
||||
<p>We do not want content that</p>
|
||||
<ul>
|
||||
<li>glorifies Nazis</li>
|
||||
<li>sexualizes children and minors</li>
|
||||
<li>is political</li>
|
||||
<li>glorifies military</li>
|
||||
<li>depicts gore</li>
|
||||
<li>depicts acts of terrorism</li>
|
||||
<li>depicts violence and cruelty against animals</li>
|
||||
</ul>
|
||||
<p>We want content that</p>
|
||||
<ul>
|
||||
<li>is cool</li>
|
||||
<li>has deeper value</li>
|
||||
<li>is fun to watch</li>
|
||||
<li>has a vibe to it</li>
|
||||
<li>can be looped for 5000 times and doesnt get boring</li>
|
||||
</ul>
|
||||
<p>but in general we welcome content that has been curated beforehand by the uploader and believe that they understand the vibe.</p>
|
||||
<p>Content that is deemed NSFW (Not Safe For Work) MUST be tagged with "nsfw"</p>
|
||||
<p>This list is subject to change, please review it periodically.</p>
|
||||
<br>
|
||||
<h5>How it works</h5>
|
||||
<ul>
|
||||
<li>The maximum filesize for direct file upload is 20MB and cannot be exceeded.</li>
|
||||
<li>There is a much higher limit for non-direct uploads via sending a URL.</li>
|
||||
<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>
|
||||
</ul>
|
||||
<div class="upload-container">
|
||||
<h2>Upload</h2>
|
||||
|
||||
<div class="content-guidelines">
|
||||
<h4>Content Guideline</h4>
|
||||
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
|
||||
<div class="guidelines-grid">
|
||||
<div class="guidelines-dont">
|
||||
<h5>We do not want</h5>
|
||||
<ul>
|
||||
<li>Content glorifying Nazis</li>
|
||||
<li>Sexualization of children/minors</li>
|
||||
<li>Political content</li>
|
||||
<li>Military glorification</li>
|
||||
<li>Gore</li>
|
||||
<li>Acts of terrorism</li>
|
||||
<li>Violence against animals</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="guidelines-do">
|
||||
<h5>We want</h5>
|
||||
<ul>
|
||||
<li>Cool content</li>
|
||||
<li>Deeper value</li>
|
||||
<li>Fun to watch</li>
|
||||
<li>Has a vibe to it</li>
|
||||
<li>Can be looped 5000 times</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session)
|
||||
<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>
|
||||
|
||||
<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)
|
||||
Reference in New Issue
Block a user