init f0ckm
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.gitea
|
||||||
|
.eslintrc.json
|
||||||
|
|
||||||
|
# Dependencies (rebuilt in container)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Config (mounted at runtime)
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Development
|
||||||
|
logs/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
29
.eslintrc.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"es6": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"linebreak-style": [
|
||||||
|
"error",
|
||||||
|
"unix"
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"double"
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*.mjs gitlab-language=javascript
|
||||||
|
*.hbs gitlab-language=handlebars
|
||||||
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
logs/*.log
|
||||||
|
config.json
|
||||||
|
public/b
|
||||||
|
public/ca
|
||||||
|
public/t
|
||||||
|
deleted/b
|
||||||
|
deleted/ca
|
||||||
|
deleted/t
|
||||||
|
tmp/*
|
||||||
|
tools
|
||||||
|
public/a
|
||||||
|
public/tag_cache
|
||||||
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
ARG GIT_HASH=unknown
|
||||||
|
ENV GIT_HASH=$GIT_HASH
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ffmpeg \
|
||||||
|
yt-dlp \
|
||||||
|
ffmpegthumbnailer \
|
||||||
|
imagemagick \
|
||||||
|
git \
|
||||||
|
mailcap \
|
||||||
|
file \
|
||||||
|
curl \
|
||||||
|
torsocks \
|
||||||
|
exiftool
|
||||||
|
|
||||||
|
WORKDIR /opt/f0bm
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm i
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
ENTRYPOINT ["npm", "run", "start"]
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 keinBot
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
167
config_example.json
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"url": {
|
||||||
|
"full": "https://example.com",
|
||||||
|
"domain": "example.com",
|
||||||
|
"regex": "example\\.com"
|
||||||
|
},
|
||||||
|
"socks": "socks5://127.0.0.1:9050",
|
||||||
|
"mail": "admin@example.com",
|
||||||
|
"discord": "",
|
||||||
|
"maxfilesize": 104857600,
|
||||||
|
"adminmultiplier": 3.5,
|
||||||
|
"upload_limit": 300,
|
||||||
|
"ignored": [
|
||||||
|
"example.net",
|
||||||
|
"example.org"
|
||||||
|
],
|
||||||
|
"invite_secret": "YOUR_SECRET_HERE",
|
||||||
|
"hide_comments_from_public": false,
|
||||||
|
"development": true
|
||||||
|
},
|
||||||
|
"allowedModes": [ "sfw", "nsfw", "untagged", "all", "nsfl" ],
|
||||||
|
"enable_nsfl": true,
|
||||||
|
"nsfl_tag_id": 1234,
|
||||||
|
"allowedMimes": [ "audio", "image", "video" ],
|
||||||
|
"nsfp": [
|
||||||
|
1, 2, 3
|
||||||
|
],
|
||||||
|
"websrv": {
|
||||||
|
"port": "1337",
|
||||||
|
"language": "en",
|
||||||
|
"allow_language_change": true,
|
||||||
|
"cache": false,
|
||||||
|
"eps": 100,
|
||||||
|
"background": true,
|
||||||
|
|
||||||
|
"description": "Example Description",
|
||||||
|
"themes": [ "amoled" ],
|
||||||
|
"theme": "amoled",
|
||||||
|
"default_layout": "legacy",
|
||||||
|
"custom_favicon": "/s/img/favicon.gif",
|
||||||
|
"custom_brand_image": [],
|
||||||
|
"show_koepfe": false,
|
||||||
|
"koepfe": [],
|
||||||
|
|
||||||
|
"enable_global_chat": true,
|
||||||
|
"enable_danmaku": true,
|
||||||
|
"private_messages": true,
|
||||||
|
"halls_enabled": true,
|
||||||
|
"meme_creator": true,
|
||||||
|
|
||||||
|
"web_url_upload": true,
|
||||||
|
"enable_youtube_upload": true,
|
||||||
|
"web_meta_extraction": true,
|
||||||
|
"bypass_duplicate_check": true,
|
||||||
|
"protect_files": false,
|
||||||
|
"allowed_comment_images": [
|
||||||
|
"i.imgur.com",
|
||||||
|
"tenor.com",
|
||||||
|
"giphy.com"
|
||||||
|
],
|
||||||
|
"show_mime_picker": true,
|
||||||
|
|
||||||
|
"embed_youtube_in_comments": true,
|
||||||
|
"show_content_warning": true,
|
||||||
|
"phrases": [
|
||||||
|
"Hello World"
|
||||||
|
],
|
||||||
|
"ban_video": "",
|
||||||
|
"enable_xd_score": true,
|
||||||
|
"enable_autoplay": false,
|
||||||
|
"enable_swiping": true,
|
||||||
|
"enable_profile_description": true,
|
||||||
|
"use_ententeich": true,
|
||||||
|
"enable_swf": true,
|
||||||
|
"swf_thumb": "/s/img/swf.png",
|
||||||
|
|
||||||
|
"open_registration": true,
|
||||||
|
"open_registration_web_toggle": false,
|
||||||
|
"private_society": false,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"images": "/b",
|
||||||
|
"thumbnails": "/t",
|
||||||
|
"coverarts": "/ca",
|
||||||
|
"emojis": "/emojis",
|
||||||
|
"memes": "/memes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clients": [{
|
||||||
|
"type": "tg",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "",
|
||||||
|
"pollrate": 1001
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "matrix",
|
||||||
|
"enabled": false,
|
||||||
|
"baseUrl": "https://matrix.org",
|
||||||
|
"token": "",
|
||||||
|
"userId": "@user:matrix.org",
|
||||||
|
"channels": [],
|
||||||
|
"upload_channel_id": "",
|
||||||
|
"notification_channel_id": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "irc",
|
||||||
|
"enabled": false,
|
||||||
|
"network": "example",
|
||||||
|
"host": "irc.example.net",
|
||||||
|
"port": 6697,
|
||||||
|
"ssl": true,
|
||||||
|
"selfSigned": true,
|
||||||
|
"sasl": false,
|
||||||
|
"nickname": "bot",
|
||||||
|
"username": "bot",
|
||||||
|
"password": "",
|
||||||
|
"realname": "bot",
|
||||||
|
"channels": [
|
||||||
|
"#example"
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
"sql": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5432,
|
||||||
|
"user": "db_user",
|
||||||
|
"password": "db_password",
|
||||||
|
"database": "db_name",
|
||||||
|
"multipleStatements": true,
|
||||||
|
"max": 50
|
||||||
|
},
|
||||||
|
"admins": [{}],
|
||||||
|
"mimes": {
|
||||||
|
"image/png": "png",
|
||||||
|
"video/webm": "webm",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/jpg": "jpg",
|
||||||
|
"image/jpeg": "jpeg",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"video/mp4": "mp4",
|
||||||
|
"video/quicktime": "mp4",
|
||||||
|
"audio/mpeg": "mpg",
|
||||||
|
"audio/mp3": "mp3",
|
||||||
|
"audio/ogg": "ogg",
|
||||||
|
"audio/opus": "opus",
|
||||||
|
"audio/flac": "flac",
|
||||||
|
"audio/x-flac": "flac",
|
||||||
|
"audio/mp4": "m4a",
|
||||||
|
"audio/x-m4a": "m4a",
|
||||||
|
"audio/aac": "m4a",
|
||||||
|
"video/x-m4v": "mp4",
|
||||||
|
"video/x-matroska": "mkv",
|
||||||
|
"application/x-shockwave-flash": "swf",
|
||||||
|
"application/vnd.adobe.flash.movie": "swf"
|
||||||
|
},
|
||||||
|
"apis": {},
|
||||||
|
"smtp": {
|
||||||
|
"enabled": false,
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": 465,
|
||||||
|
"secure": true,
|
||||||
|
"user": "smtp_user",
|
||||||
|
"password": "smtp_password",
|
||||||
|
"from": "admin@example.com",
|
||||||
|
"mail_reset_password": false
|
||||||
|
}
|
||||||
|
}
|
||||||
60
debug/autotagger.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import lib from "../src/inc/lib.mjs";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const _args = process.argv.slice(2);
|
||||||
|
const _from = +_args[0];
|
||||||
|
const _to = _from + 500;
|
||||||
|
|
||||||
|
const f0cks = await db`
|
||||||
|
select *
|
||||||
|
from items
|
||||||
|
where
|
||||||
|
id not in (select item_id from tags_assign group by item_id) and
|
||||||
|
mime like 'image/%' and
|
||||||
|
id between ${_from} and ${_to}
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.time('blah');
|
||||||
|
for(let f of f0cks) {
|
||||||
|
const tmp = await lib.detectNSFW(f.dest);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'https://f0ck.me/' + f.id,
|
||||||
|
tmp.isNSFW,
|
||||||
|
tmp.score.toFixed(2),
|
||||||
|
{
|
||||||
|
sexy: tmp.scores.sexy.toFixed(2),
|
||||||
|
porn: tmp.scores.porn.toFixed(2),
|
||||||
|
hentai: tmp.scores.hentai.toFixed(2),
|
||||||
|
neutral: tmp.scores.neutral.toFixed(2)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${
|
||||||
|
db({
|
||||||
|
item_id: f.id,
|
||||||
|
tag_id: tmp.nsfw ? 2 : 1,
|
||||||
|
user_id: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if(tmp.hentai >= .7) {
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${
|
||||||
|
db({
|
||||||
|
item_id: f.id,
|
||||||
|
tag_id: 4, // hentai
|
||||||
|
user_id: 1 // autotagger
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
console.timeEnd('blah');
|
||||||
|
process.exit();
|
||||||
|
})();
|
||||||
78
debug/backfill_phash.mjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import queue from "../src/inc/queue.mjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
||||||
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
|
||||||
|
async function backfill() {
|
||||||
|
console.log("Starting PHash backfill...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Count total items needing backfill
|
||||||
|
const countResult = await db`SELECT count(*) as count FROM items WHERE (phash IS NULL OR phash = '') AND active = true`;
|
||||||
|
const total = countResult[0].count;
|
||||||
|
console.log(`Found ${total} items to process.`);
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const items = await db`
|
||||||
|
SELECT id, dest
|
||||||
|
FROM items
|
||||||
|
WHERE (phash IS NULL OR phash = '')
|
||||||
|
AND active = true
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ${BATCH_SIZE}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (items.length === 0) break;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Correctly resolve path relative to project root
|
||||||
|
const filePath = path.join(PROJECT_ROOT, 'public', 'b', item.dest);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(`[SKIP] File not found: ${item.dest} (ID: ${item.id})`);
|
||||||
|
// Mark as MISSING so we don't pick it up again
|
||||||
|
await db`UPDATE items SET phash = 'MISSING' WHERE id = ${item.id}`;
|
||||||
|
processed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[PROCESSING] ID ${item.id}: ${item.dest}`);
|
||||||
|
const phash = await queue.generatePHash(filePath);
|
||||||
|
|
||||||
|
if (phash) {
|
||||||
|
await db`UPDATE items SET phash = ${phash} WHERE id = ${item.id}`;
|
||||||
|
console.log(`[SUCCESS] ID ${item.id}: Updated PHash.`);
|
||||||
|
} else {
|
||||||
|
console.log(`[FAILED] ID ${item.id}: Could not generate PHash.`);
|
||||||
|
// Mark as ERROR so we don't pick it up again
|
||||||
|
await db`UPDATE items SET phash = 'ERROR' WHERE id = ${item.id}`;
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ERROR] ID ${item.id}:`, err);
|
||||||
|
await db`UPDATE items SET phash = 'ERROR' WHERE id = ${item.id}`;
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
console.log(`Progress: ${processed} completed (Remaining in batch loop...)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Backfill complete!");
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
backfill();
|
||||||
102
debug/blur_existing_thumbnails.mjs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Backfill script to generate blurred thumbnails for existing NSFW items
|
||||||
|
*
|
||||||
|
* Run with: node debug/blur_existing_thumbnails.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec as _exec } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import cfg from "../src/inc/config.mjs";
|
||||||
|
import sql from "../src/inc/sql.mjs";
|
||||||
|
|
||||||
|
const db = sql;
|
||||||
|
|
||||||
|
const exec = (cmd) => new Promise((resolve, reject) => {
|
||||||
|
_exec(cmd, { maxBuffer: 5e3 * 1024 }, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
err.stderr = stderr;
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve({ stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('[BACKFILL] Starting blurred thumbnail generation for existing NSFW items...');
|
||||||
|
|
||||||
|
// Find all items with NSFW tag (tag_id = 2) that are active
|
||||||
|
const nsfwItems = await db`
|
||||||
|
SELECT DISTINCT items.id
|
||||||
|
FROM items
|
||||||
|
JOIN tags_assign ON tags_assign.item_id = items.id
|
||||||
|
WHERE tags_assign.tag_id = 2
|
||||||
|
AND items.active = true
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Found ${nsfwItems.length} NSFW items`);
|
||||||
|
|
||||||
|
const tDir = cfg.paths.t;
|
||||||
|
console.log(`[BACKFILL] Thumbnail directory: ${tDir}`);
|
||||||
|
|
||||||
|
// Debug: check if directory exists and list some files
|
||||||
|
try {
|
||||||
|
const files = await fs.promises.readdir(tDir);
|
||||||
|
console.log(`[BACKFILL] Directory exists, contains ${files.length} files`);
|
||||||
|
console.log(`[BACKFILL] Sample files: ${files.slice(0, 5).join(', ')}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BACKFILL] ERROR: Cannot access thumbnail directory: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
for (const item of nsfwItems) {
|
||||||
|
const src = path.join(tDir, `${item.id}.webp`);
|
||||||
|
const dst = path.join(tDir, `${item.id}_blur.webp`);
|
||||||
|
|
||||||
|
// Check if blur already exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(dst);
|
||||||
|
skipped++;
|
||||||
|
continue; // Already exists
|
||||||
|
} catch {
|
||||||
|
// Doesn't exist, proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(src);
|
||||||
|
} catch {
|
||||||
|
console.log(`[BACKFILL] Source thumbnail missing for item ${item.id}`);
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate blurred thumbnail
|
||||||
|
try {
|
||||||
|
await exec(`magick "${src}" -blur 0x20 "${dst}"`);
|
||||||
|
processed++;
|
||||||
|
if (processed % 100 === 0) {
|
||||||
|
console.log(`[BACKFILL] Progress: ${processed} processed, ${skipped} skipped, ${errors} errors`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BACKFILL] Failed to blur item ${item.id}:`, err.message);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[BACKFILL] Complete!`);
|
||||||
|
console.log(` Processed: ${processed}`);
|
||||||
|
console.log(` Skipped (already exist): ${skipped}`);
|
||||||
|
console.log(` Errors: ${errors}`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('[BACKFILL] Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
95
debug/clean.mjs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import cfg from "../src/inc/config.mjs";
|
||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import readline from 'node:readline/promises';
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
|
||||||
|
// npm run clean -- --dry-run
|
||||||
|
const dry = !!process.argv.filter(a => a == '--dry-run').length;
|
||||||
|
console.log(`dry run? ${dry}`);
|
||||||
|
|
||||||
|
const dirs = {
|
||||||
|
b: "./public/b",
|
||||||
|
t: "./public/t",
|
||||||
|
tmp: "./tmp"
|
||||||
|
};
|
||||||
|
const files = {
|
||||||
|
b: (await fs.promises.readdir(dirs.b)) .filter(f => f !== '.empty'),
|
||||||
|
t: (await fs.promises.readdir(dirs.t)) .filter(f => f !== '.empty'),
|
||||||
|
tmp: (await fs.promises.readdir(dirs.tmp)).filter(f => f !== '.empty')
|
||||||
|
};
|
||||||
|
const extensions = [ ...Object.values(cfg.mimes), 'mov' ];
|
||||||
|
const count = {
|
||||||
|
missing: { b: [], t: [] },
|
||||||
|
invalid: { b: [], t: [] },
|
||||||
|
spare: { b: [], t: [] },
|
||||||
|
tmp: files.tmp
|
||||||
|
};
|
||||||
|
const rows = await db`select id, dest from items where active = true`;
|
||||||
|
const f0cks = {
|
||||||
|
b: rows.flatMap(f => f.dest),
|
||||||
|
t: rows.flatMap(f => `${f.id}.webp`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// missing
|
||||||
|
for(const row of rows) {
|
||||||
|
if(!fs.existsSync(`${dirs.b}/${row.dest}`))
|
||||||
|
count.missing.b.push(row.id);
|
||||||
|
if(!fs.existsSync(`${dirs.t}/${row.id}.webp`))
|
||||||
|
count.missing.t.push(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalid
|
||||||
|
count.invalid.b = files.b.filter(f => !extensions.includes(f.toLowerCase().split('.')[1]));
|
||||||
|
count.invalid.t = files.t.filter(f => !f.endsWith('.webp'));
|
||||||
|
|
||||||
|
// spare
|
||||||
|
for(const file of files.b)
|
||||||
|
if(!f0cks.b.includes(file))
|
||||||
|
count.spare.b.push(`${dirs.b}/${file}`);
|
||||||
|
|
||||||
|
for(const file of files.t)
|
||||||
|
if(!f0cks.t.includes(file))
|
||||||
|
count.spare.t.push(`${dirs.t}/${file}`);
|
||||||
|
|
||||||
|
// show confusing summary
|
||||||
|
console.log(count);
|
||||||
|
|
||||||
|
// delete spare if --dry-run
|
||||||
|
if(!dry) {
|
||||||
|
let q;
|
||||||
|
if(count.spare.b.length > 0) {
|
||||||
|
q = (await rl.question(`delete ${count.spare.b.length} unnecessary files in ${dirs.b}? [y/N] `)) == 'y';
|
||||||
|
if(q) {
|
||||||
|
await Promise.all(count.spare.b.map(f => fs.promises.unlink(f)));
|
||||||
|
console.log(`deleted ${count.spare.b.length} files`);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
console.log('abort...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(count.spare.t.length > 0) {
|
||||||
|
q = (await rl.question(`delete ${count.spare.t.length} unnecessary files in ${dirs.t}? [y/N] `)) == 'y';
|
||||||
|
if(q) {
|
||||||
|
await Promise.all(count.spare.t.map(f => fs.promises.unlink(f)));
|
||||||
|
console.log(`deleted ${count.spare.t.length} files`);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
console.log('abort...');
|
||||||
|
}
|
||||||
|
|
||||||
|
if(files.tmp.length > 0) {
|
||||||
|
q = (await rl.question(`delete ${files.tmp.length} files in ${dirs.tmp}? [y/N] `)) == 'y';
|
||||||
|
if(q) {
|
||||||
|
await Promise.all(files.tmp.map(f => fs.promises.unlink(`${dirs.tmp}/${f}`)));
|
||||||
|
console.log(`deleted ${files.tmp.length} files`);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
console.log('abort...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// close connection
|
||||||
|
await db.end();
|
||||||
|
process.exit();
|
||||||
104
debug/find_duplicates.mjs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
|
||||||
|
const THRESHOLD = 15;
|
||||||
|
const REQUIRED_MATCHES = 2;
|
||||||
|
|
||||||
|
// Hamming distance helper — operates on a single hex-encoded hash segment
|
||||||
|
const getHammingDistance = (h1, h2) => {
|
||||||
|
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||||
|
let distance = 0;
|
||||||
|
for (let i = 0; i < h1.length; i += 2) {
|
||||||
|
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||||
|
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||||
|
let xor = v1 ^ v2;
|
||||||
|
while (xor) {
|
||||||
|
distance += xor & 1;
|
||||||
|
xor >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function findDuplicates() {
|
||||||
|
console.log("Fetching items...");
|
||||||
|
|
||||||
|
// Fetch all valid phashes
|
||||||
|
const items = await db`
|
||||||
|
SELECT id, phash
|
||||||
|
FROM items
|
||||||
|
WHERE phash IS NOT NULL
|
||||||
|
AND phash != ''
|
||||||
|
AND phash != 'MISSING'
|
||||||
|
AND phash != 'ERROR'
|
||||||
|
AND phash NOT LIKE '00000000%'
|
||||||
|
ORDER BY id ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Checking ${items.length} items for duplicates (Threshold: ${THRESHOLD}, Required frame matches: ${REQUIRED_MATCHES})...`);
|
||||||
|
|
||||||
|
const duplicates = new Map(); // Map<OriginalID, List<{id, dist}>>
|
||||||
|
const processed = new Set();
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const current = items[i];
|
||||||
|
|
||||||
|
if (processed.has(current.id)) continue;
|
||||||
|
|
||||||
|
const matchList = [];
|
||||||
|
|
||||||
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
|
const compare = items[j];
|
||||||
|
if (processed.has(compare.id)) continue;
|
||||||
|
|
||||||
|
// Split multi-frame hashes properly — do NOT compare the whole string
|
||||||
|
const aHashes = current.phash.split('_');
|
||||||
|
const bHashes = compare.phash.split('_');
|
||||||
|
const framesToCompare = Math.min(aHashes.length, bHashes.length);
|
||||||
|
|
||||||
|
let matchCount = 0;
|
||||||
|
for (let f = 0; f < framesToCompare; f++) {
|
||||||
|
const dist = getHammingDistance(aHashes[f], bHashes[f]);
|
||||||
|
if (dist <= THRESHOLD) matchCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = (framesToCompare >= 3 && matchCount >= REQUIRED_MATCHES)
|
||||||
|
|| (framesToCompare === 2 && matchCount >= 2)
|
||||||
|
|| (framesToCompare === 1 && matchCount === 1);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
const avgDist = Math.round(
|
||||||
|
aHashes.slice(0, framesToCompare)
|
||||||
|
.reduce((sum, h, idx) => sum + getHammingDistance(h, bHashes[idx]), 0)
|
||||||
|
/ framesToCompare
|
||||||
|
);
|
||||||
|
matchList.push({ id: compare.id, dist: avgDist });
|
||||||
|
processed.add(compare.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchList.length > 0) {
|
||||||
|
duplicates.set(current.id, matchList);
|
||||||
|
processed.add(current.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicates.size === 0) {
|
||||||
|
console.log("No duplicates found.");
|
||||||
|
} else {
|
||||||
|
console.log(`Found ${duplicates.size} duplicate sets:`);
|
||||||
|
console.log("---------------------------------------------------");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [originalId, matchList] of duplicates.entries()) {
|
||||||
|
const matchStr = matchList.map(m => `ID:${m.id} (avg-dist:${m.dist})`).join(", ");
|
||||||
|
console.log(`Original ID: ${originalId} matches with: ${matchStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
findDuplicates().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
72
debug/fix_deleted.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log("Starting migration...");
|
||||||
|
|
||||||
|
// 1. Ensure column exists
|
||||||
|
try {
|
||||||
|
await db`select is_deleted from items limit 1`;
|
||||||
|
console.log("Column 'is_deleted' already exists.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('column "is_deleted" does not exist')) {
|
||||||
|
console.log("Column 'is_deleted' missing. Adding it now...");
|
||||||
|
await db`ALTER TABLE items ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE`;
|
||||||
|
console.log("Column added successfully.");
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error checking column:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await db`select id, dest from items where active = false`;
|
||||||
|
console.log(`Found ${items.length} inactive items.`);
|
||||||
|
|
||||||
|
let trashCount = 0;
|
||||||
|
let pendingCount = 0;
|
||||||
|
let brokenCount = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await fs.access(`./deleted/b/${item.dest}`);
|
||||||
|
// File exists in deleted, mark as is_deleted = true
|
||||||
|
await db`update items set is_deleted = true where id = ${item.id}`;
|
||||||
|
trashCount++;
|
||||||
|
} catch {
|
||||||
|
// Not in deleted, check public
|
||||||
|
try {
|
||||||
|
await fs.access(`./public/b/${item.dest}`);
|
||||||
|
// In public, is_deleted = false (default)
|
||||||
|
pendingCount++;
|
||||||
|
} catch {
|
||||||
|
// Not in either? Broken.
|
||||||
|
console.log(`Item ${item.id} (${item.dest}) missing from both locations. Cleaning up...`);
|
||||||
|
|
||||||
|
// 2. Fix FK constraint: Check if this item is used as an avatar
|
||||||
|
try {
|
||||||
|
// Find a safe fallback avatar (active item)
|
||||||
|
const fallback = await db`select id from items where active = true limit 1`;
|
||||||
|
if (fallback.length > 0) {
|
||||||
|
const safeId = fallback[0].id;
|
||||||
|
const users = await db`update "user_options" set avatar = ${safeId} where avatar = ${item.id} returning user_id`;
|
||||||
|
if (users.length > 0) {
|
||||||
|
console.log(` > Reassigned avatar for ${users.length} users (from ${item.id} to ${safeId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fkErr) {
|
||||||
|
console.error(` ! Error fixing avatar FK for ${item.id}:`, fkErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`delete from items where id = ${item.id}`;
|
||||||
|
brokenCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete.`);
|
||||||
|
console.log(`Trash (soft-deleted): ${trashCount}`);
|
||||||
|
console.log(`Pending: ${pendingCount}`);
|
||||||
|
console.log(`Broken: ${brokenCount}`);
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
84
debug/recreate_hashes.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import db from '../src/inc/sql.mjs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
console.log('Starting hash recreation (Production Mode - Streams)...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch only necessary columns
|
||||||
|
const items = await db`SELECT id, dest, checksum, size FROM items ORDER BY id ASC`;
|
||||||
|
console.log(`Found ${items.length} items. Processing...`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let errors = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const filePath = path.join('./public/b', item.dest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Silent error in logs for missing files to avoid spamming "thousands" of lines if many are missing
|
||||||
|
// Use verbose logging if needed, but here we'll just count them.
|
||||||
|
// Actually, precise logs are better for "production" to know what's wrong.
|
||||||
|
console.error(`[MISSING] File not found for item ${item.id}: ${filePath}`);
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size without reading content
|
||||||
|
const stats = await fs.promises.stat(filePath);
|
||||||
|
const size = stats.size;
|
||||||
|
|
||||||
|
// Calculate hash using stream to ensure low memory usage
|
||||||
|
const hash = await new Promise((resolve, reject) => {
|
||||||
|
const hashStream = crypto.createHash('sha256');
|
||||||
|
const rs = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
rs.on('error', reject);
|
||||||
|
rs.on('data', chunk => hashStream.update(chunk));
|
||||||
|
rs.on('end', () => resolve(hashStream.digest('hex')));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hash !== item.checksum || size !== item.size) {
|
||||||
|
console.log(`[UPDATE] Item ${item.id} (${index + 1}/${items.length})`);
|
||||||
|
if (hash !== item.checksum) console.log(` - Hash: ${item.checksum} -> ${hash}`);
|
||||||
|
if (size !== item.size) console.log(` - Size: ${item.size} -> ${size}`);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
UPDATE items
|
||||||
|
SET checksum = ${hash}, size = ${size}
|
||||||
|
WHERE id = ${item.id}
|
||||||
|
`;
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log progress every 100 items
|
||||||
|
if ((index + 1) % 100 === 0) {
|
||||||
|
console.log(`Progress: ${index + 1}/${items.length} (Updated: ${updated}, Errors: ${errors})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ERROR] Processing item ${item.id}:`, err);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
console.log(`Total: ${items.length}`);
|
||||||
|
console.log(`Updated: ${updated}`);
|
||||||
|
console.log(`Skipped (No changes): ${skipped}`);
|
||||||
|
console.log(`Errors (Missing files): ${errors}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
87
debug/thumbnailer.mjs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import sql from "../src/inc/sql.mjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import { exec as _exec } from "child_process";
|
||||||
|
|
||||||
|
const exec = cmd => new Promise((resolve, reject) => {
|
||||||
|
_exec(cmd, { maxBuffer: 5e3 * 1024 }, (err, stdout, stderr) => {
|
||||||
|
if(err)
|
||||||
|
return reject(err);
|
||||||
|
if(stderr)
|
||||||
|
console.error(stderr);
|
||||||
|
resolve({ stdout: stdout });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const _args = process.argv.slice(2);
|
||||||
|
const _itemid = +_args[0] || 0;
|
||||||
|
|
||||||
|
// Ensure temp and output directories exist
|
||||||
|
if (!fs.existsSync('./tmp')) fs.mkdirSync('./tmp', { recursive: true });
|
||||||
|
if (!fs.existsSync('./public/t')) fs.mkdirSync('./public/t', { recursive: true });
|
||||||
|
|
||||||
|
let items;
|
||||||
|
if(_itemid > 0)
|
||||||
|
items = await sql`select id, dest, mime, src from "items" where id = ${_itemid}`;
|
||||||
|
else
|
||||||
|
items = await sql`select id, dest, mime, src from "items"`;
|
||||||
|
let count = 1;
|
||||||
|
let total = items.length;
|
||||||
|
|
||||||
|
for(let item of items) {
|
||||||
|
const itemid = item.id;
|
||||||
|
const filename = item.dest;
|
||||||
|
const mime = item.mime;
|
||||||
|
const link = item.src;
|
||||||
|
try {
|
||||||
|
if(mime.startsWith('video/') || mime == 'image/gif') {
|
||||||
|
const seeks = ['20%', '40%', '60%', '80%'];
|
||||||
|
for (const seek of seeks) {
|
||||||
|
await exec(`ffmpegthumbnailer -i./public/b/${filename} -s1024 -t ${seek} -o./tmp/${itemid}.png`);
|
||||||
|
try {
|
||||||
|
const { stdout } = await exec(`magick "./tmp/${itemid}.png" -colorspace Gray -format "%[fx:mean]" info:`);
|
||||||
|
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||||
|
} catch (e) { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(mime.startsWith('image/') && mime != 'image/gif')
|
||||||
|
await exec(`magick ./public/b/${filename}[0] ./tmp/${itemid}.png`);
|
||||||
|
else if(mime.startsWith('audio/')) {
|
||||||
|
if(link.match(/soundcloud/)) {
|
||||||
|
let cover = (await exec(`yt-dlp --get-thumbnail "${link}"`)).stdout.trim();
|
||||||
|
if(!cover.match(/default_avatar/)) {
|
||||||
|
cover = cover.replace(/-(large|original)\./, '-t500x500.');
|
||||||
|
try {
|
||||||
|
await exec(`wget "${cover}" -O ./tmp/${itemid}.jpg`);
|
||||||
|
const size = (await fs.promises.stat(`./tmp/${itemid}.jpg`)).size;
|
||||||
|
if(size >= 0) {
|
||||||
|
await exec(`magick ./tmp/${itemid}.jpg ./tmp/${itemid}.png`);
|
||||||
|
await exec(`magick ./tmp/${itemid}.jpg ./public/ca/${itemid}.webp`);
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
//console.log(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await exec(`ffmpeg -i ./public/b/${filename} -update 1 -map 0:v -map 0:1 -c copy ./tmp/${itemid}.png`);
|
||||||
|
await exec(`magick ./tmp/${itemid}.png ./public/ca/${itemid}.webp`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await exec(`ffmpeg -i ./public/b/${filename} -update 1 -map 0:v -map 0:1 -c copy ./tmp/${itemid}.png`);
|
||||||
|
await exec(`magick ./tmp/${itemid}.png ./public/ca/${itemid}.webp`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await exec(`magick "./tmp/${itemid}.png" -resize "128x128^" -gravity center -crop 128x128+0+0 +repage ./public/t/${itemid}.webp`);
|
||||||
|
await fs.promises.unlink(`./tmp/${itemid}.png`).catch(err => {});
|
||||||
|
await fs.promises.unlink(`./tmp/${itemid}.jpg`).catch(err => {});
|
||||||
|
} catch(err) {
|
||||||
|
console.error(`Failed to generate thumbnail for ${itemid}:`, err.message);
|
||||||
|
// await exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
|
||||||
|
}
|
||||||
|
console.log(`current: ${itemid} (${count} / ${total})`);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Thumbnail generation complete.');
|
||||||
|
process.exit(0);
|
||||||
34
debug/trigger.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const _args = process.argv.slice(2);
|
||||||
|
const _e = {
|
||||||
|
network: "console",
|
||||||
|
message: _args.join(" "),
|
||||||
|
args: _args.slice(1),
|
||||||
|
channel: "console",
|
||||||
|
user: {
|
||||||
|
prefix: "console!console@console",
|
||||||
|
nick: "console",
|
||||||
|
username: "console",
|
||||||
|
account: "console"
|
||||||
|
},
|
||||||
|
reply: (...args) => console.log(args),
|
||||||
|
replyAction: (...args) => console.log(args),
|
||||||
|
replyNotice: (...args) => console.log(args)
|
||||||
|
};
|
||||||
|
|
||||||
|
const trigger = (await Promise.all((await fs.readdir("./src/inc/trigger"))
|
||||||
|
.filter(f => f.endsWith(".mjs"))
|
||||||
|
.map(async t => await (await import(`../src/inc/trigger/${t}`)).default())
|
||||||
|
)).filter(t => t[0].call.test(_e.message)).map(t => ({ name: t[0].name, f: t[0].f }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if(trigger.length === 0)
|
||||||
|
return console.error("no matches");
|
||||||
|
console.log(`triggered > ${trigger[0].name} (${_e.message})`);
|
||||||
|
await trigger[0].f(_e);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
110
debug/user-admin.mjs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import lib from "../src/inc/lib.mjs";
|
||||||
|
|
||||||
|
const usage = () => {
|
||||||
|
console.log(`
|
||||||
|
Usage: node user-admin.mjs <command> [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
hash <password> - Generate a password hash
|
||||||
|
create <user> <pass> [--admin] [--mod] - Create a new user
|
||||||
|
delete <user> - Delete a user
|
||||||
|
passwd <user> <new_pass> - Change a user's password
|
||||||
|
list - List all users
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.length === 0) usage();
|
||||||
|
|
||||||
|
const cmd = args[0];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
switch (cmd) {
|
||||||
|
case "hash": {
|
||||||
|
if (args.length < 2) usage();
|
||||||
|
const hash = await lib.hash(args[1]);
|
||||||
|
console.log(`Hash: ${hash}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create": {
|
||||||
|
if (args.length < 3) usage();
|
||||||
|
const username = args[1];
|
||||||
|
const password = args[2];
|
||||||
|
const isAdmin = args.includes("--admin");
|
||||||
|
const isMod = args.includes("--mod");
|
||||||
|
|
||||||
|
const hash = await lib.hash(password);
|
||||||
|
const ts = ~~(Date.now() / 1e3);
|
||||||
|
|
||||||
|
const existing = await db`select id from "user" where login = ${username.toLowerCase()}`;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
console.error(`Error: User '${username}' already exists.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await db`
|
||||||
|
insert into "user" ("login", "user", "password", "admin", "is_moderator", "created_at")
|
||||||
|
values (${username.toLowerCase()}, ${username}, ${hash}, ${isAdmin}, ${isMod}, to_timestamp(${ts}))
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const userId = newUser[0].id;
|
||||||
|
|
||||||
|
// Add default options
|
||||||
|
await db`
|
||||||
|
insert into user_options (user_id, mode, theme, avatar)
|
||||||
|
values (${userId}, 3, 'amoled', 0)
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(`Successfully created user '${username}' (ID: ${userId})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete": {
|
||||||
|
if (args.length < 2) usage();
|
||||||
|
const username = args[1].toLowerCase();
|
||||||
|
|
||||||
|
const result = await db`delete from "user" where login = ${username} returning id`;
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.error(`Error: User '${username}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Successfully deleted user '${username}' (ID: ${result[0].id})`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "passwd": {
|
||||||
|
if (args.length < 3) usage();
|
||||||
|
const username = args[1].toLowerCase();
|
||||||
|
const newPass = args[2];
|
||||||
|
|
||||||
|
const hash = await lib.hash(newPass);
|
||||||
|
const result = await db`update "user" set password = ${hash} where login = ${username} returning id`;
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.error(`Error: User '${username}' not found.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`Successfully updated password for user '${username}'`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
const users = await db`select id, login, "user", admin, is_moderator, created_at from "user" order by id asc`;
|
||||||
|
console.table(users);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
0
logs/.empty
Normal file
2428
migrations/f0ckm_schema.sql
Normal file
418
package-lock.json
generated
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
{
|
||||||
|
"name": "f0ckv2",
|
||||||
|
"version": "2.2.1",
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "f0ckv2",
|
||||||
|
"version": "2.2.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ruffle-rs/ruffle": "^0.2.0-nightly.2026.4.21",
|
||||||
|
"cuffeo": "git+https://git.lat/naimi/scuffeo.git",
|
||||||
|
"flumm-fetch": "^1.0.1",
|
||||||
|
"flummpress": "^2.0.5",
|
||||||
|
"marked": "^18.0.2",
|
||||||
|
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||||
|
"postgres": "^3.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
"version": "17.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-17.1.0.tgz",
|
||||||
|
"integrity": "sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ruffle-rs/ruffle": {
|
||||||
|
"version": "0.2.0-nightly.2026.4.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ruffle-rs/ruffle/-/ruffle-0.2.0-nightly.2026.4.21.tgz",
|
||||||
|
"integrity": "sha512-pLnuuZG6aTTPWTZs8gLDV+A6JloNju+we9dbd/ya22FwGcfFziaiARBNeI5HKn3OLK1Q216e9QeUJkQyx0jkbA==",
|
||||||
|
"license": "(MIT OR Apache-2.0)"
|
||||||
|
},
|
||||||
|
"node_modules/@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/another-json": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/base-x": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bs58": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base-x": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cuffeo": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "git+https://git.lat/naimi/scuffeo.git#4e1641c0aeaa5d4b410feb327be01eb70a787580",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"flumm-fetch": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/flumm-fetch": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/flumm-fetch/-/flumm-fetch-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pZ5U0hheCSW43vfGZQMunr03U7rUOX+iy2y13Tu4nc3iRL+E/Qfeo5nZ2B2JMYKOGIx1A1anUYOz+ulyhouyjg=="
|
||||||
|
},
|
||||||
|
"node_modules/flummpress": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/flummpress/-/flummpress-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-C/8Im6OvoZw67q9DvYIXKjKr28zHYLJdH4DucQ6zpVbN1eWPySmxkJTURbkq3uEwABXLngXLifS6mjxAC++umQ=="
|
||||||
|
},
|
||||||
|
"node_modules/is-network-error": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwt-decode": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
|
||||||
|
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/matrix-events-sdk": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/matrix-js-sdk": {
|
||||||
|
"version": "40.3.0-rc.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-40.3.0-rc.0.tgz",
|
||||||
|
"integrity": "sha512-MxFPnlzca/g5Fq/MMN5jkzwExUv725/weW+Ids7Dwmcy34WC8NROBOhfqGJkwbAWLkJDEUKRe3+//2tHXmnnHQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
|
||||||
|
"another-json": "^0.2.0",
|
||||||
|
"bs58": "^6.0.0",
|
||||||
|
"content-type": "^1.0.4",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"matrix-events-sdk": "0.0.1",
|
||||||
|
"matrix-widget-api": "^1.16.1",
|
||||||
|
"oidc-client-ts": "^3.0.1",
|
||||||
|
"p-retry": "7",
|
||||||
|
"sdp-transform": "^3.0.0",
|
||||||
|
"unhomoglyph": "^1.0.6",
|
||||||
|
"uuid": "13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/matrix-widget-api": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/events": "^3.0.0",
|
||||||
|
"events": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/oidc-client-ts": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"jwt-decode": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-retry": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-network-error": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres": {
|
||||||
|
"version": "3.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.3.4.tgz",
|
||||||
|
"integrity": "sha512-XVu0+d/Y56pl2lSaf0c7V19AhAEfpVrhID1IENWN8nf0xch6hFq6dAov5dtUX6ZD46wfr1TxvLhxLtV8WnNsOg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/porsager"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unhomoglyph": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="
|
||||||
|
},
|
||||||
|
"@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
"version": "17.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-17.1.0.tgz",
|
||||||
|
"integrity": "sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g=="
|
||||||
|
},
|
||||||
|
"@ruffle-rs/ruffle": {
|
||||||
|
"version": "0.2.0-nightly.2026.4.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ruffle-rs/ruffle/-/ruffle-0.2.0-nightly.2026.4.21.tgz",
|
||||||
|
"integrity": "sha512-pLnuuZG6aTTPWTZs8gLDV+A6JloNju+we9dbd/ya22FwGcfFziaiARBNeI5HKn3OLK1Q216e9QeUJkQyx0jkbA=="
|
||||||
|
},
|
||||||
|
"@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g=="
|
||||||
|
},
|
||||||
|
"another-json": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg=="
|
||||||
|
},
|
||||||
|
"base-x": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||||
|
},
|
||||||
|
"bs58": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||||
|
"requires": {
|
||||||
|
"base-x": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
|
||||||
|
},
|
||||||
|
"cuffeo": {
|
||||||
|
"version": "git+https://git.lat/naimi/scuffeo.git#4e1641c0aeaa5d4b410feb327be01eb70a787580",
|
||||||
|
"from": "cuffeo@git+https://git.lat/naimi/scuffeo.git",
|
||||||
|
"requires": {
|
||||||
|
"flumm-fetch": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
|
||||||
|
},
|
||||||
|
"flumm-fetch": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/flumm-fetch/-/flumm-fetch-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pZ5U0hheCSW43vfGZQMunr03U7rUOX+iy2y13Tu4nc3iRL+E/Qfeo5nZ2B2JMYKOGIx1A1anUYOz+ulyhouyjg=="
|
||||||
|
},
|
||||||
|
"flummpress": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/flummpress/-/flummpress-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-C/8Im6OvoZw67q9DvYIXKjKr28zHYLJdH4DucQ6zpVbN1eWPySmxkJTURbkq3uEwABXLngXLifS6mjxAC++umQ=="
|
||||||
|
},
|
||||||
|
"is-network-error": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw=="
|
||||||
|
},
|
||||||
|
"jwt-decode": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
|
||||||
|
},
|
||||||
|
"loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="
|
||||||
|
},
|
||||||
|
"marked": {
|
||||||
|
"version": "18.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
|
||||||
|
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg=="
|
||||||
|
},
|
||||||
|
"matrix-events-sdk": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
|
||||||
|
},
|
||||||
|
"matrix-js-sdk": {
|
||||||
|
"version": "40.3.0-rc.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-40.3.0-rc.0.tgz",
|
||||||
|
"integrity": "sha512-MxFPnlzca/g5Fq/MMN5jkzwExUv725/weW+Ids7Dwmcy34WC8NROBOhfqGJkwbAWLkJDEUKRe3+//2tHXmnnHQ==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
|
||||||
|
"another-json": "^0.2.0",
|
||||||
|
"bs58": "^6.0.0",
|
||||||
|
"content-type": "^1.0.4",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"matrix-events-sdk": "0.0.1",
|
||||||
|
"matrix-widget-api": "^1.16.1",
|
||||||
|
"oidc-client-ts": "^3.0.1",
|
||||||
|
"p-retry": "7",
|
||||||
|
"sdp-transform": "^3.0.0",
|
||||||
|
"unhomoglyph": "^1.0.6",
|
||||||
|
"uuid": "13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"matrix-widget-api": {
|
||||||
|
"version": "1.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
|
||||||
|
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/events": "^3.0.0",
|
||||||
|
"events": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oidc-client-ts": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
|
||||||
|
"requires": {
|
||||||
|
"jwt-decode": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"p-retry": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
|
||||||
|
"requires": {
|
||||||
|
"is-network-error": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"version": "3.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.3.4.tgz",
|
||||||
|
"integrity": "sha512-XVu0+d/Y56pl2lSaf0c7V19AhAEfpVrhID1IENWN8nf0xch6hFq6dAov5dtUX6ZD46wfr1TxvLhxLtV8WnNsOg=="
|
||||||
|
},
|
||||||
|
"sdp-transform": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ=="
|
||||||
|
},
|
||||||
|
"unhomoglyph": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
|
||||||
|
},
|
||||||
|
"uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "f0ckv2",
|
||||||
|
"version": "2.2.1",
|
||||||
|
"description": "f0ck, kennste?",
|
||||||
|
"main": "index.mjs",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node --trace-uncaught src/index.mjs",
|
||||||
|
"trigger": "node debug/trigger.mjs",
|
||||||
|
"autotagger": "node debug/autotagger.mjs",
|
||||||
|
"thumbnailer": "node debug/thumbnailer.mjs",
|
||||||
|
"test": "node debug/test.mjs",
|
||||||
|
"clean": "node debug/clean.mjs",
|
||||||
|
"fix:deleted": "node debug/fix_deleted.mjs",
|
||||||
|
"build": "node scripts/build-css.mjs"
|
||||||
|
},
|
||||||
|
"author": "Flummi",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ruffle-rs/ruffle": "0.2.0-nightly.2026.4.21",
|
||||||
|
"cuffeo": "git+https://git.lat/naimi/scuffeo.git",
|
||||||
|
"flumm-fetch": "^1.0.1",
|
||||||
|
"flummpress": "^2.0.5",
|
||||||
|
"marked": "^18.0.2",
|
||||||
|
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||||
|
"postgres": "^3.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
public/manifest.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"description": "",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#0096ff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/s/img/favicon.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"categories": ["entertainment", "social", "memes"]
|
||||||
|
}
|
||||||
6
public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
User-agent: AdsBot-Google
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
BIN
public/s/95.ttf
Normal file
BIN
public/s/OpenSans.ttf
Normal file
463
public/s/css/dm.css
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════
|
||||||
|
PRIVATE MESSAGES / DM SYSTEM
|
||||||
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Page layout */
|
||||||
|
.messages-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-back-btn {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.4em;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-back-btn:hover { background: var(--bg2, #222); }
|
||||||
|
|
||||||
|
.dm-convo-header-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header-username {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg, #ddd);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-header-username:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Key notice banner */
|
||||||
|
.dm-key-notice {
|
||||||
|
background: rgba(255, 200, 80, 0.15);
|
||||||
|
border: 1px solid rgba(255, 200, 80, 0.4);
|
||||||
|
color: #ffc850;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-export-inline {
|
||||||
|
background: rgba(255, 200, 80, 0.25);
|
||||||
|
border: 1px solid rgba(255, 200, 80, 0.5);
|
||||||
|
color: #ffc850;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-export-inline:hover { background: rgba(255, 200, 80, 0.4); }
|
||||||
|
|
||||||
|
/* Inbox list */
|
||||||
|
.dm-inbox-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg2, #1a1a1a);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg, #ccc);
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-card:hover {
|
||||||
|
background: var(--bg3, #222);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-card.dm-convo-unread { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dm-convo-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-time {
|
||||||
|
font-size: 0.78em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-convo-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg, #000);
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thread */
|
||||||
|
.dm-thread {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: calc(100vh - 280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-load-more {
|
||||||
|
align-self: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #888;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-load-more:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Message bubbles */
|
||||||
|
.dm-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 75%;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-mine {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-theirs {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-bubble {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-mine .dm-bubble {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg, #000);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-theirs .dm-bubble {
|
||||||
|
background: var(--bg2, #222);
|
||||||
|
color: var(--fg, #ddd);
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-time {
|
||||||
|
font-size: 0.72em;
|
||||||
|
color: #555;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-unreadable {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send form: column layout matching comment form */
|
||||||
|
.dm-send-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg2, #1a1a1a);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: var(--fg, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.92em;
|
||||||
|
resize: none;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 140px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions bar: ☺ emoji trigger on left, Send on right */
|
||||||
|
.dm-send-form .input-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg, #000);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-btn:hover { opacity: 0.85; }
|
||||||
|
.dm-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Emojis rendered inside DM bubbles */
|
||||||
|
.dm-bubble img.emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* State messages */
|
||||||
|
.dm-loading, .dm-empty, .dm-error {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-error { color: #e06c6c; }
|
||||||
|
|
||||||
|
/* Key Manager Modal */
|
||||||
|
.dm-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
z-index: 15000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-modal {
|
||||||
|
background: var(--bg, #111);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-modal h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-modal-sub {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
font-size: 1.4em;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-modal-close:hover { color: var(--fg, #ddd); }
|
||||||
|
|
||||||
|
.dm-key-status {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||||
|
|
||||||
|
.dm-key-section h3 {
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--fg, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-section p {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #777;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg2, #1a1a1a);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: var(--fg, #ddd);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.88em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.dm-key-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg, #000);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.86em;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-btn:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.dm-key-btn-danger {
|
||||||
|
background: #d94f4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-key-danger h3 { color: #d94f4f; }
|
||||||
|
|
||||||
|
.dm-key-msg {
|
||||||
|
font-size: 0.82em;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-msg-ok { color: #5cb85c; }
|
||||||
|
.dm-msg-err { color: #e06c6c; }
|
||||||
|
|
||||||
|
/* Navbar DM icon */
|
||||||
|
#nav-dm-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.messages-page { padding: 10px; }
|
||||||
|
.dm-msg { max-width: 90%; }
|
||||||
|
.dm-thread { max-height: calc(100vh - 240px); }
|
||||||
|
.dm-modal { padding: 20px; }
|
||||||
|
}
|
||||||
11833
public/s/css/f0ckm.css
Normal file
262
public/s/css/meme-creator.css
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Impact';
|
||||||
|
src: url('/s/impact.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meme Creator Styles */
|
||||||
|
|
||||||
|
.meme-creator-container {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-header {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-bottom: 1px solid var(--accent, #9f0);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-title {
|
||||||
|
font-family: var(--nav-brand-font, 'VCR'), monospace;
|
||||||
|
color: var(--accent, #9f0);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-subtitle {
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
color: #888;
|
||||||
|
margin: 5px 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template Grid */
|
||||||
|
.template-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item {
|
||||||
|
background: var(--nav-bg, #2b2b2b);
|
||||||
|
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, .05));
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
border-color: var(--accent, #9f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-image-wrapper {
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-image-wrapper img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-name {
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
color: var(--white, #fff);
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-category-tag {
|
||||||
|
font-size: 0.7em;
|
||||||
|
color: var(--accent, #9f0);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Creator Layout - Simple Flexbox */
|
||||||
|
.meme-editor-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row; /* Col 1: Meme, Col 2: Controls */
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Allow shrinking */
|
||||||
|
background: #000;
|
||||||
|
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#memeCanvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-controls {
|
||||||
|
width: 380px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--nav-bg, #2b2b2b);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, .05));
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles — SCOPED to .meme-layout-wrapper to prevent global leakage */
|
||||||
|
.meme-layout-wrapper .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .form-group label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
color: var(--accent, #9f0);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .form-group textarea,
|
||||||
|
.meme-layout-wrapper .form-group input[type="text"],
|
||||||
|
.meme-layout-wrapper .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--black, #000);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
padding: 10px;
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .form-group textarea:focus,
|
||||||
|
.meme-layout-wrapper .form-group input[type="text"]:focus {
|
||||||
|
border-color: var(--accent, #9f0);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
text-transform: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--accent, #9f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .layer-input-group {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .layer-input-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .remove-layer:hover {
|
||||||
|
color: #ff0000 !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .btn — SCOPED to .meme-layout-wrapper to prevent global leakage into navbar */
|
||||||
|
.meme-layout-wrapper .btn {
|
||||||
|
padding: 12px;
|
||||||
|
font-family: var(--font, monospace);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .btn-primary {
|
||||||
|
background: var(--accent, #9f0);
|
||||||
|
color: var(--black, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meme-layout-wrapper .btn-secondary {
|
||||||
|
background: #444;
|
||||||
|
color: var(--white, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Stacking - Simple 2-Row Layout */
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.meme-editor-layout {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
grid-template-rows: 0.6fr 1fr !important;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.meme-controls {
|
||||||
|
width: 100% !important;
|
||||||
|
grid-row: 2;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.canvas-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
grid-row: 1;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.meme-creator-container, .meme-select-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.template-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
816
public/s/css/upload.css
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
/* Upload Page Styles */
|
||||||
|
.upload-container {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
opacity: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@keyframes uploadReveal {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-container h2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Limit Info */
|
||||||
|
.upload-limit-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-limit-info i {
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-unlimited {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-exhausted {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limit-remaining {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Form */
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 5px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
pointer-events: none;
|
||||||
|
/* Let input handle clicks */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Preview (Stacked) */
|
||||||
|
.file-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Stacked */
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
/* remove margin-top as it's now in a flex row */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background: #fa5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ratings */
|
||||||
|
@media(max-width: 700px) {
|
||||||
|
.rating-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option:nth-child(3) {
|
||||||
|
grid-column: 1 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestions {
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label {
|
||||||
|
display: block;
|
||||||
|
padding: 0.1rem 2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.sfw {
|
||||||
|
background: rgba(6, 115, 24, 0.36);
|
||||||
|
border-color: rgba(19, 134, 38, 0.96);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.sfw:hover {
|
||||||
|
background: #04200c9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.nsfw {
|
||||||
|
background: #cb009866;
|
||||||
|
border-color: rgb(255, 0, 227);
|
||||||
|
color: #f9f9f9;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.nsfw:hover {
|
||||||
|
background: rgba(69, 0, 45, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.nsfl {
|
||||||
|
background: #2b01016e;
|
||||||
|
color: #f2f2f2;
|
||||||
|
border-color: #990000;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.nsfl:hover {
|
||||||
|
background: #130101;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input:checked + .rating-label.sfw {
|
||||||
|
background: rgba(6, 130, 8, 0.73);
|
||||||
|
border-color: #51cf66;
|
||||||
|
color: var(--white);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input:checked + .rating-label.nsfw {
|
||||||
|
background: #880059;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-form), 0 0 0 4px #cd0030;
|
||||||
|
color: var(--white);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input:checked + .rating-label.nsfl {
|
||||||
|
background: #500404;
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-form), 0 0 0 4px #660000;
|
||||||
|
color: var(--white);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-input-container {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 10000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.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;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Comment */
|
||||||
|
.upload-comment-input {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-comment {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 200px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-comment:focus {
|
||||||
|
border-color: var(--accent, #7c5cbf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-comment::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-comment-input .input-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-comment-input .emoji-picker {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form .tag-suggestions {
|
||||||
|
position: absolute !important;
|
||||||
|
min-width: 220px !important;
|
||||||
|
max-width: 320px !important;
|
||||||
|
max-height: 260px !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
background: var(--dropdown-bg, #1a1a1a) !important;
|
||||||
|
border: 1px solid var(--black, #000) !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6) !important;
|
||||||
|
margin-top: 8px !important; /* Spacing from input */
|
||||||
|
display: none; /* Controlled by JS .style.display */
|
||||||
|
z-index: 200000 !important;
|
||||||
|
animation: tagDropIn 0.15s ease-out !important;
|
||||||
|
scrollbar-width: thin !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tagDropIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form .tag-suggestion-item {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: background 0.12s !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form .tag-suggestion-item:not(:last-child) {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form .tag-suggestion-item:hover,
|
||||||
|
#upload-form .tag-suggestion-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form .tag-suggestion-name {
|
||||||
|
font-size: 14px !important;
|
||||||
|
color: var(--accent, #66d9ef) !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#upload-form .tag-suggestion-meta {
|
||||||
|
font-size: 11px !important;
|
||||||
|
color: rgba(255, 255, 255, 0.35) !important;
|
||||||
|
margin-left: 12px !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit Button */
|
||||||
|
.btn-upload {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.success {
|
||||||
|
color: #51cf66;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Required */
|
||||||
|
.login-required {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Mode Tabs */
|
||||||
|
.upload-mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab:first-child {
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-mode-tab svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL Upload */
|
||||||
|
.url-input-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-container input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-container input[type="url"]:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input-container input[type="url"]::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-type-badge {
|
||||||
|
display: none; /* shown via JS */
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 5px 14px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-type-badge.youtube {
|
||||||
|
background: #ff0000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-type-badge.direct {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-type-badge.fetching {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-type-badge.success {
|
||||||
|
background: #2b8a3e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: metaSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes metaSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
/* Metadata Sync Spinner */
|
||||||
|
.sync-spinner {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 5px 0 5px 5px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-spinner.active {
|
||||||
|
display: flex;
|
||||||
|
animation: spinnerReveal 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinnerReveal {
|
||||||
|
from { opacity: 0; transform: translateX(-5px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-spinner .spinner-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: metaSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestions-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(88, 101, 242, 0.05);
|
||||||
|
border: 1px dashed rgba(88, 101, 242, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion {
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion i {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion:hover i {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion.selected {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-suggestion.selected i {
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Privacy Warning Banner */
|
||||||
|
.gps-privacy-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 8px 0;
|
||||||
|
background: rgba(255, 160, 0, 0.12);
|
||||||
|
border: 1px solid rgba(255, 160, 0, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #ffa500;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.gps-privacy-warning i { flex-shrink: 0; font-size: 1.1em; }
|
||||||
|
.gps-privacy-warning span { flex: 1; min-width: 160px; color: var(--text, #ccc); }
|
||||||
|
.gps-privacy-warning span strong { color: #ffa500; }
|
||||||
|
.gps-strip-btn {
|
||||||
|
background: #ffa500;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.gps-strip-btn:hover { background: #ffb733; }
|
||||||
|
.gps-strip-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.gps-dismiss-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.gps-dismiss-btn:hover { color: #ccc; }
|
||||||
|
.gps-privacy-warning.gps-stripped {
|
||||||
|
background: rgba(76, 175, 80, 0.12);
|
||||||
|
border-color: rgba(76, 175, 80, 0.4);
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
.gps-privacy-warning.gps-stripped i,
|
||||||
|
.gps-privacy-warning.gps-stripped span { color: #4caf50; }
|
||||||
495
public/s/css/v0ck.css
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
.v0ck {
|
||||||
|
position: relative;
|
||||||
|
font-size: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
/* background-color: #000;*/
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
touch-action: none; /* Prevent pull-to-refresh and scroll while interacting */
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck video {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck.v0ck_fullscreen {
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main:fullscreen .v0ck.v0ck_fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 2147483647;
|
||||||
|
background-color: #000 !important;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main:fullscreen .v0ck.v0ck_fullscreen video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio in fullscreen: hide the invisible audio element, show cover art via background */
|
||||||
|
#main:fullscreen .v0ck.v0ck_fullscreen audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_overlay {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck.v0ck_initial .v0ck_overlay {
|
||||||
|
display: block;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_overlay>svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
height: 60px;
|
||||||
|
width: 60px;
|
||||||
|
filter: drop-shadow(0 0 9px var(--accent));
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_button {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
line-height: 1;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
outline: 0;
|
||||||
|
padding: 8px 6px; /* Increased hit area height and horizontal spacing */
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 36px;
|
||||||
|
height: 40px; /* Consistent taller height for easier tapping */
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_fs_btn {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_button.v0ck_tplay>svg {
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_button svg:hover {
|
||||||
|
filter: drop-shadow(0 0 9px var(--accent));
|
||||||
|
fill: #000;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_controls svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_controls {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 20%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
transition: opacity .3s, transform .3s;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
transform: translateY(100%) translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck:hover .v0ck_player_controls,
|
||||||
|
.v0ck.v0ck_hover .v0ck_player_controls,
|
||||||
|
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck:hover .v0ck_progress,
|
||||||
|
.v0ck.v0ck_hover .v0ck_progress,
|
||||||
|
.v0ck.v0ck_swf_active .v0ck_progress {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.v0ck_progress {
|
||||||
|
flex: 10;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-basis: 100%;
|
||||||
|
height: 5px;
|
||||||
|
transition: height 0.4s;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_progress_buffered {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_progress_filled {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
flex-basis: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_controls>input[type="range"][name="volume"]::after {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
text-shadow: 1px 1px 1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"] {
|
||||||
|
position: relative;
|
||||||
|
height: 5px;
|
||||||
|
margin: auto;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: min-width 0.3s, max-width 0.3s, opacity 0.3s;
|
||||||
|
flex: none;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.v0ck_volume_group:hover input[type="range"][name="volume"] {
|
||||||
|
min-width: 50px;
|
||||||
|
max-width: 50px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_controls>input[type=range]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_player_button.v0ck_playtime {
|
||||||
|
max-width: none;
|
||||||
|
cursor: default;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume/Gesture HUD */
|
||||||
|
.v0ck_hud {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_hud:not(.v0ck_hidden) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_hud svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_hud_bar_container {
|
||||||
|
width: 100px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_hud_bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-runnable-track {
|
||||||
|
background-color: rgb(65, 65, 65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"]::-moz-range-track {
|
||||||
|
height: 5px;
|
||||||
|
background-color: rgb(65, 65, 65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--accent);
|
||||||
|
height: 5px;
|
||||||
|
width: 0.1px;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: -100vw 0 0 100vw var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_volume_group input[type="range"][name="volume"]::-moz-range-thumb {
|
||||||
|
background: var(--accent);
|
||||||
|
height: 5px;
|
||||||
|
width: 0.1px;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: -100vw 0 0 100vw var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck.v0ck_no_transition .v0ck_player_controls,
|
||||||
|
.v0ck.v0ck_no_transition .v0ck_progress {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seek Marker Ripple */
|
||||||
|
.v0ck_seek_marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 2px;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_seek_marker.active {
|
||||||
|
animation: v0ck-seek-ripple 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v0ck-seek-ripple {
|
||||||
|
0% { opacity: 0.8; transform: scaleX(1); }
|
||||||
|
100% { opacity: 0; transform: scaleX(20); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.v0ck_loader {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 101;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v0ck_loader div {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 4px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: v0ck-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes v0ck-spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Danmaku overlay ───────────────────────────────────────── */
|
||||||
|
.danmaku-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow-x: clip;
|
||||||
|
overflow-y: visible;
|
||||||
|
z-index: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-pill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
white-space: normal; /* allow multiline for quotes */
|
||||||
|
max-width: 70vw; /* prevent full-width wrapping */
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column; /* stack lines vertically */
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 2px 0;
|
||||||
|
font-family: var(--font, inherit);
|
||||||
|
font-size: 35px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
gap: 0;
|
||||||
|
color: #fff;
|
||||||
|
/* Multi-layer outline shadow for readability over any background */
|
||||||
|
text-shadow:
|
||||||
|
-1px -1px 0 #000,
|
||||||
|
1px -1px 0 #000,
|
||||||
|
-1px 1px 0 #000,
|
||||||
|
1px 1px 0 #000,
|
||||||
|
0 0 6px rgba(0,0,0,0.9),
|
||||||
|
0 0 12px rgba(0,0,0,0.6);
|
||||||
|
will-change: transform;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-pill .dpill-text {
|
||||||
|
font-family: var(--font, inherit);
|
||||||
|
font-size: 35px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each plain text line inside a multiline pill */
|
||||||
|
.danmaku-pill .dpill-line {
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quoted lines > rendered as greentext */
|
||||||
|
.danmaku-pill .dpill-greentext {
|
||||||
|
color: #78b87a;
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spoiler inside a flying pill — black-on-black, click to reveal */
|
||||||
|
.danmaku-pill .dpill-spoiler {
|
||||||
|
background: #000;
|
||||||
|
color: #000 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
/* Hide images (emojis, inline imgs) inside unrevealed spoiler */
|
||||||
|
.danmaku-pill .dpill-spoiler img {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.danmaku-pill .dpill-spoiler:hover,
|
||||||
|
.danmaku-pill .dpill-spoiler.revealed {
|
||||||
|
color: #fff !important;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
text-shadow:
|
||||||
|
-1px -1px 0 #000, 1px -1px 0 #000,
|
||||||
|
-1px 1px 0 #000, 1px 1px 0 #000;
|
||||||
|
}
|
||||||
|
.danmaku-pill .dpill-spoiler:hover img,
|
||||||
|
.danmaku-pill .dpill-spoiler.revealed img {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blur inside a flying pill — blurred text, click/hover to clear */
|
||||||
|
.danmaku-pill .dpill-blur {
|
||||||
|
filter: blur(6px);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: filter 0.25s ease;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
|
.danmaku-pill .dpill-blur:hover,
|
||||||
|
.danmaku-pill .dpill-blur.revealed {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emoji inline image */
|
||||||
|
.danmaku-pill .dpill-emoji {
|
||||||
|
height: 1.3em;
|
||||||
|
width: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline image URL embedded in pill */
|
||||||
|
.danmaku-pill .dpill-img {
|
||||||
|
max-height: 80px;
|
||||||
|
max-width: 120px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes danmaku-fly {
|
||||||
|
from { transform: translateX(calc(100vw + 100%)); }
|
||||||
|
to { transform: translateX(calc(-100% - 200px)); }
|
||||||
|
}
|
||||||
9
public/s/fa/all.min.css
vendored
Normal file
BIN
public/s/fa/webfonts/fa-brands-400.ttf
Normal file
BIN
public/s/fa/webfonts/fa-brands-400.woff2
Normal file
BIN
public/s/fa/webfonts/fa-regular-400.ttf
Normal file
BIN
public/s/fa/webfonts/fa-regular-400.woff2
Normal file
BIN
public/s/fa/webfonts/fa-solid-900.ttf
Normal file
BIN
public/s/fa/webfonts/fa-solid-900.woff2
Normal file
BIN
public/s/fa/webfonts/fa-v4compatibility.ttf
Normal file
BIN
public/s/fa/webfonts/fa-v4compatibility.woff2
Normal file
BIN
public/s/fonts/Hack.woff
Normal file
BIN
public/s/fonts/Manrope-Bold.woff2
Normal file
BIN
public/s/fonts/Manrope-ExtraBold.woff2
Normal file
BIN
public/s/fonts/Manrope-ExtraLight.woff2
Normal file
BIN
public/s/fonts/Manrope-Light.woff2
Normal file
BIN
public/s/fonts/Manrope-Medium.woff2
Normal file
BIN
public/s/fonts/Manrope-Regular.woff2
Normal file
BIN
public/s/fonts/Manrope-SemiBold.woff2
Normal file
BIN
public/s/img/404.gif
Normal file
|
After Width: | Height: | Size: 652 KiB |
BIN
public/s/img/audio.webp
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/s/img/favicon.gif
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
24
public/s/img/iconset.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
|
||||||
|
<defs>
|
||||||
|
<symbol id="heart_regular" viewBox="0 0 512 512"><path d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"/></symbol>
|
||||||
|
<symbol id="heart_solid" viewBox="0 0 512 512"><path d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"/></symbol>
|
||||||
|
<symbol id="cross" viewBox="0 0 512 512"><path d="M53.6,62.3 L458.4,458.4 M458.4,62.3 L53.6,458.4" style="stroke-linecap: round;stroke-width: 60;"/></symbol>
|
||||||
|
<symbol id="window-maximize" viewBox="0 0 512 512"><path d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z" style="fill: var(--maximize_button);"/></symbol>
|
||||||
|
<symbol id="window-minimize" viewBox="0 0 512 512"><path d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zm-96 464H48V256h320v208zm96-96h-48V144c0-26.5-21.5-48-48-48H144V48h320v320z" style="fill: var(--maximize_button);"/></symbol>
|
||||||
|
<symbol id="plus" viewBox="0 0 512 512"><path d="M256 80c0-8.84-7.16-16-16-16s-16 7.16-16 16v160H64c-8.84 0-16 7.16-16 16s7.16 16 16 16h160v160c0 8.84 7.16 16 16 16s16-7.16 16-16V272h160c8.84 0 16-7.16 16-16s-7.16-16-16-16H272V80z"/></symbol>
|
||||||
|
<symbol id="tag" viewBox="0 0 512 512"><path d="M0 252.118V48C0 21.49 21.49 0 48 0h204.118a48 48 0 0 1 33.941 14.059l211.882 211.882c18.745 18.745 18.745 49.137 0 67.882L293.823 497.941c-18.745 18.745-49.137 18.745-67.882 0L14.059 286.059A48 48 0 0 1 0 252.118zM112 64a48 48 0 1 0 48 48 48.055 48.055 0 0 0-48-48z"/></symbol>
|
||||||
|
<symbol id="shield" viewBox="0 0 512 512"><path d="M466.5 83.71c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91a27.3 27.3 0 0 1-31.4-18.41V28c0-15.46-12.54-28-28-28h-74.8c-15.46 0-28 12.54-28 28v37.39a27.3 27.3 0 0 1-31.4 18.41c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91A27.3 27.3 0 0 1 12 102.12V256c0 141.38 102.34 230.13 234.33 254.12a27.5 27.5 0 0 0 9.34 0C387.66 486.13 490 397.38 490 256V102.12a27.3 27.3 0 0 1-23.5-18.41z"/></symbol>
|
||||||
|
<symbol id="bell_solid" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></symbol>
|
||||||
|
<symbol id="bell_regular" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM44.75 330.4C63.2 312 96 281.3 96 208c0-61.94 43.15-112 112-112s112 50.06 112 112c0 73.3 32.8 104 51.25 122.4 2.8 2.8 3.5 6.9 1.75 10.4s-5.4 5.6-9.15 5.6H52.1c-3.75 0-7.35-2.1-9.1-5.6s-1.05-7.6 1.75-10.4z"/></symbol>
|
||||||
|
<symbol id="pin_solid" viewBox="0 0 24 24"><path d="M12 17h-7v-1.76a2 2 0 0 1 1.11-1.79l1.78-.9a2 0 0 0 1.11-1.76v-4.79a3 3 0 0 1 3-3 3 3 0 0 1 3 3v4.76a2 2 0 0 0 1.11 1.79l1.78.9a2 0 0 1 1.11 1.79v1.76h-7v5z" fill="currentColor" transform="rotate(45 12 12)"/></symbol>
|
||||||
|
<symbol id="pin_regular" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"></line><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path></g></symbol>
|
||||||
|
<symbol id="building" viewBox="0 0 512 512"><path d="M432 64H80c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16zm-32 352H112V96h288v320zM192 288h128v32H192zm0-64h128v32H192zm0-64h128v32H192z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="arrow-down" viewBox="0 0 448 512"><path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="arrow-up" viewBox="0 0 448 512"><path d="M34.9 289.5l-22.2-22.2c-9.4-9.4-9.4-24.6 0-33.9L207 39c9.4-9.4 24.6-9.4 33.9 0l194.3 194.3c9.4 9.4 9.4 24.6 0 33.9l-22.2 22.2c-9.5 9.5-25 9.3-34.3-.4L264 168.6V456c0 13.3-10.7 24-24 24h-32c-13.3 0-24-10.7-24-24V168.6L69.2 289.1c-9.3 9.8-24.8 10-34.3.4z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="star_regular" viewBox="0 0 576 512"><path d="M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 137.4L288 384.8l-124.3 64.9 23.7-137.4L87.5 214.4l138-20.1L288 69.1l62.5 125.2 138 20.1-99.9 97.9z" fill="currentColor"/></symbol>
|
||||||
|
<symbol id="star_solid" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z" fill="currentColor"/></symbol>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/s/img/loool.webp
Normal file
|
After Width: | Height: | Size: 7.0 MiB |
BIN
public/s/img/music.webp
Normal file
|
After Width: | Height: | Size: 634 KiB |
BIN
public/s/img/swf.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
17
public/s/img/v0ck.svg
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
|
||||||
|
<defs>
|
||||||
|
<symbol id="pause" viewBox="0 0 448 512"><path d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"/></symbol>
|
||||||
|
<symbol id="play" viewBox="0 0 448 512"><path d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"/></symbol>
|
||||||
|
<symbol id="fullscreen" viewBox="0 0 448 512"><path d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"/></symbol>
|
||||||
|
<symbol id="backward" viewBox="0 0 512 512"><path d="M11.5 280.6l192 160c20.6 17.2 52.5 2.8 52.5-24.6V96c0-27.4-31.9-41.8-52.5-24.6l-192 160c-15.3 12.8-15.3 36.4 0 49.2zm256 0l192 160c20.6 17.2 52.5 2.8 52.5-24.6V96c0-27.4-31.9-41.8-52.5-24.6l-192 160c-15.3 12.8-15.3 36.4 0 49.2z"/></symbol>
|
||||||
|
<symbol id="forward" viewBox="0 0 512 512"><path d="M500.5 231.4l-192-160C287.9 54.3 256 68.6 256 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2zm-256 0l-192-160C31.9 54.3 0 68.6 0 96v320c0 27.4 31.9 41.8 52.5 24.6l192-160c15.3-12.8 15.3-36.4 0-49.2z"/></symbol>
|
||||||
|
<symbol id="volume_mute" viewBox="0 0 512 512"><path d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM461.64 256l45.64-45.64c6.3-6.3 6.3-16.52 0-22.82l-22.82-22.82c-6.3-6.3-16.52-6.3-22.82 0L416 210.36l-45.64-45.64c-6.3-6.3-16.52-6.3-22.82 0l-22.82 22.82c-6.3 6.3-6.3 16.52 0 22.82L370.36 256l-45.63 45.63c-6.3 6.3-6.3 16.52 0 22.82l22.82 22.82c6.3 6.3 16.52 6.3 22.82 0L416 301.64l45.64 45.64c6.3 6.3 16.52 6.3 22.82 0l22.82-22.82c6.3-6.3 6.3-16.52 0-22.82L461.64 256z"/></symbol>
|
||||||
|
<symbol id="volume_mid" viewBox="0 0 384 512"><path d="M215.03 72.04L126.06 161H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V89.02c0-21.47-25.96-31.98-40.97-16.98zm123.2 108.08c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 229.28 336 242.62 336 257c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.87z"/></symbol>
|
||||||
|
<symbol id="volume_full" viewBox="0 0 500 512"><path d="M232.35938,64.009766 c -0.39117,-0.0089 -0.78371,-0.0083 -1.17579,0.002 -5.70289,0.149548 -11.46367,2.348437 -16.15429,7.039062 L 126.06055,160 H 24 C 10.74,160 0,170.74 0,184 v 144 c 0,13.25 10.74,24 24,24 h 102.06055 l 88.96875,88.94922 c 15.03,15.03 40.9707,4.47125 40.9707,-16.96875 V 88.019531 C 256,73.726836 244.48559,64.2867 232.35938,64.009766 Z m 149.5,31.99414 c -8.10621,-0.16023 -16.09723,3.814342 -20.75,11.216794 -7.09002,11.28 -3.77985,26.20938 7.41015,33.35938 C 408.2695,165.97008 432,209.11 432,256 c 0,46.89 -23.73047,90.02992 -63.48047,115.41992 -11.19,7.14 -14.50015,22.06938 -7.41015,33.35938 6.50999,10.36 21.12109,15.14093 33.12109,7.46093 C 447.94047,377.94023 480,319.54 480,256 480,192.47 447.94047,134.05977 394.23047,99.759766 c -3.84656,-2.454375 -8.125,-3.67193 -12.37109,-3.75586 z m -55.03126,80.173824 c -8.5099,-0.0456 -16.79523,4.42047 -21.20898,12.40235 -6.39,11.61 -2.15883,26.19937 9.45117,32.60937 C 327.98028,228.27945 336,241.63 336,256 c 0,14.38 -8.01992,27.72055 -20.91992,34.81055 -11.61,6.41 -15.83922,20.99937 -9.44922,32.60937 6.43,11.66 21.04937,15.79922 32.60937,9.44922 28.23,-15.55 45.76954,-44.99891 45.76954,-76.87891 0,-31.88 -17.5393,-61.31937 -45.7793,-76.85937 -3.61875,-1.97813 -7.53414,-2.9324 -11.40235,-2.95313 z"/></symbol>
|
||||||
|
<symbol id="ol_play" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm115.7 272l-176 101c-15.8 8.8-35.7-2.5-35.7-21V152c0-18.4 19.8-29.8 35.7-21l176 107c16.4 9.2 16.4 32.9 0 42z"/></symbol>
|
||||||
|
<symbol id="cogs" viewBox="0 0 512 512"><path d="M487.4 315.7l-42.6-24.6c4.3-23.2 4.3-47 0-70.2l42.6-24.6c4.9-2.8 7.1-8.6 5.5-14-11.1-35.6-30-67.8-54.7-94.6-3.8-4.1-10-5.1-14.8-2.3L380.8 110c-17.9-15.4-38.5-27.3-60.8-35.1V25.8c0-5.6-3.9-10.5-9.4-11.7-36.7-8.2-74.3-7.8-109.2 0-5.5 1.2-9.4 6.1-9.4 11.7V75c-22.2 7.9-42.8 19.8-60.8 35.1L88.7 85.5c-4.9-2.8-11-1.9-14.8 2.3-24.7 26.7-43.6 58.9-54.7 94.6-1.7 5.4.6 11.2 5.5 14L67.3 221c-4.3 23.2-4.3 47 0 70.2l-42.6 24.6c-4.9 2.8-7.1 8.6-5.5 14 11.1 35.6 30 67.8 54.7 94.6 3.8 4.1 10 5.1 14.8 2.3l42.6-24.6c17.9 15.4 38.5 27.3 60.8 35.1v49.2c0 5.6 3.9 10.5 9.4 11.7 36.7 8.2 74.3 7.8 109.2 0 5.5-1.2 9.4-6.1 9.4-11.7v-49.2c22.2-7.9 42.8-19.8 60.8-35.1l42.6 24.6c4.9 2.8 11 1.9 14.8-2.3 24.7-26.7 43.6-58.9 54.7-94.6 1.5-5.5-.7-11.3-5.6-14.1zM256 336c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z"/></symbol>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
448
public/s/js/admin.js
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
(async () => {
|
||||||
|
// Helper to get dynamic context
|
||||||
|
const getContext = () => {
|
||||||
|
const idLink = document.querySelector("a.id-link");
|
||||||
|
if (!idLink) return null;
|
||||||
|
const tagsContainer = document.querySelector("#tags");
|
||||||
|
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||||
|
const usernameEl = document.querySelector("a#a_username");
|
||||||
|
return {
|
||||||
|
postid: +idLink.innerText,
|
||||||
|
// Prefer data-username (raw DB username) over innerText (may be a display name)
|
||||||
|
poster: usernameEl?.dataset?.username || usernameEl?.innerText?.trim() || null,
|
||||||
|
tags: [...inner.querySelectorAll(".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",
|
||||||
|
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
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, highlightTag = null) => {
|
||||||
|
const tagsContainer = document.querySelector("#tags");
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||||
|
|
||||||
|
// Only remove existing dynamically generated tags
|
||||||
|
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||||
|
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
||||||
|
if (!tag.querySelector('#a_addtag') && !tag.querySelector('#a_toggle') && !tag.classList.contains('tag-ac-wrapper')) {
|
||||||
|
tag.parentElement.removeChild(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tags.reverse().forEach(tag => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = `/tag/${tag.normalized}`;
|
||||||
|
a.style = "color: inherit !important";
|
||||||
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.classList.add("badge", "mr-2");
|
||||||
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
|
span.classList.add('new-tag-glow');
|
||||||
|
}
|
||||||
|
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||||
|
|
||||||
|
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||||
|
|
||||||
|
const delbutton = document.createElement("a");
|
||||||
|
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||||
|
delbutton.href = "javascript:void(0)";
|
||||||
|
// Class for delegation
|
||||||
|
delbutton.classList.add("admin-deltag", "removetag");
|
||||||
|
|
||||||
|
span.appendChild(a);
|
||||||
|
span.appendChild(document.createTextNode('\u00A0'));
|
||||||
|
span.appendChild(delbutton);
|
||||||
|
|
||||||
|
inner.insertAdjacentElement("afterbegin", span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle show more/less toggle visibility and count
|
||||||
|
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||||
|
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||||
|
|
||||||
|
let toggle = tagsContainer.querySelector(".show-tags-toggle");
|
||||||
|
|
||||||
|
if (realTags.length > 10) {
|
||||||
|
if (!toggle) {
|
||||||
|
toggle = document.createElement("a");
|
||||||
|
toggle.href = "#";
|
||||||
|
toggle.className = "show-tags-toggle";
|
||||||
|
tagsContainer.appendChild(toggle);
|
||||||
|
}
|
||||||
|
const hiddenCount = realTags.length - 10;
|
||||||
|
toggle.dataset.count = hiddenCount;
|
||||||
|
|
||||||
|
// Auto-expand when rendering new tags (e.g. after adding one) as requested
|
||||||
|
tagsContainer.classList.add('tags-expanded');
|
||||||
|
toggle.textContent = "show less";
|
||||||
|
|
||||||
|
} else if (toggle) {
|
||||||
|
toggle.remove();
|
||||||
|
tagsContainer.classList.remove('tags-expanded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.renderTags = renderTags;
|
||||||
|
|
||||||
|
const deleteEvent = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid } = ctx;
|
||||||
|
|
||||||
|
let target = e.target;
|
||||||
|
if (target.nodeType === 3) target = target.parentElement;
|
||||||
|
|
||||||
|
const badge = target.closest('.badge');
|
||||||
|
if (!badge) return;
|
||||||
|
const tagLink = badge.querySelector('a[href*="/tag/"], a:first-of-type');
|
||||||
|
const tagname = tagLink ? tagLink.innerText.trim() : null;
|
||||||
|
if (!tagname) return;
|
||||||
|
|
||||||
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
|
ModAction.confirm((window.f0ckI18n && window.f0ckI18n.tag_delete_title) || 'Delete Tag', `${(window.f0ckI18n && window.f0ckI18n.tag_delete_confirm) || 'Are you sure you want to delete the tag'} <strong style="color:#d9534f">${tagname}</strong>?`, async (reason) => {
|
||||||
|
// Send reason via query param for DELETE request
|
||||||
|
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (reason ? "?reason=" + encodeURIComponent(reason) : ""), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.msg || "Error deleting tag");
|
||||||
|
}
|
||||||
|
renderTags(res.tags);
|
||||||
|
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.tag_deleted_success) || 'Tag deleted', 2500, 'success');
|
||||||
|
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addtagClick = (e) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid, tags } = ctx;
|
||||||
|
const anchor = document.querySelector("a#a_addtag");
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
TagAutocomplete.open({
|
||||||
|
postid,
|
||||||
|
existingTags: tags,
|
||||||
|
anchorEl: anchor,
|
||||||
|
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||||
|
renderTags
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const toggleFavEvent = async (e) => {
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid } = ctx;
|
||||||
|
|
||||||
|
// Read state BEFORE the API call so we know which direction to toggle
|
||||||
|
const favoBtn = document.querySelector("#a_favo");
|
||||||
|
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||||
|
|
||||||
|
const res = await post('/api/v2/togglefav', {
|
||||||
|
postid: postid
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
// New state is the logical opposite of what it was before the API call
|
||||||
|
const isNowFav = !wasAlreadyFav;
|
||||||
|
|
||||||
|
if (favoBtn) {
|
||||||
|
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||||
|
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||||
|
}
|
||||||
|
|
||||||
|
const favcontainer = document.querySelector('#favs');
|
||||||
|
favcontainer.innerHTML = "";
|
||||||
|
if (res.favs.length > 0) {
|
||||||
|
res.favs.forEach(f => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/user/${f.user}`;
|
||||||
|
a.setAttribute('tooltip', f.display_name || f.user);
|
||||||
|
a.setAttribute('flow', 'up');
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||||
|
img.style.height = "32px";
|
||||||
|
img.style.width = "32px";
|
||||||
|
if (f.username_color) img.style.borderColor = f.username_color;
|
||||||
|
|
||||||
|
a.appendChild(img);
|
||||||
|
favcontainer.appendChild(a);
|
||||||
|
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||||
|
});
|
||||||
|
favcontainer.hidden = false;
|
||||||
|
} else {
|
||||||
|
favcontainer.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||||
|
if (navigator.vibrate) navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteButtonEvent = async e => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid, poster } = ctx;
|
||||||
|
|
||||||
|
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||||
|
|
||||||
|
const i18n = window.f0ckI18n || {};
|
||||||
|
const confirmTitle = i18n.item_delete_title || 'Delete Item';
|
||||||
|
const confirmMsg = (i18n.item_delete_confirm || 'Are you sure you want to delete item {id} by {user}?')
|
||||||
|
.replace('{id}', postid)
|
||||||
|
.replace('{user}', poster || 'unknown');
|
||||||
|
|
||||||
|
ModAction.confirm(confirmTitle, confirmMsg, async (reason) => {
|
||||||
|
// Flag immediately so the SSE delete_item handler skips navigation
|
||||||
|
window._adminJustDeletedItem = postid;
|
||||||
|
const res = await post("/api/v2/admin/deletepost", {
|
||||||
|
postid: postid,
|
||||||
|
reason: reason
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
window._adminJustDeletedItem = null;
|
||||||
|
throw new Error(res.msg || "Error deleting item");
|
||||||
|
}
|
||||||
|
const mediaObj = document.querySelector('.media-object');
|
||||||
|
if (mediaObj) {
|
||||||
|
mediaObj.innerHTML = '<div style="padding: 100px; text-align: center; color: #d9534f;"><h1>Item Deleted</h1><p>The item has been successfully removed.</p></div>';
|
||||||
|
}
|
||||||
|
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.item_deleted_success) || 'Item deleted', 2500, 'success');
|
||||||
|
// Clear flag after a short delay (SSE has surely arrived by then)
|
||||||
|
setTimeout(() => { window._adminJustDeletedItem = null; }, 3000);
|
||||||
|
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||||
|
};
|
||||||
|
|
||||||
|
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("keydown", async e => {
|
||||||
|
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||||
|
parent.removeChild(textfield);
|
||||||
|
let res = await fetch('/api/v2/tags/rename/' + encodeURIComponent(oldtag), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
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 => {
|
||||||
|
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
||||||
|
|
||||||
|
if (target.closest("a#a_addtag")) {
|
||||||
|
addtagClick(e);
|
||||||
|
} else if (target.closest("#a_delete")) {
|
||||||
|
deleteButtonEvent(e);
|
||||||
|
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
|
||||||
|
editTagEvent(e);
|
||||||
|
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
|
||||||
|
deleteEvent(e);
|
||||||
|
} else if (target.closest("#a_pin")) {
|
||||||
|
pinButtonEvent(e);
|
||||||
|
} else if (target.closest("#a_favo")) {
|
||||||
|
toggleFavEvent(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinButtonEvent = async e => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid } = ctx;
|
||||||
|
|
||||||
|
const pinBtn = document.querySelector('#a_pin');
|
||||||
|
if (!pinBtn) return;
|
||||||
|
|
||||||
|
const isPinned = pinBtn.getAttribute('data-pinned') === 'true';
|
||||||
|
const url = isPinned ? `/mod/unpin?id=${postid}` : `/mod/pin?id=${postid}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await (await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
const newState = res.pinned;
|
||||||
|
const title = newState ? 'Unpin from main' : 'Pin to main';
|
||||||
|
const currentBtn = document.querySelector('#a_pin');
|
||||||
|
if (currentBtn) {
|
||||||
|
currentBtn.setAttribute('data-pinned', newState);
|
||||||
|
currentBtn.setAttribute('title', title);
|
||||||
|
currentBtn.classList.toggle('active', newState);
|
||||||
|
}
|
||||||
|
window.flashMessage(newState ? 'ITEM PINNED' : 'ITEM UNPINNED');
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + res.msg);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Pin error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keyup", e => {
|
||||||
|
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 'f' and 'i' handled by f0ckm.js keybindings via programmatic click
|
||||||
|
if (e.key === "x") deleteButtonEvent();
|
||||||
|
else if (e.key === "g") pinButtonEvent();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.adminSetPassword = async (btn) => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
|
||||||
|
if (!password) return;
|
||||||
|
if (password.length < 20) return alert('Password must be at least 20 characters.');
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.msg);
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Failed to set password');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminDeleteUser = async (btn) => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.msg);
|
||||||
|
document.getElementById(`user-row-${id}`)?.remove();
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminResetLoginAttempts = async (btn) => {
|
||||||
|
const username = btn.dataset.username;
|
||||||
|
if (!confirm(`Are you sure you want to reset login attempts for ${username}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.msg);
|
||||||
|
window.location.reload(); // Quickest way to refresh badges
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Failed to reset attempts');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.adminBulkDeleteHalls = async (btn) => {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const name = btn.dataset.name;
|
||||||
|
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||||
|
if (data.success) {
|
||||||
|
alert(data.msg);
|
||||||
|
} else {
|
||||||
|
alert(data.msg || 'Failed to delete halls');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Network error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
|
|
||||||
2509
public/s/js/comments.js
Normal file
679
public/s/js/danmaku.js
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
/**
|
||||||
|
* danmaku.js — NicoNico/弾幕-style flying comments for v0ck
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const d = new Danmaku(playerEl, videoEl);
|
||||||
|
* d.load(commentsArray); // feed comments from API
|
||||||
|
* d.fire('hello!', 'user', '#f0f'); // fire one immediately
|
||||||
|
* d.toggle(); // toggle on/off
|
||||||
|
* d.destroy(); // cleanup
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
|
||||||
|
const PILL_MIN_MS = 6000; // Fastest a pill can cross the screen
|
||||||
|
const PILL_MAX_MS = 12000; // Slowest a pill can cross the screen
|
||||||
|
const LANE_COUNT = 10; // Vertical lane slots
|
||||||
|
const LOOKAHEAD_SEC = 0.25; // How far ahead of currentTime we look when scanning
|
||||||
|
const MIN_RANDOM_SECS = 2; // Random timecode lower bound (avoid very start)
|
||||||
|
const RANDOM_SPREAD = 0.85; // Use 85% of duration for random spread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyntheticClock — emulates a <video> element's time API for non-video items
|
||||||
|
* (Flash/Ruffle). Ticks at 4 Hz so Danmaku's timeupdate handler fires normally.
|
||||||
|
*/
|
||||||
|
class SyntheticClock {
|
||||||
|
constructor() {
|
||||||
|
this._currentTime = 0;
|
||||||
|
this._paused = false;
|
||||||
|
this._listeners = { timeupdate: [], seeked: [] };
|
||||||
|
this._timer = this._startTimer();
|
||||||
|
}
|
||||||
|
_startTimer() {
|
||||||
|
return setInterval(() => {
|
||||||
|
if (this._paused) return;
|
||||||
|
this._currentTime += 0.25;
|
||||||
|
this._listeners.timeupdate.forEach(fn => fn());
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
get currentTime() { return this._currentTime; }
|
||||||
|
get duration() { return Infinity; }
|
||||||
|
get paused() { return this._paused; }
|
||||||
|
pause() {
|
||||||
|
// For Flash/Ruffle: never pause the clock — let pills and time advance freely.
|
||||||
|
// Ruffle's is_playing is unreliable and would stall danmaku if respected.
|
||||||
|
this._paused = true;
|
||||||
|
}
|
||||||
|
resume() {
|
||||||
|
this._paused = false;
|
||||||
|
}
|
||||||
|
addEventListener(type, fn, opts) {
|
||||||
|
if (this._listeners[type]) this._listeners[type].push(fn);
|
||||||
|
}
|
||||||
|
removeEventListener(type, fn) {
|
||||||
|
if (this._listeners[type])
|
||||||
|
this._listeners[type] = this._listeners[type].filter(f => f !== fn);
|
||||||
|
}
|
||||||
|
/** Reset the clock to zero (used for looping in Flash/Ruffle mode). */
|
||||||
|
reset() {
|
||||||
|
this._currentTime = 0;
|
||||||
|
this._listeners.seeked.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
destroy() { clearInterval(this._timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Danmaku {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} playerEl — the .v0ck wrapper element
|
||||||
|
* @param {HTMLVideoElement|HTMLAudioElement} mediaEl — the <video> or <audio>
|
||||||
|
*/
|
||||||
|
constructor(playerEl, mediaEl) {
|
||||||
|
this.player = playerEl;
|
||||||
|
this.media = mediaEl;
|
||||||
|
this._synthClock = (mediaEl instanceof SyntheticClock) ? mediaEl : null;
|
||||||
|
this.overlay = null;
|
||||||
|
this.items = [];
|
||||||
|
this._lastTime = -1;
|
||||||
|
this._paused = false;
|
||||||
|
this._laneUntil = new Array(LANE_COUNT).fill(0);
|
||||||
|
// Site-wide config default
|
||||||
|
const configDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
|
||||||
|
? !!window.f0ckSession.enable_danmaku
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// User preference
|
||||||
|
const savedPref = localStorage.getItem('danmaku');
|
||||||
|
|
||||||
|
if (savedPref !== null) {
|
||||||
|
// User has explicitly chosen ON or OFF in the past
|
||||||
|
this._enabled = savedPref !== 'false';
|
||||||
|
} else {
|
||||||
|
// No user preference yet — use the site-wide factory default
|
||||||
|
this._enabled = configDefault;
|
||||||
|
}
|
||||||
|
this._bound_onTime = this._onTimeUpdate.bind(this);
|
||||||
|
this._bound_onSeek = this._onSeeked.bind(this);
|
||||||
|
this._bound_onPause = this._onPause.bind(this);
|
||||||
|
this._bound_onPlay = this._onPlay.bind(this);
|
||||||
|
|
||||||
|
// Own emoji cache — populated via CommentSystem, event, or independent fetch
|
||||||
|
this._emojiCache = {};
|
||||||
|
this._initEmojiCache();
|
||||||
|
|
||||||
|
this._createOverlay();
|
||||||
|
this.media.addEventListener('timeupdate', this._bound_onTime, { passive: true });
|
||||||
|
this.media.addEventListener('seeked', this._bound_onSeek, { passive: true });
|
||||||
|
this.media.addEventListener('pause', this._bound_onPause, { passive: true });
|
||||||
|
this.media.addEventListener('play', this._bound_onPlay, { passive: true });
|
||||||
|
|
||||||
|
// For Ruffle/SyntheticClock: no poller needed — clock runs freely
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the emoji cache from whichever source resolves first:
|
||||||
|
* 1. CommentSystem.emojiCache already populated (fast path — browser was already on a page)
|
||||||
|
* 2. f0ck:emojis_ready event (CommentSystem finishes loading after us)
|
||||||
|
* 3. Independent fetch (Ruffle/Flash items where CommentSystem may never fire the event)
|
||||||
|
*/
|
||||||
|
_initEmojiCache() {
|
||||||
|
// Fast path: CommentSystem already populated (AJAX nav / second page view)
|
||||||
|
const tryCs = () => {
|
||||||
|
const cs = (typeof CommentSystem !== 'undefined') ? CommentSystem.emojiCache : null;
|
||||||
|
return (cs && Object.keys(cs).length > 0) ? cs : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cs0 = tryCs();
|
||||||
|
if (cs0) { this._emojiCache = cs0; return; }
|
||||||
|
|
||||||
|
// Listen for CommentSystem's own event
|
||||||
|
this._bound_onEmojis = (e) => {
|
||||||
|
const map = e.detail || tryCs() || {};
|
||||||
|
if (map && Object.keys(map).length > 0) {
|
||||||
|
this._emojiCache = map;
|
||||||
|
this._reRenderEmojis();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('f0ck:emojis_ready', this._bound_onEmojis);
|
||||||
|
|
||||||
|
// Aggressive retry: try every 500 ms for up to 30 attempts.
|
||||||
|
// Each attempt checks CommentSystem first (free), then falls back to a fetch.
|
||||||
|
let attempts = 0;
|
||||||
|
let fetched = false;
|
||||||
|
const retry = () => {
|
||||||
|
if (this._emojiCache && Object.keys(this._emojiCache).length > 0) return; // already got them
|
||||||
|
if (++attempts > 30) return;
|
||||||
|
|
||||||
|
// 1. CommentSystem populated by now?
|
||||||
|
const cs = tryCs();
|
||||||
|
if (cs) {
|
||||||
|
this._emojiCache = cs;
|
||||||
|
this._reRenderEmojis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Kick off the HTTP fetch once; then just wait for it / the event
|
||||||
|
if (!fetched) {
|
||||||
|
fetched = true;
|
||||||
|
fetch('/api/v2/emojis')
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`emoji fetch ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success || !Array.isArray(data.emojis)) return;
|
||||||
|
const map = {};
|
||||||
|
data.emojis.forEach(e => { map[e.name] = e.url; });
|
||||||
|
if (Object.keys(map).length > 0) {
|
||||||
|
this._emojiCache = map;
|
||||||
|
this._reRenderEmojis();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[Danmaku] emoji fetch failed:', err.message);
|
||||||
|
fetched = false; // allow retry
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next check
|
||||||
|
if (!this._destroyed) setTimeout(retry, 500);
|
||||||
|
};
|
||||||
|
setTimeout(retry, 200); // first attempt after a short grace window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load (or reload) comments. Comments with video_time=null get a random time.
|
||||||
|
* @param {Array} comments — raw comment objects from /api/comments
|
||||||
|
*/
|
||||||
|
load(comments) {
|
||||||
|
if (!Array.isArray(comments) || comments.length === 0) return;
|
||||||
|
|
||||||
|
const duration = this.media.duration;
|
||||||
|
const hasDuration = isFinite(duration) && duration > 0;
|
||||||
|
|
||||||
|
// Build the prepared item list
|
||||||
|
const mapped = comments
|
||||||
|
.filter(c => !c.is_deleted && c.content)
|
||||||
|
.map(c => ({
|
||||||
|
text: this._prepareText(c.content),
|
||||||
|
username: c.display_name || c.username || '?',
|
||||||
|
color: c.username_color || null,
|
||||||
|
raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null,
|
||||||
|
fired: false,
|
||||||
|
video_time: 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this._synthClock) {
|
||||||
|
// Flash/Ruffle mode: bypass the timeline entirely.
|
||||||
|
// Store pool and start the random continuous loop.
|
||||||
|
this._flashPool = mapped;
|
||||||
|
this._startFlashLoop();
|
||||||
|
return; // don't touch this.items / _lastTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal video mode: use video_time, random spread for nulls
|
||||||
|
this.items = mapped.map(item => {
|
||||||
|
if (item.raw_time !== null) {
|
||||||
|
item.video_time = item.raw_time;
|
||||||
|
} else if (hasDuration) {
|
||||||
|
const spread = duration * RANDOM_SPREAD;
|
||||||
|
item.video_time = MIN_RANDOM_SECS + Math.random() * Math.max(0, spread - MIN_RANDOM_SECS);
|
||||||
|
} else {
|
||||||
|
item.video_time = MIN_RANDOM_SECS + Math.random() * 598;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}).sort((a, b) => a.video_time - b.video_time);
|
||||||
|
|
||||||
|
this._resetFiredState(this.media.currentTime);
|
||||||
|
this._lastTime = this.media.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random continuous loop for Flash/Ruffle.
|
||||||
|
* Fires one comment every 2-5 s (random), reshuffles pool on exhaustion.
|
||||||
|
*/
|
||||||
|
_startFlashLoop() {
|
||||||
|
// Cancel any previous loop
|
||||||
|
if (this._flashTimer) clearTimeout(this._flashTimer);
|
||||||
|
this._flashTimer = null;
|
||||||
|
|
||||||
|
if (!this._flashPool || this._flashPool.length === 0) return;
|
||||||
|
|
||||||
|
// Shuffle helper
|
||||||
|
const shuffle = arr => {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Working queue — randomised copy of pool
|
||||||
|
let queue = shuffle([...this._flashPool]);
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (this._destroyed || !this._enabled) return;
|
||||||
|
|
||||||
|
// Refill and reshuffle when queue exhausted
|
||||||
|
if (idx >= queue.length) {
|
||||||
|
queue = shuffle([...this._flashPool]);
|
||||||
|
idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = queue[idx++];
|
||||||
|
this._spawnPill(item.text, item.username, item.color);
|
||||||
|
|
||||||
|
// Random delay 2 – 5 seconds between pills
|
||||||
|
const delay = 2000 + Math.random() * 3000;
|
||||||
|
this._flashTimer = setTimeout(tick, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small initial delay so page finishes loading before first pill
|
||||||
|
this._flashTimer = setTimeout(tick, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immediately fire a single comment pill (e.g. user's own new comment).
|
||||||
|
*/
|
||||||
|
fire(text, username, color) {
|
||||||
|
if (!this._enabled) return;
|
||||||
|
this._spawnPill(this._prepareText(text), username, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new comment to the timeline so it loops back in future playback.
|
||||||
|
* Also fires it immediately as a one-shot pill.
|
||||||
|
* @param {Object} comment — raw comment object from API
|
||||||
|
*/
|
||||||
|
addItem(comment) {
|
||||||
|
if (!comment || !comment.content) return;
|
||||||
|
|
||||||
|
const duration = this.media.duration;
|
||||||
|
const hasDuration = isFinite(duration) && duration > 0;
|
||||||
|
|
||||||
|
let t = (comment.video_time != null) ? parseFloat(comment.video_time) : null;
|
||||||
|
|
||||||
|
if (t === null) {
|
||||||
|
const now = this.media.currentTime || 0;
|
||||||
|
if (hasDuration) {
|
||||||
|
const remaining = duration - now;
|
||||||
|
const spread = Math.max(remaining * 0.9, MIN_RANDOM_SECS);
|
||||||
|
t = now + MIN_RANDOM_SECS + Math.random() * spread;
|
||||||
|
if (t > duration) t = MIN_RANDOM_SECS + Math.random() * duration * RANDOM_SPREAD;
|
||||||
|
} else {
|
||||||
|
t = (this.media.currentTime || 0) + MIN_RANDOM_SECS + Math.random() * 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
video_time: t,
|
||||||
|
text: this._prepareText(comment.content),
|
||||||
|
username: comment.display_name || comment.username || '?',
|
||||||
|
color: comment.username_color || null,
|
||||||
|
fired: true // mark as already fired — caller handles any immediate one-shot
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert in sorted order
|
||||||
|
const idx = this.items.findIndex(i => i.video_time > t);
|
||||||
|
if (idx === -1) this.items.push(item);
|
||||||
|
else this.items.splice(idx, 0, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle danmaku on/off. */
|
||||||
|
toggle() {
|
||||||
|
this._enabled = !this._enabled;
|
||||||
|
localStorage.setItem('danmaku', this._enabled ? 'true' : 'false');
|
||||||
|
this.overlay.style.display = this._enabled ? '' : 'none';
|
||||||
|
|
||||||
|
// Update the switch if it exists in the player
|
||||||
|
const sw = this.player.querySelector('#toggledanmaku');
|
||||||
|
if (sw) sw.classList.toggle('active', this._enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(val) {
|
||||||
|
if (this._enabled === !!val) return;
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled() { return this._enabled; }
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._destroyed = true;
|
||||||
|
this.media.removeEventListener('timeupdate', this._bound_onTime);
|
||||||
|
this.media.removeEventListener('seeked', this._bound_onSeek);
|
||||||
|
this.media.removeEventListener('pause', this._bound_onPause);
|
||||||
|
this.media.removeEventListener('play', this._bound_onPlay);
|
||||||
|
if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis);
|
||||||
|
if (this._rufflePoller) clearInterval(this._rufflePoller);
|
||||||
|
if (this._flashTimer) clearTimeout(this._flashTimer);
|
||||||
|
if (this._synthClock) this._synthClock.destroy();
|
||||||
|
if (this.overlay && this.overlay.parentNode) this.overlay.parentNode.removeChild(this.overlay);
|
||||||
|
this.overlay = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_createOverlay() {
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.className = 'danmaku-overlay';
|
||||||
|
if (!this._enabled) this.overlay.style.display = 'none';
|
||||||
|
this.player.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onPause() {
|
||||||
|
this._paused = true;
|
||||||
|
if (this._synthClock) this._synthClock.pause();
|
||||||
|
// Do NOT pause pill animations — pills already in flight always complete.
|
||||||
|
}
|
||||||
|
|
||||||
|
_onPlay() {
|
||||||
|
this._paused = false;
|
||||||
|
if (this._synthClock) this._synthClock.resume();
|
||||||
|
// Nothing to do for in-flight pills — they were never paused.
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkRuffleState() {
|
||||||
|
const rp = document.querySelector('ruffle-player, ruffle-object');
|
||||||
|
if (!rp) return;
|
||||||
|
// Ruffle exposes is_playing on the element
|
||||||
|
const isPlaying = rp.is_playing !== undefined ? !!rp.is_playing : true;
|
||||||
|
if (isPlaying && this._paused) this._onPlay();
|
||||||
|
if (!isPlaying && !this._paused) this._onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onTimeUpdate() {
|
||||||
|
if (this._paused) return;
|
||||||
|
const now = this.media.currentTime;
|
||||||
|
if (!this._enabled || this.items.length === 0) { this._lastTime = now; return; }
|
||||||
|
|
||||||
|
const prev = this._lastTime;
|
||||||
|
this._lastTime = now;
|
||||||
|
|
||||||
|
// Detect video loop (time jumped backwards) — reset so comments fire again
|
||||||
|
if (now < prev - 0.5) {
|
||||||
|
this._resetFiredState(now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = prev;
|
||||||
|
const to = now + LOOKAHEAD_SEC;
|
||||||
|
|
||||||
|
for (const item of this.items) {
|
||||||
|
if (item.fired) continue;
|
||||||
|
if (item.video_time < from) { item.fired = true; continue; } // already passed
|
||||||
|
if (item.video_time > to) break; // sorted, nothing further in range
|
||||||
|
item.fired = true;
|
||||||
|
this._spawnPill(item.text, item.username, item.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop for SyntheticClock (Flash/Ruffle): once all items have fired, restart
|
||||||
|
if (this._synthClock && this.items.length > 0 && this.items.every(i => i.fired)) {
|
||||||
|
this._loopSynth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all fired flags and the synthetic clock for looping. */
|
||||||
|
_loopSynth() {
|
||||||
|
this.items.forEach(i => { i.fired = false; });
|
||||||
|
this._synthClock.reset(); // back to t=0
|
||||||
|
this._lastTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSeeked() {
|
||||||
|
this._resetFiredState(this.media.currentTime);
|
||||||
|
this._lastTime = this.media.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetFiredState(currentTime) {
|
||||||
|
for (const item of this.items) {
|
||||||
|
item.fired = item.video_time < currentTime - LOOKAHEAD_SEC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickLane() {
|
||||||
|
const now = Date.now();
|
||||||
|
// Find the lane that will be free soonest
|
||||||
|
let best = 0;
|
||||||
|
let bestFree = this._laneUntil[0];
|
||||||
|
for (let i = 1; i < LANE_COUNT; i++) {
|
||||||
|
if (this._laneUntil[i] < bestFree) {
|
||||||
|
bestFree = this._laneUntil[i];
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Occupy the lane — use the max duration so slower pills don't get overwritten
|
||||||
|
this._laneUntil[best] = now + PILL_MAX_MS;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawnPill(text, username, color) {
|
||||||
|
if (!this.overlay || !this._enabled || this._paused) return;
|
||||||
|
|
||||||
|
const pill = document.createElement('div');
|
||||||
|
pill.className = 'danmaku-pill';
|
||||||
|
|
||||||
|
// Lane assignment — distribute vertically to avoid full overlap
|
||||||
|
const lane = this._pickLane();
|
||||||
|
const laneH = 100 / LANE_COUNT;
|
||||||
|
const topPct = lane * laneH + (laneH * 0.1); // slight inset
|
||||||
|
pill.style.top = topPct + '%';
|
||||||
|
|
||||||
|
// Message content — store raw text for deferred emoji re-render
|
||||||
|
const msg = document.createElement('span');
|
||||||
|
msg.className = 'dpill-text';
|
||||||
|
msg.dataset.rawText = text;
|
||||||
|
|
||||||
|
// Read the freshest emoji source available at spawn time
|
||||||
|
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
|
||||||
|
? this._emojiCache
|
||||||
|
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
|
||||||
|
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
|
||||||
|
|
||||||
|
msg.appendChild(this._renderContent(text));
|
||||||
|
|
||||||
|
// Track pill for deferred re-render if emojis weren't ready yet
|
||||||
|
if (!this._emojiCache || Object.keys(this._emojiCache).length === 0) {
|
||||||
|
if (!this._pendingPills) this._pendingPills = new Set();
|
||||||
|
this._pendingPills.add(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pill.appendChild(msg);
|
||||||
|
|
||||||
|
// Insert paused so we can measure before animation fires
|
||||||
|
this.overlay.appendChild(pill);
|
||||||
|
|
||||||
|
// Duration scales with text length so long comments get enough time to cross.
|
||||||
|
// Formula: 5s base + 25ms per character, clamped to [6s, 45s].
|
||||||
|
const charCount = text.length;
|
||||||
|
const duration = Math.min(Math.max(5000 + charCount * 25, PILL_MIN_MS), 45_000);
|
||||||
|
|
||||||
|
// Use actual scroll (content) width — wider than offsetWidth for very long lines.
|
||||||
|
// This ensures the animation pixel travel is enough for ALL content to exit left,
|
||||||
|
// not just the max-width-capped pill box.
|
||||||
|
const overlayW = this.overlay.offsetWidth || window.innerWidth || 1920;
|
||||||
|
const contentW = pill.scrollWidth || pill.offsetWidth || 200;
|
||||||
|
const startX = overlayW + contentW; // off-screen right
|
||||||
|
const endX = -(contentW + 200); // fully off-screen left, overflow included
|
||||||
|
|
||||||
|
const anim = pill.animate(
|
||||||
|
[
|
||||||
|
{ transform: `translateX(${startX}px)` },
|
||||||
|
{ transform: `translateX(${endX}px)` }
|
||||||
|
],
|
||||||
|
{ duration, easing: 'linear', fill: 'none' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove pill once animation completes
|
||||||
|
anim.addEventListener('finish', () => {
|
||||||
|
if (pill.parentNode) pill.parentNode.removeChild(pill);
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
// Failsafe in case the Animations API finish event doesn't fire
|
||||||
|
pill._timeoutId = setTimeout(() => { if (pill.parentNode) pill.parentNode.removeChild(pill); }, duration + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-render pending pills once emojis are available. */
|
||||||
|
_reRenderEmojis() {
|
||||||
|
if (!this.overlay) return;
|
||||||
|
// Prefer direct element tracking (fast, no DOM query needed)
|
||||||
|
const pending = this._pendingPills;
|
||||||
|
if (pending && pending.size > 0) {
|
||||||
|
pending.forEach(msg => {
|
||||||
|
if (!msg.parentNode) { pending.delete(msg); return; } // already removed
|
||||||
|
const raw = msg.dataset.rawText;
|
||||||
|
if (!raw) return;
|
||||||
|
msg.textContent = '';
|
||||||
|
msg.appendChild(this._renderContent(raw));
|
||||||
|
});
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
// Also sweep any pills that slipped through (belt-and-suspenders)
|
||||||
|
this.overlay.querySelectorAll('.dpill-text[data-raw-text]').forEach(msg => {
|
||||||
|
const raw = msg.dataset.rawText;
|
||||||
|
if (!raw) return;
|
||||||
|
// Only re-render if the content is still plain text (no img children)
|
||||||
|
if (!msg.querySelector('img.dpill-emoji')) {
|
||||||
|
msg.textContent = '';
|
||||||
|
msg.appendChild(this._renderContent(raw));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a DocumentFragment from raw comment text.
|
||||||
|
* Handles [spoiler], [blur], :emoji:, and >greentext lines.
|
||||||
|
*/
|
||||||
|
_renderContent(rawText) {
|
||||||
|
if (!rawText) return document.createDocumentFragment();
|
||||||
|
|
||||||
|
const prepared = this._prepareText(rawText);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
const cache = this._emojiCache; // always use full cache in danmaku
|
||||||
|
|
||||||
|
// Process line by line — leading > lines become greentext
|
||||||
|
// Filter empty lines to avoid ghost rows from trailing newlines
|
||||||
|
const lines = prepared.split('\n').filter(l => l.trim() !== '');
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const isQuote = /^>\s?/.test(line);
|
||||||
|
const text = isQuote ? '> ' + line.replace(/^>\s?/, '') : line;
|
||||||
|
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = isQuote ? 'dpill-greentext' : 'dpill-line';
|
||||||
|
this._renderInline(text, span, cache);
|
||||||
|
frag.appendChild(span);
|
||||||
|
});
|
||||||
|
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders inline content (spoiler/blur/emoji) into a parent node.
|
||||||
|
* Prepends a space before the first text chunk for visual separation.
|
||||||
|
*/
|
||||||
|
_renderInline(text, parent, emojiCache) {
|
||||||
|
// match[1]=spoiler, match[2]=blur, match[3]=emoji, match[4]=inline-img-url
|
||||||
|
const combined = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[blur\]([\s\S]*?)\[\/blur\]|:([a-z0-9_+\-]+):|\x04([^\x05]+)\x05/gi;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = combined.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parent.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match[1] !== undefined) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'dpill-spoiler';
|
||||||
|
span.title = 'Click to reveal spoiler';
|
||||||
|
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
|
||||||
|
this._renderInline(match[1], span, emojiCache); // recursive so emojis inside spoilers work
|
||||||
|
parent.appendChild(span);
|
||||||
|
} else if (match[2] !== undefined) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'dpill-blur';
|
||||||
|
span.title = 'Click to reveal';
|
||||||
|
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
|
||||||
|
this._renderInline(match[2], span, emojiCache); // recursive so emojis inside blur work
|
||||||
|
parent.appendChild(span);
|
||||||
|
} else if (match[3]) {
|
||||||
|
const code = match[3];
|
||||||
|
const url = emojiCache[code];
|
||||||
|
if (url) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
img.alt = `:${code}:`;
|
||||||
|
img.className = 'dpill-emoji';
|
||||||
|
parent.appendChild(img);
|
||||||
|
} else {
|
||||||
|
parent.appendChild(document.createTextNode(match[0]));
|
||||||
|
}
|
||||||
|
} else if (match[4]) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = match[4];
|
||||||
|
img.className = 'dpill-img';
|
||||||
|
parent.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parent.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips markdown and character limits; preserves > and newlines for _renderContent. */
|
||||||
|
_prepareText(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Protect emoji codes from the bold/italic underscore regex.
|
||||||
|
// e.g. `:dance_fart: :dance_fart:` would have its underscores eaten
|
||||||
|
// when the regex pairs the _ from the first code with the _ in the second.
|
||||||
|
const emojiTokens = [];
|
||||||
|
let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
|
||||||
|
emojiTokens.push(match);
|
||||||
|
return `\x02${emojiTokens.length - 1}\x03`; // private-use delimiters
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tokenize image URLs with \x04URL\x05 so they survive all regexes and reach _renderInline
|
||||||
|
// Only embed images from the allowed-images allowlist (window.f0ckAllowedImages) or same site
|
||||||
|
const allowedHosts = Array.isArray(window.f0ckAllowedImages) ? window.f0ckAllowedImages : [];
|
||||||
|
const siteHost = window.location.hostname;
|
||||||
|
const isAllowedImg = (url) => {
|
||||||
|
try {
|
||||||
|
const h = new URL(url).hostname;
|
||||||
|
return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a));
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
const imgTokenUrls = [];
|
||||||
|
protected_ = protected_
|
||||||
|
// Stop at protocol boundaries so concatenated URLs aren't merged into one broken src.
|
||||||
|
.replace(/https?:\/\/(?:(?!https?:\/\/)\S)+\.(?:png|jpg|jpeg|gif|webp|svg|avif)(\?(?:(?!https?:\/\/)\S)*)?/gi, (url) => {
|
||||||
|
if (!isAllowedImg(url)) return url; // disallowed image URLs stay as plain text
|
||||||
|
imgTokenUrls.push(url);
|
||||||
|
return `\x04${imgTokenUrls.length - 1}\x05`; // numeric index placeholder
|
||||||
|
})
|
||||||
|
.replace(/```[\s\S]*?```/g, '[code]')
|
||||||
|
.replace(/`[^`]+`/g, match => match.slice(1, -1))
|
||||||
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[img]')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||||
|
// Normalize \r\n → \n but keep line breaks for greentext
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
// Collapse 3+ blank lines to 2
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Restore emoji codes, then image URL tokens
|
||||||
|
return protected_
|
||||||
|
.replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '')
|
||||||
|
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? `\x04${imgTokenUrls[+i]}\x05` : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Danmaku = Danmaku;
|
||||||
|
window.SyntheticClock = SyntheticClock;
|
||||||
|
|
||||||
|
})();
|
||||||
194
public/s/js/f0ck_upload_init.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Global Drag and Drop Initialization - ROBUST VERSION
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const dropOverlay = document.getElementById('drop-overlay');
|
||||||
|
const dragModal = document.getElementById('upload-drag-modal');
|
||||||
|
const dragModalClose = document.getElementById('drag-modal-close');
|
||||||
|
const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null;
|
||||||
|
|
||||||
|
const showModal = () => {
|
||||||
|
if (!dragModal) return;
|
||||||
|
dragModal.classList.add('show');
|
||||||
|
// Reset scroll position so it always starts at the top
|
||||||
|
dragModal.scrollTop = 0;
|
||||||
|
const modalContent = dragModal.querySelector('.modal-content');
|
||||||
|
if (modalContent) modalContent.scrollTop = 0;
|
||||||
|
const modalBody = dragModal.querySelector('.modal-body');
|
||||||
|
if (modalBody) modalBody.scrollTop = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navbar upload link — always attached so it works even if drag-drop can't init.
|
||||||
|
// Opens the modal when available; falls back to /upload navigation otherwise.
|
||||||
|
const navUploadLink = document.getElementById('nav-upload-link');
|
||||||
|
if (navUploadLink) {
|
||||||
|
navUploadLink.addEventListener('click', (e) => {
|
||||||
|
if (!dragModal) return; // no modal → fall back to href navigation
|
||||||
|
e.preventDefault();
|
||||||
|
showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dropOverlay || !dragModal || !dragForm) {
|
||||||
|
console.warn('[f0ck] Quick Upload Modal or Form missing, global drag-drop disabled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragCounter = 0;
|
||||||
|
let uploader = null;
|
||||||
|
|
||||||
|
// Use the consolidated initUploadForm from upload.js
|
||||||
|
if (window.initUploadForm) {
|
||||||
|
uploader = window.initUploadForm(dragForm);
|
||||||
|
} else {
|
||||||
|
console.error('[f0ck] window.initUploadForm is missing! upload.js not loaded?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global Drag Events
|
||||||
|
window.addEventListener('dragenter', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.location.pathname === '/upload') return;
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
dragCounter++;
|
||||||
|
dropOverlay.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.location.pathname === '/upload') return;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('dragleave', (e) => {
|
||||||
|
if (window.location.pathname === '/upload') return;
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
dropOverlay.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('drop', (e) => {
|
||||||
|
if (window.location.pathname === '/upload') return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
dropOverlay.classList.remove('active');
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
if (uploader && uploader.handleFile) {
|
||||||
|
const ok = uploader.handleFile(files[0]);
|
||||||
|
if (ok !== false) {
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal Close
|
||||||
|
dragModalClose.onclick = () => {
|
||||||
|
dragModal.classList.remove('show');
|
||||||
|
if (uploader && uploader.reset) {
|
||||||
|
uploader.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on ESC
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && dragModal.classList.contains('show')) {
|
||||||
|
dragModalClose.onclick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open on 'u' shortcut (not on /upload, not when typing)
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'u' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
|
||||||
|
if (window.location.pathname === '/upload') return;
|
||||||
|
if (dragModal.classList.contains('show')) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global Paste Event for Clipboard Images/Videos/URLs
|
||||||
|
window.addEventListener('paste', (e) => {
|
||||||
|
const activeTag = document.activeElement?.tagName;
|
||||||
|
const isTyping = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || activeTag === 'SELECT' || document.activeElement?.isContentEditable;
|
||||||
|
|
||||||
|
const isModalOpen = dragModal.classList.contains('show');
|
||||||
|
const isUploadPage = window.location.pathname === '/upload';
|
||||||
|
|
||||||
|
// Items loop for files
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
let file = null;
|
||||||
|
if (items) {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (item.kind === 'file' && (item.type.startsWith('image/') || item.type.startsWith('video/'))) {
|
||||||
|
file = item.getAsFile();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
// Find target uploader
|
||||||
|
let targetUploader = null;
|
||||||
|
if (isModalOpen) {
|
||||||
|
targetUploader = uploader;
|
||||||
|
} else if (isUploadPage) {
|
||||||
|
const pageForm = document.querySelector('.pagewrapper .upload-form') || document.querySelector('#main .upload-form');
|
||||||
|
targetUploader = (pageForm && pageForm._f0ckUploader) ? pageForm._f0ckUploader : uploader;
|
||||||
|
} else {
|
||||||
|
targetUploader = uploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUploader && targetUploader.handleFile) {
|
||||||
|
if (isUploadPage || isModalOpen) {
|
||||||
|
targetUploader.handleFile(file);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (!isTyping) {
|
||||||
|
e.preventDefault();
|
||||||
|
showModal();
|
||||||
|
targetUploader.handleFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL paste (only if NOT typing)
|
||||||
|
const text = e.clipboardData.getData('text')?.trim();
|
||||||
|
if (text && (text.startsWith('http://') || text.startsWith('https://') || text.includes('youtube.com/') || text.includes('youtu.be/'))) {
|
||||||
|
if (!isTyping) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Context-aware target selection
|
||||||
|
let targetContainer = dragModal;
|
||||||
|
if (!isModalOpen && isUploadPage) {
|
||||||
|
targetContainer = document.querySelector('.pagewrapper .upload-form') ||
|
||||||
|
document.querySelector('#main .upload-form') ||
|
||||||
|
dragModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetContainer === dragModal && !isModalOpen && !isUploadPage) {
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to URL tab
|
||||||
|
const urlTab = targetContainer.querySelector('.upload-mode-tab[data-mode="url"]');
|
||||||
|
if (urlTab) urlTab.click();
|
||||||
|
|
||||||
|
// Fill input
|
||||||
|
const urlInput = targetContainer.querySelector('#url-upload-input');
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.value = text;
|
||||||
|
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
8200
public/s/js/f0ckm.js
Normal file
723
public/s/js/flash_yank.js
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'w0bmFlashFilterSettings_v1';
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
yank: 60, // 0..100 master "yank"
|
||||||
|
enabled: false,
|
||||||
|
advanced: false, // if true, use individual params instead of yank mapping
|
||||||
|
enableResolution: true, // per-axis degradation toggles (advanced only)
|
||||||
|
enableFps: true,
|
||||||
|
enablePalette: true,
|
||||||
|
internalWidth: 320,
|
||||||
|
fps: 12,
|
||||||
|
paletteLevels: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = loadSettings();
|
||||||
|
let currentConfig = computeConfig();
|
||||||
|
let chanLUT = buildChannelLUT(currentConfig.paletteLevels);
|
||||||
|
|
||||||
|
let currentController = null;
|
||||||
|
let hotkeyAttached = false;
|
||||||
|
let ui = null; // { panel, slider, yankValue, info, toggle }
|
||||||
|
const isMobile = /Mobi/i.test(navigator.userAgent);
|
||||||
|
|
||||||
|
function isItemPage() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
// Strictly match item pages (e.g., /123, /user/name/123) and exclude grids/specials
|
||||||
|
const isItem = (path.match(/^\/\d+/) || path.split('/').some(s => /^\d+$/.test(s))) && !path.match(/\/p\//);
|
||||||
|
const isForbidden = path === '/upload' || path.startsWith('/admin') || path.startsWith('/mod');
|
||||||
|
return isItem && !isForbidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Settings / Config ----------
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (val) {
|
||||||
|
const obj = JSON.parse(val);
|
||||||
|
return Object.assign({}, DEFAULT_SETTINGS, obj);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load settings from localStorage', e);
|
||||||
|
}
|
||||||
|
return Object.assign({}, DEFAULT_SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save settings to localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yank 0..100 => config
|
||||||
|
// Higher yank = harsher: lower res, lower FPS, fewer colors
|
||||||
|
function computeConfigFromYank(yank) {
|
||||||
|
const t = Math.min(1, Math.max(0, yank / 100));
|
||||||
|
// Match the ranges used by the advanced sliders:
|
||||||
|
// Resolution: 160..800, FPS: 8..30, Palette levels: 2..12
|
||||||
|
const widthMax = 800, widthMin = 160;
|
||||||
|
const fpsMax = 30, fpsMin = 8;
|
||||||
|
const levelsMax = 12, levelsMin = 2; // per channel
|
||||||
|
|
||||||
|
const width = Math.round(widthMax - (widthMax - widthMin) * t);
|
||||||
|
const height = Math.round(width * 9 / 16);
|
||||||
|
const fps = Math.round(fpsMax - (fpsMax - fpsMin) * t);
|
||||||
|
const paletteLevels = Math.round(levelsMax - (levelsMax - levelsMin) * t);
|
||||||
|
|
||||||
|
return {
|
||||||
|
internalWidth: width,
|
||||||
|
internalHeight: null, // Will be calculated dynamically based on aspect ratio
|
||||||
|
fps,
|
||||||
|
paletteLevels
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update advanced parameters from current yank value
|
||||||
|
function updateAdvancedFromYank() {
|
||||||
|
const cfg = computeConfigFromYank(settings.yank);
|
||||||
|
settings.internalWidth = cfg.internalWidth;
|
||||||
|
settings.fps = cfg.fps;
|
||||||
|
settings.paletteLevels = cfg.paletteLevels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive an approximate yank from current advanced sliders (0..100)
|
||||||
|
function updateYankFromAdvanced() {
|
||||||
|
const widthMax = 800, widthMin = 160;
|
||||||
|
const fpsMax = 30, fpsMin = 8;
|
||||||
|
const levelsMax = 12, levelsMin = 2;
|
||||||
|
|
||||||
|
const tWidth = (widthMax - settings.internalWidth) / (widthMax - widthMin);
|
||||||
|
const tFps = (fpsMax - settings.fps) / (fpsMax - fpsMin);
|
||||||
|
const tPalette = (levelsMax - settings.paletteLevels) / (levelsMax - levelsMin);
|
||||||
|
|
||||||
|
let t = (tWidth + tFps + tPalette) / 3;
|
||||||
|
t = Math.min(1, Math.max(0, t));
|
||||||
|
settings.yank = Math.round(t * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine yank mapping with optional advanced overrides
|
||||||
|
function computeConfig() {
|
||||||
|
if (settings.advanced) {
|
||||||
|
const w = clamp(settings.internalWidth || DEFAULT_SETTINGS.internalWidth, 160, 800);
|
||||||
|
const fps = clamp(settings.fps || DEFAULT_SETTINGS.fps, 8, 30);
|
||||||
|
const pl = clamp(settings.paletteLevels || DEFAULT_SETTINGS.paletteLevels, 2, 12);
|
||||||
|
settings.internalWidth = w;
|
||||||
|
settings.fps = fps;
|
||||||
|
settings.paletteLevels = pl;
|
||||||
|
return {
|
||||||
|
internalWidth: w,
|
||||||
|
internalHeight: null, // Calculated dynamically
|
||||||
|
fps,
|
||||||
|
paletteLevels: pl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = computeConfigFromYank(settings.yank);
|
||||||
|
// keep advanced values in sync so toggling advanced inherits current feel
|
||||||
|
settings.internalWidth = cfg.internalWidth;
|
||||||
|
settings.fps = cfg.fps;
|
||||||
|
settings.paletteLevels = cfg.paletteLevels;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v, min, max) {
|
||||||
|
v = Number(v);
|
||||||
|
if (isNaN(v)) return min;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettingsToRuntime() {
|
||||||
|
currentConfig = computeConfig();
|
||||||
|
chanLUT = buildChannelLUT(currentConfig.paletteLevels);
|
||||||
|
if (currentController && currentController.onConfigChanged) {
|
||||||
|
currentController.onConfigChanged();
|
||||||
|
}
|
||||||
|
updateUIFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Palette / Image processing ----------
|
||||||
|
|
||||||
|
function buildChannelLUT(levels) {
|
||||||
|
const lut = new Uint8Array(256);
|
||||||
|
if (levels <= 1) {
|
||||||
|
lut.fill(0);
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
const step = 255 / (levels - 1);
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
const idx = Math.round(i / step);
|
||||||
|
lut[i] = Math.round(idx * step);
|
||||||
|
}
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPalette(imageData, lut) {
|
||||||
|
const data = imageData.data;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
data[i] = lut[data[i]];
|
||||||
|
data[i + 1] = lut[data[i + 1]];
|
||||||
|
data[i + 2] = lut[data[i + 2]];
|
||||||
|
// alpha unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Video controller ----------
|
||||||
|
|
||||||
|
function createCanvasForVideo(video) {
|
||||||
|
// Ensure parent is relative for absolute positioning of canvas
|
||||||
|
const style = window.getComputedStyle(video.parentNode);
|
||||||
|
if (style.position === 'static') {
|
||||||
|
video.parentNode.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
canvas.style.position = 'absolute';
|
||||||
|
canvas.style.top = '0';
|
||||||
|
canvas.style.left = '0';
|
||||||
|
canvas.style.zIndex = '1'; // Lower z-index so controls can sit on top
|
||||||
|
canvas.style.pointerEvents = 'none'; // Let clicks pass through to controls
|
||||||
|
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
canvas.style.backgroundColor = 'transparent';
|
||||||
|
canvas.style.imageRendering = 'pixelated';
|
||||||
|
canvas.style.objectFit = 'contain';
|
||||||
|
video.parentNode.insertBefore(canvas, video.nextSibling);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController(video) {
|
||||||
|
const canvas = createCanvasForVideo(video);
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
let enabled = false;
|
||||||
|
let intervalId = null;
|
||||||
|
|
||||||
|
function applyConfigToCanvas() {
|
||||||
|
const rect = video.getBoundingClientRect();
|
||||||
|
const displayWidth = rect.width || video.clientWidth || video.width || 640;
|
||||||
|
const displayHeight = rect.height || video.clientHeight || video.height || 360;
|
||||||
|
|
||||||
|
let internalWidth = currentConfig.internalWidth;
|
||||||
|
let internalHeight;
|
||||||
|
|
||||||
|
// Calculate aspect ratio
|
||||||
|
const videoWidth = video.videoWidth || 640;
|
||||||
|
const videoHeight = video.videoHeight || 360;
|
||||||
|
const aspectRatio = videoHeight / videoWidth;
|
||||||
|
|
||||||
|
// If advanced resolution scaling is disabled, match display size for best quality
|
||||||
|
if (settings.advanced && !settings.enableResolution) {
|
||||||
|
internalWidth = Math.round(displayWidth);
|
||||||
|
internalHeight = Math.round(displayHeight);
|
||||||
|
} else {
|
||||||
|
// Calculate height based on fixed width and aspect ratio
|
||||||
|
internalHeight = Math.round(internalWidth * aspectRatio);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = internalWidth;
|
||||||
|
canvas.height = internalHeight;
|
||||||
|
canvas.width = internalWidth;
|
||||||
|
canvas.height = internalHeight;
|
||||||
|
// The canvas fills the container (which is sized by the hidden video)
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = '100%';
|
||||||
|
canvas.style.maxWidth = '';
|
||||||
|
canvas.style.aspectRatio = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyConfigToCanvas();
|
||||||
|
|
||||||
|
function drawFrame() {
|
||||||
|
if (!enabled || video.paused || video.ended) return;
|
||||||
|
try {
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
|
||||||
|
// If advanced palette reduction is disabled, skip quantization
|
||||||
|
if (!(settings.advanced && !settings.enablePalette)) {
|
||||||
|
const frame = ctx.getImageData(0, 0, w, h);
|
||||||
|
applyPalette(frame, chanLUT);
|
||||||
|
ctx.putImageData(frame, 0, 0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoop() {
|
||||||
|
if (intervalId || !enabled) return;
|
||||||
|
const effectiveFps = (settings.advanced && !settings.enableFps) ? 60 : currentConfig.fps;
|
||||||
|
intervalId = setInterval(drawFrame, 1000 / effectiveFps);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoop() {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable() {
|
||||||
|
if (enabled) return;
|
||||||
|
enabled = true;
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
video.style.visibility = 'hidden'; // Keep layout space!
|
||||||
|
if (!video.paused && !video.ended) startLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
if (!enabled) return;
|
||||||
|
enabled = false;
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
video.style.visibility = '';
|
||||||
|
stopLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (enabled) disable();
|
||||||
|
else enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfigChanged() {
|
||||||
|
const wasEnabled = enabled;
|
||||||
|
stopLoop();
|
||||||
|
applyConfigToCanvas();
|
||||||
|
if (wasEnabled) {
|
||||||
|
startLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
disable();
|
||||||
|
canvas.remove();
|
||||||
|
video.removeEventListener('play', startLoop);
|
||||||
|
video.removeEventListener('pause', stopLoop);
|
||||||
|
video.removeEventListener('ended', stopLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('play', startLoop);
|
||||||
|
video.addEventListener('pause', stopLoop);
|
||||||
|
video.addEventListener('ended', stopLoop);
|
||||||
|
video.addEventListener('loadedmetadata', applyConfigToCanvas); // Recalculate when metadata allows
|
||||||
|
|
||||||
|
return { enable, disable, toggle, isEnabled, destroy, onConfigChanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupVideo(video) {
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
if (!isItemPage()) {
|
||||||
|
if (ui) ui.wrapper.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritize the main item player (id="my-video" or class "viewer")
|
||||||
|
const isPrimary = video.id === 'my-video' || video.classList.contains('viewer') || video.classList.contains('v0ck_video');
|
||||||
|
|
||||||
|
if (video.dataset.flashFilterAttached === '1') {
|
||||||
|
// If already attached, ensure its currentController is restored if it's the primary one
|
||||||
|
if (isPrimary && !currentController) {
|
||||||
|
currentController = video.__flashFilterController;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.dataset.flashFilterAttached = '1';
|
||||||
|
const controller = makeController(video);
|
||||||
|
video.__flashFilterController = controller;
|
||||||
|
|
||||||
|
// If this is the primary video, or we don't have one yet, set as current
|
||||||
|
if (isPrimary || !currentController) {
|
||||||
|
currentController = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always sync with global setting on creation
|
||||||
|
if (settings.enabled) {
|
||||||
|
controller.enable();
|
||||||
|
} else {
|
||||||
|
controller.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure v0ck has injected its controls
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ui) updateUIFromSettings();
|
||||||
|
}, 50);
|
||||||
|
if (ui && isPrimary) {
|
||||||
|
ui.wrapper.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanExistingVideos() {
|
||||||
|
document.querySelectorAll('video').forEach(setupVideo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startObserver() {
|
||||||
|
if (!document.body) return;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
m.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
setupVideo(node);
|
||||||
|
} else if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll('video').forEach(setupVideo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
m.removedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== 1) return;
|
||||||
|
|
||||||
|
const vids = [];
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
vids.push(node);
|
||||||
|
}
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll('video').forEach(v => vids.push(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
vids.forEach((v) => {
|
||||||
|
const c = v.__flashFilterController;
|
||||||
|
if (c) {
|
||||||
|
c.destroy();
|
||||||
|
if (currentController === c) {
|
||||||
|
currentController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ui && document.querySelectorAll('video').length === 0) {
|
||||||
|
ui.wrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI Panel ----------
|
||||||
|
|
||||||
|
function createOptionsUI() {
|
||||||
|
if (ui || !document.body) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.position = 'fixed';
|
||||||
|
wrapper.style.bottom = '10px';
|
||||||
|
wrapper.style.right = '10px';
|
||||||
|
wrapper.style.zIndex = '99999';
|
||||||
|
wrapper.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
|
wrapper.style.fontSize = '12px';
|
||||||
|
wrapper.style.color = '#fff';
|
||||||
|
wrapper.style.display = 'none'; // Initially hidden
|
||||||
|
wrapper.id = 'flash-yank-ui';
|
||||||
|
|
||||||
|
const floatingBadge = document.createElement('div');
|
||||||
|
floatingBadge.id = 'w0bm-floating-swf';
|
||||||
|
floatingBadge.textContent = 'SWF';
|
||||||
|
floatingBadge.style.background = 'rgba(0,0,0,0.9)';
|
||||||
|
floatingBadge.style.padding = '2px 8px';
|
||||||
|
floatingBadge.style.borderRadius = '3px';
|
||||||
|
floatingBadge.style.cursor = 'pointer';
|
||||||
|
floatingBadge.style.fontWeight = 'bold';
|
||||||
|
floatingBadge.style.letterSpacing = '0.08em';
|
||||||
|
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.style.position = 'fixed';
|
||||||
|
panel.style.right = '10px';
|
||||||
|
panel.style.bottom = '22px';
|
||||||
|
panel.style.background = 'rgba(0,0,0,0.9)';
|
||||||
|
panel.style.color = '#fff';
|
||||||
|
panel.style.padding = '6px 10px';
|
||||||
|
panel.style.fontSize = '12px';
|
||||||
|
panel.style.borderRadius = '4px';
|
||||||
|
panel.style.maxWidth = '320px';
|
||||||
|
panel.style.minWidth = '240px';
|
||||||
|
panel.style.maxHeight = 'calc(100vh - 40px)'; // Prevent exceeding screen height
|
||||||
|
panel.style.overflowY = 'auto'; // Make scrollable
|
||||||
|
panel.style.boxSizing = 'border-box';
|
||||||
|
panel.style.boxShadow = '0 0 6px rgba(0,0,0,0.7)';
|
||||||
|
panel.style.display = 'none';
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<div id="w0bm-title" style="font-weight:bold; cursor:pointer;">Flash Yank</div>
|
||||||
|
<div id="w0bm-close" style="cursor:pointer; font-size:16px; padding: 0 4px;">×</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style="font-size:11px; display:block; margin-bottom:2px;">
|
||||||
|
Yank: <span id="w0bm-yank-value"></span>
|
||||||
|
</label>
|
||||||
|
<input id="w0bm-yank-slider" type="range" min="0" max="100" step="1" style="width:100%;">
|
||||||
|
|
||||||
|
<div id="w0bm-yank-info" style="font-size:10px; margin-top:8px; opacity: 0.8;"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
wrapper.appendChild(panel);
|
||||||
|
wrapper.appendChild(floatingBadge);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
|
||||||
|
let panelVisible = false;
|
||||||
|
const HOVER_MARGIN = 50; // px around panel before closing
|
||||||
|
|
||||||
|
let activePlayer = null;
|
||||||
|
|
||||||
|
function showPanel(trigger) {
|
||||||
|
if (panelVisible) return;
|
||||||
|
panelVisible = true;
|
||||||
|
panel.style.display = 'block';
|
||||||
|
|
||||||
|
activePlayer = trigger.closest('.v0ck');
|
||||||
|
if (activePlayer) activePlayer.classList.add('v0ck_swf_active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
|
if (!panelVisible) return;
|
||||||
|
panelVisible = false;
|
||||||
|
panel.style.display = 'none';
|
||||||
|
|
||||||
|
if (activePlayer) {
|
||||||
|
activePlayer.classList.remove('v0ck_swf_active');
|
||||||
|
activePlayer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBadgeHover(target) {
|
||||||
|
if (target.id === 'toggleswf' || target === floatingBadge) {
|
||||||
|
// Anchor to trigger element (shared logic for mobile/desktop)
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
panel.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||||
|
panel.style.right = (window.innerWidth - rect.right) + 'px';
|
||||||
|
panel.style.transform = 'none';
|
||||||
|
panel.style.maxWidth = isMobile ? '90vw' : '320px';
|
||||||
|
|
||||||
|
showPanel(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', (e) => {
|
||||||
|
const target = e.target.closest('#toggleswf') || (e.target === floatingBadge ? floatingBadge : null);
|
||||||
|
if (target) {
|
||||||
|
handleBadgeHover(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!panelVisible || isMobile) return; // Don't hide by mousemove on mobile
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
const left = rect.left - HOVER_MARGIN;
|
||||||
|
const right = rect.right + HOVER_MARGIN;
|
||||||
|
const top = rect.top - HOVER_MARGIN;
|
||||||
|
const bottom = rect.bottom + HOVER_MARGIN;
|
||||||
|
|
||||||
|
if (e.clientX < left || e.clientX > right || e.clientY < top || e.clientY > bottom) {
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = panel.querySelector('#w0bm-title');
|
||||||
|
const slider = panel.querySelector('#w0bm-yank-slider');
|
||||||
|
const yankValue = panel.querySelector('#w0bm-yank-value');
|
||||||
|
const info = panel.querySelector('#w0bm-yank-info');
|
||||||
|
|
||||||
|
ui = {
|
||||||
|
wrapper,
|
||||||
|
floatingBadge,
|
||||||
|
panel,
|
||||||
|
title,
|
||||||
|
slider,
|
||||||
|
yankValue,
|
||||||
|
info
|
||||||
|
};
|
||||||
|
|
||||||
|
slider.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
settings.yank = isNaN(val) ? 0 : Math.min(100, Math.max(0, val));
|
||||||
|
// Yank drives the underlying advanced parameters
|
||||||
|
updateAdvancedFromYank();
|
||||||
|
saveSettings();
|
||||||
|
applySettingsToRuntime();
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleEnabledFromUI(targetController) {
|
||||||
|
settings.enabled = !settings.enabled;
|
||||||
|
saveSettings();
|
||||||
|
|
||||||
|
// If a specific controller was the target (e.g. clicked inside a player), use it.
|
||||||
|
// Otherwise use the global currentController (main player).
|
||||||
|
const controller = targetController || currentController;
|
||||||
|
if (controller) {
|
||||||
|
if (settings.enabled) controller.enable();
|
||||||
|
else controller.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For global consistency, if settings.enabled changed, we might want to toggle ALL?
|
||||||
|
// But per user request, we focus on the item player.
|
||||||
|
// If there's another video that isn't the currentController, it won't toggle here,
|
||||||
|
// but the hotkey and UI rely on currentController.
|
||||||
|
|
||||||
|
updateUIFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click SWF badge or title to toggle filter enabled/disabled
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const swfBtn = e.target.closest('#toggleswf');
|
||||||
|
const isBadge = swfBtn || e.target === floatingBadge;
|
||||||
|
const isTitle = e.target === title;
|
||||||
|
if (isBadge || isTitle) {
|
||||||
|
// If clicked a button inside a player, try to get THAT player's controller
|
||||||
|
let targetCtrl = null;
|
||||||
|
if (swfBtn) {
|
||||||
|
const player = swfBtn.closest('.v0ck');
|
||||||
|
const vid = player ? player.querySelector('video') : null;
|
||||||
|
if (vid && vid.__flashFilterController) {
|
||||||
|
targetCtrl = vid.__flashFilterController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleEnabledFromUI(targetCtrl);
|
||||||
|
// On mobile, explicitly show panel on click/tap
|
||||||
|
if (isMobile) {
|
||||||
|
handleBadgeHover(swfBtn || floatingBadge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = panel.querySelector('#w0bm-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
hidePanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close Flash Yank panel when the main settings menu is closed
|
||||||
|
document.addEventListener('v0ck_settings_closed', () => {
|
||||||
|
hidePanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateUIFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUIFromSettings() {
|
||||||
|
if (!ui) return;
|
||||||
|
|
||||||
|
const isItem = isItemPage();
|
||||||
|
const hasVideos = document.querySelectorAll('video').length > 0;
|
||||||
|
|
||||||
|
if (!isItem || !hasVideos) {
|
||||||
|
ui.wrapper.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.slider.value = settings.yank;
|
||||||
|
ui.yankValue.textContent = settings.yank + '%';
|
||||||
|
|
||||||
|
const approxColors = Math.pow(currentConfig.paletteLevels, 3);
|
||||||
|
ui.info.textContent = currentConfig.internalWidth + 'px width, ' +
|
||||||
|
currentConfig.fps + 'fps, ~' + approxColors + ' colors';
|
||||||
|
|
||||||
|
// Enable/disable controls based on enabled state
|
||||||
|
const isEnabled = !!settings.enabled;
|
||||||
|
ui.slider.disabled = !isEnabled;
|
||||||
|
|
||||||
|
// Visual state: strike-through when disabled
|
||||||
|
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
||||||
|
|
||||||
|
// Handle floating badge visibility
|
||||||
|
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
|
||||||
|
|
||||||
|
// Style both (if they exist)
|
||||||
|
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
||||||
|
if (!b) return;
|
||||||
|
b.style.textDecoration = isEnabled ? 'none' : 'line-through';
|
||||||
|
b.style.opacity = isEnabled ? '1' : '0.6';
|
||||||
|
if (b.classList.contains('v0ck_menu_item')) {
|
||||||
|
b.style.color = isEnabled ? 'var(--accent, #9f0)' : '#fff';
|
||||||
|
b.style.fontWeight = isEnabled ? 'bold' : 'normal';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hotkey ----------
|
||||||
|
|
||||||
|
function attachHotkey() {
|
||||||
|
if (hotkeyAttached) return;
|
||||||
|
hotkeyAttached = true;
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !isItemPage()) return;
|
||||||
|
if (e.key.toLowerCase() !== 's') return;
|
||||||
|
|
||||||
|
// Ignore when typing in an input, textarea, or contenteditable
|
||||||
|
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable) return;
|
||||||
|
|
||||||
|
if (!currentController) return;
|
||||||
|
|
||||||
|
settings.enabled = !settings.enabled;
|
||||||
|
if (settings.enabled) currentController.enable();
|
||||||
|
else currentController.disable();
|
||||||
|
|
||||||
|
saveSettings();
|
||||||
|
updateUIFromSettings();
|
||||||
|
|
||||||
|
if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bootstrapping ----------
|
||||||
|
|
||||||
|
function onReady(fn) {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
||||||
|
} else {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(() => {
|
||||||
|
// firefox mobile check
|
||||||
|
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
|
||||||
|
if (isFirefoxMobile) {
|
||||||
|
console.log("Firefox Mobile detected, disabling Flash Yank script.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applySettingsToRuntime();
|
||||||
|
createOptionsUI();
|
||||||
|
scanExistingVideos();
|
||||||
|
startObserver();
|
||||||
|
attachHotkey();
|
||||||
|
|
||||||
|
// Path change handling for AJAX transitions
|
||||||
|
const handlePathChange = () => {
|
||||||
|
// Reset currentController on path change to force re-discovery
|
||||||
|
currentController = null;
|
||||||
|
if (!isItemPage()) {
|
||||||
|
if (ui) ui.wrapper.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
// Scan after a short delay to allow DOM/v0ck to settle
|
||||||
|
setTimeout(() => {
|
||||||
|
scanExistingVideos();
|
||||||
|
updateUIFromSettings();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePathChange);
|
||||||
|
document.addEventListener('f0ck:contentLoaded', handlePathChange);
|
||||||
|
});
|
||||||
|
})();
|
||||||
1271
public/s/js/globalchat.js
Normal file
48
public/s/js/koepfe.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
(function() {
|
||||||
|
const koepfe = window.f0ckKoepfe;
|
||||||
|
if (!koepfe || !koepfe.length) return;
|
||||||
|
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
const pick = () => {
|
||||||
|
if (koepfe.length === 1) return koepfe[0];
|
||||||
|
let next;
|
||||||
|
do { next = koepfe[~~(Math.random() * koepfe.length)]; } while (next === current && koepfe.length > 1);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.getElementById('koepfe-img')) return;
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'koepfe-img';
|
||||||
|
img.alt = '';
|
||||||
|
img.draggable = false;
|
||||||
|
|
||||||
|
const show = (src) => {
|
||||||
|
current = src;
|
||||||
|
img.classList.remove('visible');
|
||||||
|
img.onload = () => img.classList.add('visible');
|
||||||
|
img.src = src;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
document.body.prepend(img);
|
||||||
|
show(pick());
|
||||||
|
|
||||||
|
// Hide the image immediately when a new AJAX load starts to prevent it
|
||||||
|
// from being revealed while the page content is empty/churning.
|
||||||
|
window.addEventListener('pjax:start', () => {
|
||||||
|
img.classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change on AJAX navigation (next/prev/random/page change) but NOT infinite scroll
|
||||||
|
document.addEventListener('f0ck:contentLoaded', (e) => {
|
||||||
|
if (e.detail && e.detail.isInfinite) return;
|
||||||
|
|
||||||
|
// Small delay to ensure the content layer is fully painted on top
|
||||||
|
// before we swap the background image, preventing 'pop-in' flickers.
|
||||||
|
setTimeout(() => {
|
||||||
|
show(pick());
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
})();
|
||||||
69
public/s/js/marked.min.js
vendored
Normal file
405
public/s/js/meme-creator.js
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
/**
|
||||||
|
* Meme Creator Logic
|
||||||
|
* DYNAMIC MULTIPLE LAYERS
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const canvas = document.getElementById('memeCanvas');
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const layersContainer = document.getElementById('textLayersContainer');
|
||||||
|
const addTextBtn = document.getElementById('addText');
|
||||||
|
|
||||||
|
const uploadBtn = document.getElementById('uploadMeme');
|
||||||
|
|
||||||
|
// Core state
|
||||||
|
let textLayers = [];
|
||||||
|
let dragOffset = { x: 0, y: 0 };
|
||||||
|
let draggingLayer = null;
|
||||||
|
let hoveredLayer = null;
|
||||||
|
let img = new Image();
|
||||||
|
|
||||||
|
const memeFont = 'Impact, Charcoal, sans-serif';
|
||||||
|
|
||||||
|
// Image Setup
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = img.width || 800;
|
||||||
|
canvas.height = img.height || 600;
|
||||||
|
|
||||||
|
const defaultSize = 40;
|
||||||
|
|
||||||
|
// Initial layers
|
||||||
|
textLayers = [
|
||||||
|
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||||
|
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||||
|
];
|
||||||
|
|
||||||
|
renderInputs();
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
img.src = window.memeTemplate.url;
|
||||||
|
|
||||||
|
// Ensure font is loaded before first draw
|
||||||
|
if (document.fonts) {
|
||||||
|
document.fonts.ready.then(() => {
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInputs() {
|
||||||
|
layersContainer.innerHTML = '';
|
||||||
|
textLayers.forEach((layer, index) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'form-group layer-input-group';
|
||||||
|
div.style.marginBottom = '20px';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<label style="margin-bottom: 0; font-weight: bold;">${(window.f0ckI18n?.meme?.text_layer) || 'Text Layer'} ${index + 1}</label>
|
||||||
|
<button class="remove-layer" data-id="${layer.id}" style="background: transparent; border: none; color: #ff4444; cursor: pointer; padding: 0 5px;">
|
||||||
|
<i class="fa fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea data-id="${layer.id}" placeholder="${(window.f0ckI18n?.meme?.enter_text) || 'Enter text...'}" rows="2" style="width: 100%; margin-bottom: 8px;">${layer.text}</textarea>
|
||||||
|
|
||||||
|
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
||||||
|
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textarea = div.querySelector('textarea');
|
||||||
|
textarea.addEventListener('input', (e) => {
|
||||||
|
layer.text = e.target.value;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fsInput = div.querySelector('.layer-fs-input');
|
||||||
|
const fsVal = div.querySelector('.layer-fs-val');
|
||||||
|
fsInput.addEventListener('input', (e) => {
|
||||||
|
layer.fontSize = parseInt(e.target.value);
|
||||||
|
fsVal.textContent = layer.fontSize;
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = div.querySelector('.remove-layer');
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
textLayers = textLayers.filter(l => l.id !== layer.id);
|
||||||
|
renderInputs();
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
layersContainer.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addTextBtn.addEventListener('click', () => {
|
||||||
|
textLayers.push({
|
||||||
|
id: Date.now(),
|
||||||
|
text: 'NEW TEXT',
|
||||||
|
x: canvas.width / 2,
|
||||||
|
y: canvas.height / 2,
|
||||||
|
fontSize: 40
|
||||||
|
});
|
||||||
|
renderInputs();
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!img.complete) return;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.strokeStyle = 'black';
|
||||||
|
ctx.miterLimit = 2; // Prevent sharp spikes in characters like 'A'
|
||||||
|
|
||||||
|
const globalFontSize = 40;
|
||||||
|
|
||||||
|
// Render each layer
|
||||||
|
textLayers.forEach((layer) => {
|
||||||
|
if (!layer.text) return;
|
||||||
|
|
||||||
|
const fontSize = layer.fontSize || 40;
|
||||||
|
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||||
|
|
||||||
|
let displayStr = layer.text.toUpperCase();
|
||||||
|
const lines = displayStr.split('\n');
|
||||||
|
const h = lines.length * fontSize * 1.1;
|
||||||
|
const w = canvas.width * 0.9;
|
||||||
|
|
||||||
|
// Box for the dragged/hovered layer (top-most layer gets preference)
|
||||||
|
if (hoveredLayer === layer || draggingLayer === layer) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
ctx.strokeStyle = '#9f0';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(layer.x - w / 2, layer.y - 10, w, h + 20);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
const yOffset = i * (fontSize * 1.1);
|
||||||
|
const renderX = Math.round(layer.x);
|
||||||
|
const renderY = Math.round(layer.y + yOffset);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
||||||
|
ctx.strokeStyle = 'black';
|
||||||
|
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
||||||
|
ctx.strokeText(line, renderX, renderY);
|
||||||
|
ctx.fillText(line, renderX, renderY);
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options hooks removed as they are now hardcoded and the inputs are gone
|
||||||
|
|
||||||
|
const getEventPos = (e) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||||
|
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (clientX - rect.left) * (canvas.width / rect.width),
|
||||||
|
y: (clientY - rect.top) * (canvas.height / rect.height)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isInsideText = (pt, layer) => {
|
||||||
|
if (!layer.text) return false;
|
||||||
|
const fontSize = layer.fontSize || 40;
|
||||||
|
const lines = layer.text.split('\n');
|
||||||
|
const w = canvas.width * 0.95;
|
||||||
|
const h = lines.length * fontSize * 1.2;
|
||||||
|
|
||||||
|
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
||||||
|
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
||||||
|
};
|
||||||
|
|
||||||
|
// POINTER EVENTS
|
||||||
|
const onStart = (e) => {
|
||||||
|
const pt = getEventPos(e);
|
||||||
|
|
||||||
|
// Find layer (start from top-most, reverse of render order)
|
||||||
|
draggingLayer = [...textLayers].reverse().find(layer => isInsideText(pt, layer)) || null;
|
||||||
|
|
||||||
|
if (draggingLayer) {
|
||||||
|
dragOffset = { x: pt.x - draggingLayer.x, y: pt.y - draggingLayer.y };
|
||||||
|
if (e.pointerId) canvas.setPointerCapture(e.pointerId);
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
draw();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
const pt = getEventPos(e);
|
||||||
|
|
||||||
|
if (draggingLayer) {
|
||||||
|
draggingLayer.x = pt.x - dragOffset.x;
|
||||||
|
draggingLayer.y = pt.y - dragOffset.y;
|
||||||
|
draw();
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
// Hover logic
|
||||||
|
const currentHover = [...textLayers].reverse().find(layer => isInsideText(pt, layer)) || null;
|
||||||
|
if (currentHover !== hoveredLayer) {
|
||||||
|
hoveredLayer = currentHover;
|
||||||
|
canvas.style.cursor = hoveredLayer ? 'grab' : 'crosshair';
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = (e) => {
|
||||||
|
if (draggingLayer) {
|
||||||
|
if (e.pointerId) canvas.releasePointerCapture(e.pointerId);
|
||||||
|
draggingLayer = null;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onStart);
|
||||||
|
window.addEventListener('pointermove', onMove);
|
||||||
|
window.addEventListener('pointerup', onEnd);
|
||||||
|
canvas.addEventListener('pointercancel', onEnd);
|
||||||
|
canvas.addEventListener('mousedown', onStart);
|
||||||
|
// Upload
|
||||||
|
uploadBtn.addEventListener('click', async () => {
|
||||||
|
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
||||||
|
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
||||||
|
|
||||||
|
const isOrakelVon10 = subCategory === 'von10';
|
||||||
|
const isOrakelUser = subCategory === 'user';
|
||||||
|
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
|
||||||
|
|
||||||
|
let uploadCanvas = canvas;
|
||||||
|
|
||||||
|
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
|
||||||
|
// Create an off-screen canvas to apply the orakel answer silently
|
||||||
|
uploadCanvas = document.createElement('canvas');
|
||||||
|
uploadCanvas.width = canvas.width;
|
||||||
|
uploadCanvas.height = canvas.height;
|
||||||
|
const uCtx = uploadCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Copy current canvas state
|
||||||
|
uCtx.drawImage(canvas, 0, 0);
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
if (isOrakelNormal) {
|
||||||
|
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
||||||
|
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
||||||
|
} else if (isOrakelUser) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/orakel/user');
|
||||||
|
const data = await res.json();
|
||||||
|
result = (data.success && data.username) ? `${data.display_name || data.username}|||ID: ${data.id}` : 'Anonymous';
|
||||||
|
} catch (e) {
|
||||||
|
result = 'Anonymous';
|
||||||
|
}
|
||||||
|
} else if (isOrakelVon10) {
|
||||||
|
result = Math.floor(Math.random() * 11).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Orakel result on the hidden canvas
|
||||||
|
uCtx.save();
|
||||||
|
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||||
|
uCtx.textAlign = 'center';
|
||||||
|
uCtx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
if (isOrakelNormal) {
|
||||||
|
uCtx.shadowBlur = 20;
|
||||||
|
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
||||||
|
} else if (isOrakelVon10) {
|
||||||
|
uCtx.shadowBlur = 0; // No shadow as requested
|
||||||
|
} else {
|
||||||
|
// No shadow for the User Orakel
|
||||||
|
uCtx.shadowBlur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uCtx.fillStyle = '#fff';
|
||||||
|
uCtx.strokeStyle = '#000';
|
||||||
|
uCtx.lineWidth = 10;
|
||||||
|
uCtx.miterLimit = 2;
|
||||||
|
|
||||||
|
// Adjust position for user Orakel (reverting to +10 offset)
|
||||||
|
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
||||||
|
|
||||||
|
if (isOrakelVon10) {
|
||||||
|
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
||||||
|
}
|
||||||
|
|
||||||
|
const xPos = Math.round(uploadCanvas.width / 2);
|
||||||
|
|
||||||
|
// Auto-fit font size for user orakel — shrink until text fits within image width
|
||||||
|
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
||||||
|
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
||||||
|
|
||||||
|
if (isOrakelUser) {
|
||||||
|
const parts = result.split('|||');
|
||||||
|
const namePart = parts[0];
|
||||||
|
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
||||||
|
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
||||||
|
|
||||||
|
// Even tighter threshold for User Orakel (approx 25% total padding)
|
||||||
|
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
||||||
|
|
||||||
|
let currentFontSize = 74;
|
||||||
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
|
|
||||||
|
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||||
|
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||||
|
currentFontSize -= 2;
|
||||||
|
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||||
|
|
||||||
|
if (combinedFits) {
|
||||||
|
// Single line — potentially shrunk for long names
|
||||||
|
uCtx.fillText(combinedText, xPos, yPos);
|
||||||
|
} else {
|
||||||
|
// Two lines — auto-fit just the name, ID below
|
||||||
|
let nameFontSize = 74;
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||||
|
nameFontSize -= 2;
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||||
|
const lineGap = Math.round(nameFontSize * 0.65);
|
||||||
|
const nameY = Math.round(yPos - lineGap / 2);
|
||||||
|
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||||
|
|
||||||
|
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||||
|
uCtx.fillText(namePart, xPos, nameY);
|
||||||
|
|
||||||
|
if (idPart) {
|
||||||
|
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||||
|
uCtx.fillText(idPart, xPos, idY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal / von10 — single line as before
|
||||||
|
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||||
|
uCtx.strokeText(result, xPos, yPos);
|
||||||
|
uCtx.fillText(result, xPos, yPos);
|
||||||
|
}
|
||||||
|
uCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ' + (window.f0ckI18n?.uploading || 'Uploading...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await new Promise(resolve => uploadCanvas.toBlob(resolve, 'image/jpeg', 0.95));
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, `meme-${Date.now()}.jpg`);
|
||||||
|
const defaultTags = document.getElementById('tags').value || 'meme';
|
||||||
|
const autoTag = window.memeTemplate ? window.memeTemplate.name : '';
|
||||||
|
const tags = `${defaultTags}, ${autoTag}`;
|
||||||
|
|
||||||
|
formData.append('rating', 'sfw');
|
||||||
|
formData.append('tags', tags);
|
||||||
|
formData.append('csrf_token', window.csrf_token);
|
||||||
|
|
||||||
|
const res = await fetch('/api/v2/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'X-CSRF-Token': window.csrf_token, 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
const dest = result.redirect || '/meme';
|
||||||
|
if (window.loadItemAjax) {
|
||||||
|
window.loadItemAjax(dest);
|
||||||
|
} else if (window.loadPageAjax) {
|
||||||
|
window.loadPageAjax(dest);
|
||||||
|
} else {
|
||||||
|
window.location.href = dest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
window.flashMessage('Error: ' + result.msg, 3000, 'error');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
uploadBtn.innerHTML = `<i class="fa fa-upload"></i> ${(window.f0ckI18n?.meme?.upload_btn) || 'Upload Meme'}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
window.flashMessage('Upload failed', 3000, 'error');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial draw
|
||||||
|
setTimeout(draw, 300);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
199
public/s/js/mention_autocomplete.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* MentionAutocomplete — Real-time user mention suggestions for textareas
|
||||||
|
* Detects "@" and fetches matching users from the backend API.
|
||||||
|
*/
|
||||||
|
window.MentionAutocomplete = (() => {
|
||||||
|
let activeDropdown = null;
|
||||||
|
let selectedIndex = -1;
|
||||||
|
let suggestions = [];
|
||||||
|
let currentInput = null;
|
||||||
|
let mentionStart = -1;
|
||||||
|
let query = '';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 200;
|
||||||
|
let debounceTimer = null;
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (activeDropdown) {
|
||||||
|
activeDropdown.remove();
|
||||||
|
activeDropdown = null;
|
||||||
|
}
|
||||||
|
selectedIndex = -1;
|
||||||
|
suggestions = [];
|
||||||
|
mentionStart = -1;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionDropdown(input) {
|
||||||
|
if (!activeDropdown) return;
|
||||||
|
const rect = input.getBoundingClientRect();
|
||||||
|
activeDropdown.style.left = `${rect.left}px`;
|
||||||
|
activeDropdown.style.width = `${rect.width}px`;
|
||||||
|
// layout-modern: input is near the top of the sidebar → open downward
|
||||||
|
if (document.body.classList.contains('layout-modern')) {
|
||||||
|
activeDropdown.style.top = `${rect.bottom}px`;
|
||||||
|
activeDropdown.style.bottom = 'auto';
|
||||||
|
} else {
|
||||||
|
activeDropdown.style.bottom = `${window.innerHeight - rect.top + 5}px`;
|
||||||
|
activeDropdown.style.top = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsers(q) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.suggestions || [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDropdown() {
|
||||||
|
if (!activeDropdown) {
|
||||||
|
activeDropdown = document.createElement('div');
|
||||||
|
activeDropdown.className = 'mention-suggestions';
|
||||||
|
document.body.appendChild(activeDropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDropdown.innerHTML = '';
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions.forEach((user, i) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'mention-suggestion-item';
|
||||||
|
if (i === selectedIndex) item.classList.add('active');
|
||||||
|
|
||||||
|
const avatarSrc = user.avatar_file ? `/a/${user.avatar_file}` : (user.avatar ? `/t/${user.avatar}.webp` : '/a/default.png');
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = avatarSrc;
|
||||||
|
img.onerror = () => { img.src = '/a/default.png'; };
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'mention-name';
|
||||||
|
nameSpan.textContent = user.user;
|
||||||
|
|
||||||
|
item.appendChild(img);
|
||||||
|
item.appendChild(nameSpan);
|
||||||
|
|
||||||
|
if (user.display_name) {
|
||||||
|
const displaySpan = document.createElement('span');
|
||||||
|
displaySpan.className = 'mention-display';
|
||||||
|
displaySpan.textContent = user.display_name;
|
||||||
|
item.appendChild(displaySpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(user.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
activeDropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the active item is visible in the scrollable container
|
||||||
|
const activeItem = activeDropdown.querySelector('.mention-suggestion-item.active');
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
positionDropdown(currentInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipNextInput = false;
|
||||||
|
|
||||||
|
function insertMention(username) {
|
||||||
|
if (!currentInput) return;
|
||||||
|
const text = currentInput.value;
|
||||||
|
const before = text.substring(0, mentionStart);
|
||||||
|
const after = text.substring(currentInput.selectionStart);
|
||||||
|
const insert = username.includes(' ') ? `[@${username}]` : `@${username}`;
|
||||||
|
currentInput.value = before + insert + after;
|
||||||
|
const newPos = before.length + insert.length;
|
||||||
|
currentInput.setSelectionRange(newPos, newPos);
|
||||||
|
currentInput.focus();
|
||||||
|
destroy();
|
||||||
|
|
||||||
|
// Trigger generic input/change events for other modules (like auto-resize or emoji autocomplete)
|
||||||
|
skipNextInput = true;
|
||||||
|
currentInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
currentInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (!activeDropdown) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = (selectedIndex + 1) % suggestions.length;
|
||||||
|
renderDropdown();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
|
||||||
|
renderDropdown();
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
insertMention(suggestions[selectedIndex].user);
|
||||||
|
} else {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(e) {
|
||||||
|
if (skipNextInput) {
|
||||||
|
skipNextInput = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = e.target;
|
||||||
|
const pos = input.selectionStart;
|
||||||
|
const text = input.value.substring(0, pos);
|
||||||
|
|
||||||
|
// Detect @ followed by alphanum, _, -, . (standard username chars)
|
||||||
|
const match = text.match(/@([a-zA-Z0-9_\-\.]{0,})$/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
currentInput = input;
|
||||||
|
mentionStart = pos - match[0].length;
|
||||||
|
query = match[1];
|
||||||
|
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
suggestions = await fetchUsers(query);
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
selectedIndex = 0;
|
||||||
|
renderDropdown();
|
||||||
|
} else {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Attach mention autocomplete to a textarea
|
||||||
|
* @param {HTMLTextAreaElement} textarea
|
||||||
|
*/
|
||||||
|
attach(textarea) {
|
||||||
|
if (!textarea || textarea._mentionsAttached) return;
|
||||||
|
textarea._mentionsAttached = true;
|
||||||
|
textarea.addEventListener('input', handleInput);
|
||||||
|
textarea.addEventListener('keydown', handleKeyDown);
|
||||||
|
textarea.addEventListener('blur', () => {
|
||||||
|
setTimeout(destroy, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
1705
public/s/js/messages.js
Normal file
93
public/s/js/sanitizer.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Simple Whitelist-based HTML Sanitizer
|
||||||
|
* Protects against XSS by stripping disallowed tags and attributes.
|
||||||
|
*/
|
||||||
|
class Sanitizer {
|
||||||
|
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'textarea', 'button', 'input', 'label', 'select', 'option', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
|
||||||
|
static ALLOWED_ATTRS = ['class', 'style', 'src', 'href', 'alt', 'title', 'target', 'width', 'height', 'placeholder', 'readonly', 'disabled', 'value', 'name', 'id', 'type', 'data-parent', 'data-id', 'data-username', 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'points', 'x1', 'y1', 'x2', 'y2', 'd', 'transform', 'rx', 'ry', 'x', 'y', 'offset', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'fill-opacity', 'stroke-opacity', 'preserveaspectratio', 'vector-effect', 'pointer-events', 'allowfullscreen', 'frameborder', 'allow', 'referrerpolicy', 'rel', 'controls', 'loop', 'muted', 'playsinline', 'preload', 'tooltip', 'flow'];
|
||||||
|
static DISALLOWED_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean an HTML string
|
||||||
|
* @param {string} html
|
||||||
|
* @returns {string} Sanitized HTML string
|
||||||
|
*/
|
||||||
|
static clean(html) {
|
||||||
|
if (!html) return '';
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html;
|
||||||
|
this.sanitizeNode(template.content);
|
||||||
|
return template.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iteratively sanitize DOM nodes (prevents stack overflow)
|
||||||
|
* @param {Node} root
|
||||||
|
*/
|
||||||
|
static sanitizeNode(root) {
|
||||||
|
const stack = [root];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
const nodes = Array.from(current.childNodes);
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const tagName = node.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (!this.ALLOWED_TAGS.includes(tagName)) {
|
||||||
|
// If tag is not allowed, replace it with its text content
|
||||||
|
const text = document.createTextNode(node.textContent);
|
||||||
|
node.parentNode.replaceChild(text, node);
|
||||||
|
} else {
|
||||||
|
// Sanitize attributes
|
||||||
|
const attrs = Array.from(node.attributes);
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const attrName = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Check if attribute is on whitelist or is a data- attribute
|
||||||
|
if (!this.ALLOWED_ATTRS.includes(attrName) && !attrName.startsWith('data-')) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for URLs
|
||||||
|
if (attrName === 'href' || attrName === 'src') {
|
||||||
|
const val = attr.value.trim().toLowerCase();
|
||||||
|
if (this.DISALLOWED_URL_SCHEMES.some(scheme => val.startsWith(scheme))) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
// Iframes: only allow YouTube embed URLs
|
||||||
|
if (attrName === 'src' && tagName === 'iframe') {
|
||||||
|
if (!val.startsWith('https://www.youtube.com/embed/')) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for style (extremely restrictive)
|
||||||
|
if (attrName === 'style') {
|
||||||
|
// Only allow specific safe CSS properties
|
||||||
|
const safeStyles = ['color', 'background', 'background-color', 'background-image', 'font-weight', 'font-style', 'text-decoration', 'vertical-align', 'height', 'width', 'display', 'fill', 'stroke', 'stroke-width', 'opacity', 'cursor', 'border', 'border-radius', 'padding', 'padding-left', 'padding-right', 'padding-top', 'padding-bottom', 'margin', 'margin-left', 'margin-right', 'margin-top', 'margin-bottom', 'position', 'top', 'left', 'right', 'bottom', 'z-index', 'flex', 'flex-direction', 'justify-content', 'align-items', 'gap'];
|
||||||
|
const styleParts = attr.value.split(';').filter(p => p.trim().length > 0);
|
||||||
|
const cleanStyles = styleParts.filter(part => {
|
||||||
|
const prop = part.split(':')[0].trim().toLowerCase();
|
||||||
|
return safeStyles.includes(prop);
|
||||||
|
});
|
||||||
|
if (cleanStyles.length > 0) {
|
||||||
|
node.setAttribute(attr.name, cleanStyles.join('; '));
|
||||||
|
} else {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Push to stack for iterative processing of children
|
||||||
|
stack.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global export
|
||||||
|
window.Sanitizer = Sanitizer;
|
||||||
2568
public/s/js/scroller.js
Normal file
1322
public/s/js/settings.js
Normal file
662
public/s/js/sidebar-activity.js
Normal file
@@ -0,0 +1,662 @@
|
|||||||
|
(function() {
|
||||||
|
let customEmojis = {};
|
||||||
|
let loading = false;
|
||||||
|
let loadingMore = false;
|
||||||
|
let currentPage = 1;
|
||||||
|
let hasMore = true;
|
||||||
|
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
||||||
|
// Shared cache for activity across AJAX loads
|
||||||
|
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
||||||
|
|
||||||
|
const loadEmojis = async () => {
|
||||||
|
if (Object.keys(customEmojis).length > 0) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/emojis');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
data.emojis.forEach(e => {
|
||||||
|
customEmojis[e.name] = e.url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sidebar Activity: Failed to load emojis", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmoji = (match, name) => {
|
||||||
|
if (customEmojis[name]) {
|
||||||
|
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (unsafe) => {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = unsafe;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCommentContent = (content) => {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Anti-recursion / Performance safeguard for extremely long comments
|
||||||
|
if (content.length > 50000) {
|
||||||
|
console.warn('Sidebar Activity: Comment too long, skipping markdown');
|
||||||
|
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0; padding: 0; background: none; border: none; font-size: inherit; color: inherit;">${escapeHtml(content)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof marked === 'undefined') {
|
||||||
|
return escapeHtml(content)
|
||||||
|
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract and protect code blocks (```...```) before escaping
|
||||||
|
const codeBlocks = [];
|
||||||
|
let processed = content.replace(/```([\s\S]*?)```/g, (match) => {
|
||||||
|
const placeholder = `BLOCKPORTALX${codeBlocks.length}X`;
|
||||||
|
codeBlocks.push(marked.parse(match));
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
|
||||||
|
let escaped = escapeHtml(processed)
|
||||||
|
.replace(/>/g, ">"); // Restore > for markdown markers
|
||||||
|
|
||||||
|
// Handle Image Embeds (Client-side)
|
||||||
|
const siteOrigin = window.location.origin;
|
||||||
|
const escapedSiteUrl = siteOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const allowedHosts = [escapedSiteUrl];
|
||||||
|
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||||
|
window.f0ckAllowedImages.forEach(h => {
|
||||||
|
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hostsRegexPart = allowedHosts.join('|');
|
||||||
|
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
||||||
|
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||||
|
const imageRegex = new RegExp(`(?<![\\(\\[])((?:https?:\\/\\/)?(?:${hostsRegexPart})(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
||||||
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||||
|
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.blockquote = function (quote) {
|
||||||
|
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||||
|
text = text.replace(/<p>|<\/p>/g, '');
|
||||||
|
return text.split('\n').map(line => {
|
||||||
|
if (!line.trim()) return '';
|
||||||
|
return `<span class="greentext">>${line}</span>`;
|
||||||
|
}).join('\n');
|
||||||
|
};
|
||||||
|
renderer.paragraph = function (text) {
|
||||||
|
return (typeof text === 'string') ? text : (text.text || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.link = function (href, title, text) {
|
||||||
|
if (typeof href === 'object' && href !== null) {
|
||||||
|
title = href.title; text = href.text || text; href = href.href;
|
||||||
|
}
|
||||||
|
if (!href) return text || '';
|
||||||
|
const titleAttr = title ? ` title="${title}"` : '';
|
||||||
|
const isExternal = href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
|
||||||
|
let isSameSite = false;
|
||||||
|
|
||||||
|
// Marked greedy autolink fix for spoiler brackets appended to URLs
|
||||||
|
let extraSuffix = '';
|
||||||
|
const lowerHref = href.toLowerCase();
|
||||||
|
if (lowerHref.endsWith('%5b/spoiler%5d')) {
|
||||||
|
href = href.substring(0, href.length - 14);
|
||||||
|
text = text.replace(/\[\/spoiler\]/ig, '');
|
||||||
|
extraSuffix = '[/spoiler]';
|
||||||
|
} else if (lowerHref.endsWith('[/spoiler]')) {
|
||||||
|
href = href.substring(0, href.length - 10);
|
||||||
|
text = text.replace(/\[\/spoiler\]/ig, '');
|
||||||
|
extraSuffix = '[/spoiler]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith(siteOrigin) || (href.startsWith('/') && !href.startsWith('//'))) {
|
||||||
|
isSameSite = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
|
||||||
|
const urlObj = new URL(urlToParse, siteOrigin);
|
||||||
|
isSameSite = (urlObj.hostname === window.location.hostname);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayText = text;
|
||||||
|
if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) {
|
||||||
|
try {
|
||||||
|
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
|
||||||
|
const url = new URL(urlToParse.startsWith('http') ? urlToParse : siteOrigin + (urlToParse.startsWith('/') ? '' : '/') + urlToParse);
|
||||||
|
displayText = url.pathname + url.search + url.hash;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMention = href.startsWith('/user/') && text.startsWith('@');
|
||||||
|
if (isExternal && !isSameSite) {
|
||||||
|
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${displayText}<i class="fa-solid fa-arrow-up-right-from-square external-link-icon"></i></a>${extraSuffix}`;
|
||||||
|
}
|
||||||
|
return `<a href="${href}"${titleAttr}${isMention ? ' class="mention"' : ''}>${displayText}</a>${extraSuffix}`;
|
||||||
|
};
|
||||||
|
renderer.image = function (href, title, text) {
|
||||||
|
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||||
|
const alt = text || '';
|
||||||
|
const ttl = title ? ` title="${title}"` : '';
|
||||||
|
return `<img class="sidebar-comment-img" src="${src}" alt="${alt}"${ttl} loading="lazy">`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||||
|
const renderedLines = escaped.split('\n').map(line => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (trimmed.startsWith('>')) {
|
||||||
|
// Manual greentext handling — apply emoji if the user preference allows it
|
||||||
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
const rendered = quoteEmojis
|
||||||
|
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||||
|
: quoteContent;
|
||||||
|
return `<span class="greentext">>${rendered}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-line limit to prevent marked.parse recursion on single giant lines
|
||||||
|
if (line.length > 10000) return line;
|
||||||
|
|
||||||
|
if (!line.trim()) return ' ';
|
||||||
|
|
||||||
|
// Perform replacements on the single line
|
||||||
|
let processedLine = line;
|
||||||
|
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
||||||
|
const user = g1 || g2;
|
||||||
|
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||||
|
});
|
||||||
|
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||||
|
let fullUrl = url;
|
||||||
|
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||||
|
fullUrl = '//' + url;
|
||||||
|
}
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use marked for each line individually
|
||||||
|
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||||
|
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
|
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
});
|
||||||
|
|
||||||
|
let md = renderedLines.join('\n');
|
||||||
|
|
||||||
|
// YouTube label replacement: show icon + labeled link
|
||||||
|
md = md.replace(
|
||||||
|
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||||
|
(match) => {
|
||||||
|
const hrefMatch = match.match(/href="([^"]+)"/i);
|
||||||
|
const href = hrefMatch ? hrefMatch[1] : '#';
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-brands fa-youtube"></i></a>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build regex for allowed media hosters (video/audio)
|
||||||
|
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const mediaHosts = [escapedSiteHost];
|
||||||
|
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||||
|
window.f0ckAllowedImages.forEach(h => {
|
||||||
|
const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
mediaHosts.push(`(?:[a-z0-9-]+\\.)*${escaped}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const mediaHostsPart = mediaHosts.join('|');
|
||||||
|
const mediaDomainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${mediaHostsPart})|(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||||
|
|
||||||
|
// Video label replacement: instead of embedding, show a link
|
||||||
|
const videoEmbedRegex = new RegExp(`<a\\s[^>]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
||||||
|
md = md.replace(videoEmbedRegex, (match, url) => {
|
||||||
|
let isSameSite = false;
|
||||||
|
try {
|
||||||
|
const urlToParse = url.startsWith('//') ? window.location.protocol + url : url;
|
||||||
|
const urlObj = new URL(urlToParse, siteOrigin);
|
||||||
|
isSameSite = (urlObj.hostname === window.location.hostname);
|
||||||
|
} catch(e) {
|
||||||
|
isSameSite = url.startsWith(siteOrigin) || (url.startsWith('/') && !url.startsWith('//'));
|
||||||
|
}
|
||||||
|
const label = isSameSite ? 'Video Link' : 'External Video Link';
|
||||||
|
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-solid fa-film"></i> ${label} »</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
||||||
|
let prevMd;
|
||||||
|
let iterations = 0;
|
||||||
|
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
|
||||||
|
do {
|
||||||
|
prevMd = md;
|
||||||
|
md = md.replace(spoilerRegex, (match, content) => {
|
||||||
|
return `<span class="spoiler">${content}</span>`;
|
||||||
|
});
|
||||||
|
iterations++;
|
||||||
|
} while (md !== prevMd && iterations < 10);
|
||||||
|
|
||||||
|
// Handle blur [blur]text[/blur] (supports nesting)
|
||||||
|
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
|
||||||
|
iterations = 0;
|
||||||
|
do {
|
||||||
|
prevMd = md;
|
||||||
|
md = md.replace(blurRegex, (match, content) => {
|
||||||
|
return `<span class="blur-text">${content}</span>`;
|
||||||
|
});
|
||||||
|
iterations++;
|
||||||
|
} while (md !== prevMd && iterations < 10);
|
||||||
|
|
||||||
|
// Restore protected code blocks
|
||||||
|
md = md.replace(/BLOCKPORTALX(\d+)X/g, (match, index) => {
|
||||||
|
return codeBlocks[index] || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return md;
|
||||||
|
} catch (e) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_MAX_CHARS = 200;
|
||||||
|
const SIDEBAR_MAX_EMOJIS = 12;
|
||||||
|
|
||||||
|
const renderActivityItem = (c) => {
|
||||||
|
const rawContent = c.content || c.body || '';
|
||||||
|
const displayContent = renderCommentContent(rawContent);
|
||||||
|
|
||||||
|
// Build avatar URL — same priority as the rest of the app
|
||||||
|
let avatarSrc = '/a/default.png';
|
||||||
|
if (c.avatar_file) {
|
||||||
|
avatarSrc = `/a/${c.avatar_file}`;
|
||||||
|
} else if (c.avatar) {
|
||||||
|
avatarSrc = `/t/${c.avatar}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeStr = c.created_at
|
||||||
|
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
||||||
|
: (c.timeago || 'just now');
|
||||||
|
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
|
||||||
|
|
||||||
|
let itemPreview = '';
|
||||||
|
if (c.item_id) {
|
||||||
|
let mediaHtml = '';
|
||||||
|
mediaHtml = `<img src="/t/${c.item_id}.webp" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
|
||||||
|
|
||||||
|
itemPreview = `
|
||||||
|
<div class="item-preview">
|
||||||
|
<a href="/${c.item_id}">${mediaHtml}</a>
|
||||||
|
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment" id="sc${c.id}">
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-header-left">
|
||||||
|
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
||||||
|
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
||||||
|
</div>
|
||||||
|
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content" style="font-size: 0.85em; line-height: 1.3;"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||||
|
${itemPreview}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOverflow = () => {
|
||||||
|
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
|
||||||
|
const container = inner.parentElement;
|
||||||
|
const btn = container.querySelector('.read-more-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
// If expanded, always show "see less"
|
||||||
|
if (container.classList.contains('expanded')) {
|
||||||
|
btn.style.display = 'block';
|
||||||
|
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
|
||||||
|
btn.style.display = 'block';
|
||||||
|
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
||||||
|
container.classList.add('has-overflow');
|
||||||
|
} else {
|
||||||
|
btn.style.display = 'none';
|
||||||
|
container.classList.remove('has-overflow');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event delegation — read-more expands, see-less collapses
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// Read more / See less
|
||||||
|
const readBtn = e.target.closest('.read-more-btn');
|
||||||
|
if (readBtn) {
|
||||||
|
const contentDiv = readBtn.closest('.comment-content');
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.classList.toggle('expanded');
|
||||||
|
checkOverflow(); // Re-sync button text and visibility
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderFromCache = () => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
if (!container || window._sidebarActivityCache.length === 0) return false;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
window._sidebarActivityCache.forEach(c => {
|
||||||
|
html += renderActivityItem(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (window.Sanitizer) {
|
||||||
|
container.innerHTML = window.Sanitizer.clean(html);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
// Re-append IO sentinel so the scroll observer keeps working after re-renders
|
||||||
|
if (ioSentinel) {
|
||||||
|
container.appendChild(ioSentinel);
|
||||||
|
}
|
||||||
|
checkOverflow();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_PAGE_LIMIT = 50;
|
||||||
|
|
||||||
|
const loadActivity = async (silent = false) => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
if (!container || loading) return;
|
||||||
|
|
||||||
|
const hasCache = renderFromCache();
|
||||||
|
if (!hasCache && !silent) {
|
||||||
|
container.innerHTML = '<div class="loading">Loading activity...</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
currentPage = 1;
|
||||||
|
hasMore = true;
|
||||||
|
try {
|
||||||
|
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||||
|
const res = await fetch(`/activity?json=true&page=1&mode=${mode}`, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
|
window._sidebarActivityCache = data.comments.map(c => ({
|
||||||
|
...c,
|
||||||
|
body: c.content || c.body
|
||||||
|
}));
|
||||||
|
hasMore = data.hasMore === true;
|
||||||
|
renderFromCache();
|
||||||
|
// Also check after a delay to account for image/emoji loading shifts
|
||||||
|
setTimeout(checkOverflow, 500);
|
||||||
|
} else if (container.innerHTML.includes('loading')) {
|
||||||
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
|
||||||
|
hasMore = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sidebar Activity: Failed to load activity", e);
|
||||||
|
if (container.innerHTML.includes('loading')) {
|
||||||
|
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
|
||||||
|
}
|
||||||
|
hasMore = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreActivity = async () => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
if (!container || loading || loadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
loadingMore = true;
|
||||||
|
const nextPage = currentPage + 1;
|
||||||
|
|
||||||
|
// Show a subtle loading row at the bottom
|
||||||
|
const sentinel = document.createElement('div');
|
||||||
|
sentinel.id = 'sidebar-load-more-sentinel';
|
||||||
|
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
||||||
|
sentinel.textContent = 'Loading…';
|
||||||
|
container.appendChild(sentinel);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||||
|
const res = await fetch(`/activity?json=true&page=${nextPage}&mode=${mode}`, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Remove sentinel before inserting real content
|
||||||
|
const s = document.getElementById('sidebar-load-more-sentinel');
|
||||||
|
if (s) s.remove();
|
||||||
|
|
||||||
|
if (data.success && data.comments && data.comments.length > 0) {
|
||||||
|
currentPage = nextPage;
|
||||||
|
hasMore = data.hasMore === true;
|
||||||
|
|
||||||
|
// Append only comments not already in the cache
|
||||||
|
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
||||||
|
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
|
||||||
|
...c,
|
||||||
|
body: c.content || c.body
|
||||||
|
}));
|
||||||
|
|
||||||
|
window._sidebarActivityCache.push(...newComments);
|
||||||
|
|
||||||
|
// Append new items to DOM
|
||||||
|
let html = '';
|
||||||
|
newComments.forEach(c => { html += renderActivityItem(c); });
|
||||||
|
if (html) {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
if (window.Sanitizer) {
|
||||||
|
temp.innerHTML = window.Sanitizer.clean(html);
|
||||||
|
} else {
|
||||||
|
temp.innerHTML = html;
|
||||||
|
}
|
||||||
|
while (temp.firstElementChild) {
|
||||||
|
container.appendChild(temp.firstElementChild);
|
||||||
|
}
|
||||||
|
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
||||||
|
if (ioSentinel) container.appendChild(ioSentinel);
|
||||||
|
checkOverflow();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasMore = false;
|
||||||
|
// Show end-of-feed indicator
|
||||||
|
const end = document.createElement('div');
|
||||||
|
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
||||||
|
end.textContent = '─ end of activity ─';
|
||||||
|
container.appendChild(end);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Sidebar Activity: Failed to load more", e);
|
||||||
|
const s = document.getElementById('sidebar-load-more-sentinel');
|
||||||
|
if (s) s.remove();
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNewActivity = (data) => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
|
||||||
|
// 1. Deduplicate: check if this comment ID is already in the cache
|
||||||
|
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
|
||||||
|
console.log("Sidebar Activity: Duplicate comment ignored", data.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
|
||||||
|
const newItem = {
|
||||||
|
...data,
|
||||||
|
body: data.body || data.content,
|
||||||
|
timeago: (window.f0ckI18n && window.f0ckI18n.timeago_just_now) || 'just now'
|
||||||
|
};
|
||||||
|
window._sidebarActivityCache.unshift(newItem);
|
||||||
|
|
||||||
|
// Update DOM if visible
|
||||||
|
if (container) {
|
||||||
|
const html = renderActivityItem(newItem);
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
if (window.Sanitizer) {
|
||||||
|
temp.innerHTML = window.Sanitizer.clean(html);
|
||||||
|
} else {
|
||||||
|
temp.innerHTML = html;
|
||||||
|
}
|
||||||
|
const node = temp.firstElementChild;
|
||||||
|
if (node) {
|
||||||
|
node.classList.add('new-item-fade');
|
||||||
|
container.prepend(node);
|
||||||
|
checkOverflow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await loadEmojis();
|
||||||
|
loadActivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for live activity from f0ckm.js
|
||||||
|
document.addEventListener('f0ck:activityReceived', (e) => {
|
||||||
|
console.log("Sidebar Activity: Live update received", e.detail);
|
||||||
|
handleNewActivity(e.detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLiveEdit = (data) => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
|
||||||
|
// 1. Update cache
|
||||||
|
if (window._sidebarActivityCache) {
|
||||||
|
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
|
||||||
|
if (comment) {
|
||||||
|
comment.content = data.content;
|
||||||
|
comment.body = data.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update DOM if visible
|
||||||
|
if (container) {
|
||||||
|
const el = document.getElementById('sc' + data.comment_id);
|
||||||
|
if (el) {
|
||||||
|
const inner = el.querySelector('.comment-content-inner');
|
||||||
|
if (inner) {
|
||||||
|
inner.innerHTML = renderCommentContent(data.content);
|
||||||
|
el.classList.remove('new-item-fade');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('new-item-fade');
|
||||||
|
checkOverflow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('f0ck:comment_edited', (e) => {
|
||||||
|
console.log("Sidebar Activity: Live edit received", e.detail);
|
||||||
|
handleLiveEdit(e.detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||||
|
|
||||||
|
// Handle AJAX item loads
|
||||||
|
document.addEventListener('f0ck:contentLoaded', () => {
|
||||||
|
const currentMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||||
|
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
|
||||||
|
lastBoundMode = currentMode;
|
||||||
|
|
||||||
|
console.log("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
|
||||||
|
|
||||||
|
if (modeChanged) {
|
||||||
|
window._sidebarActivityCache = [];
|
||||||
|
currentPage = 1;
|
||||||
|
hasMore = true;
|
||||||
|
loadActivity(false); // Force reload with loading state
|
||||||
|
} else {
|
||||||
|
// Immediately render from cache to avoid flicker
|
||||||
|
renderFromCache();
|
||||||
|
// Background sync
|
||||||
|
loadActivity(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync sidebar and comments-list layout on initial page load (Legacy View Only)
|
||||||
|
if (typeof syncSidebarAndComments === 'function') {
|
||||||
|
syncSidebarAndComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
|
||||||
|
document.addEventListener('f0ck:modeChanged', (e) => {
|
||||||
|
console.log("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||||
|
lastBoundMode = e.detail.mode;
|
||||||
|
window._sidebarActivityCache = [];
|
||||||
|
currentPage = 1;
|
||||||
|
hasMore = true;
|
||||||
|
loadActivity(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the current user posts a comment, silently refresh sidebar to show it
|
||||||
|
document.addEventListener('f0ck:commentPosted', () => {
|
||||||
|
console.log("Sidebar Activity: Own comment posted, refreshing...");
|
||||||
|
loadActivity(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Infinite scroll: load older comments when scrolling near the bottom
|
||||||
|
const bindScrollListener = () => {
|
||||||
|
const container = document.getElementById('sidebar-activity-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Use IntersectionObserver if available (performant), fallback to scroll event
|
||||||
|
if (typeof IntersectionObserver !== 'undefined') {
|
||||||
|
// Create the sentinel once at module level so re-renders can re-append the same node
|
||||||
|
if (!ioSentinel) {
|
||||||
|
ioSentinel = document.createElement('div');
|
||||||
|
ioSentinel.id = 'sidebar-io-sentinel';
|
||||||
|
ioSentinel.style.height = '1px';
|
||||||
|
container.appendChild(ioSentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||||
|
loadMoreActivity();
|
||||||
|
}
|
||||||
|
}, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 });
|
||||||
|
|
||||||
|
observer.observe(ioSentinel);
|
||||||
|
} else {
|
||||||
|
container.addEventListener('scroll', () => {
|
||||||
|
if (loading || loadingMore || !hasMore) return;
|
||||||
|
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;
|
||||||
|
if (nearBottom) loadMoreActivity();
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
const _origInit = init;
|
||||||
|
const initWithScroll = async () => {
|
||||||
|
await _origInit();
|
||||||
|
bindScrollListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initWithScroll);
|
||||||
|
} else {
|
||||||
|
initWithScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live updates are handled via SSE (f0ck:activityReceived event)
|
||||||
|
})();
|
||||||
283
public/s/js/tag_autocomplete.js
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* TagAutocomplete — Custom mobile-friendly tag autocomplete dropdown
|
||||||
|
* Replaces native <datalist> which is unreliable on mobile Chrome.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* TagAutocomplete.open({
|
||||||
|
* postid: Number,
|
||||||
|
* existingTags: String[],
|
||||||
|
* anchorEl: Element, // the "add tag" link
|
||||||
|
* onSubmit: async (tag) => { ... return { success, tags } },
|
||||||
|
* renderTags: (tags) => void
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
window.TagAutocomplete = (() => {
|
||||||
|
let activeInstance = null;
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const MIN_QUERY_LEN = 1;
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (!activeInstance) return;
|
||||||
|
const { wrapper } = activeInstance;
|
||||||
|
if (wrapper && wrapper.parentElement) {
|
||||||
|
wrapper.parentElement.removeChild(wrapper);
|
||||||
|
}
|
||||||
|
activeInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(opts) {
|
||||||
|
const { postid, existingTags, anchorEl, onSubmit, renderTags } = opts;
|
||||||
|
|
||||||
|
// If already open, just focus the existing input
|
||||||
|
if (activeInstance && activeInstance.wrapper.parentElement) {
|
||||||
|
activeInstance.input.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Build DOM --
|
||||||
|
const wrapper = document.createElement('span');
|
||||||
|
wrapper.className = 'badge badge-light ml-2 tag-ac-wrapper';
|
||||||
|
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.style.display = 'inline';
|
||||||
|
form.setAttribute('autocomplete', 'off');
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.size = '10';
|
||||||
|
input.value = '';
|
||||||
|
input.setAttribute('autocomplete', 'off');
|
||||||
|
input.setAttribute('autocorrect', 'off');
|
||||||
|
input.setAttribute('autocapitalize', 'off');
|
||||||
|
input.setAttribute('spellcheck', 'false');
|
||||||
|
input.className = 'tag-ac-input';
|
||||||
|
input.placeholder = '';
|
||||||
|
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'tag-suggestions';
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
|
||||||
|
form.appendChild(input);
|
||||||
|
wrapper.appendChild(form);
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
|
||||||
|
// Insert after the anchor itself to put it right next to it.
|
||||||
|
anchorEl.insertAdjacentElement('afterend', wrapper);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
activeInstance = { wrapper, input, dropdown };
|
||||||
|
|
||||||
|
// Flag to prevent focusout from destroying dropdown while touching it
|
||||||
|
let dropdownTouching = false;
|
||||||
|
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
||||||
|
dropdown.addEventListener('touchend', () => {
|
||||||
|
dropdownTouching = false;
|
||||||
|
// Re-focus input so user can keep typing after scrolling
|
||||||
|
input.focus();
|
||||||
|
}, { passive: true });
|
||||||
|
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
||||||
|
|
||||||
|
// -- Debounced suggest --
|
||||||
|
let debounceTimer = null;
|
||||||
|
let lastQuery = '';
|
||||||
|
let highlightIndex = -1;
|
||||||
|
|
||||||
|
const updateHighlight = (items, newIndex) => {
|
||||||
|
// Remove old highlight
|
||||||
|
if (highlightIndex >= 0 && highlightIndex < items.length) {
|
||||||
|
items[highlightIndex].classList.remove('active');
|
||||||
|
}
|
||||||
|
highlightIndex = newIndex;
|
||||||
|
// Apply new highlight
|
||||||
|
if (highlightIndex >= 0 && highlightIndex < items.length) {
|
||||||
|
items[highlightIndex].classList.add('active');
|
||||||
|
items[highlightIndex].scrollIntoView({ block: 'nearest' });
|
||||||
|
// Update input to show highlighted tag
|
||||||
|
input.value = items[highlightIndex].querySelector('.tag-suggestion-name').textContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSuggestions = async (q) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/tags/suggest?q=' + encodeURIComponent(q));
|
||||||
|
const data = await res.json();
|
||||||
|
return data.suggestions || [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDropdown = (suggestions) => {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
highlightIndex = -1; // reset on new suggestions
|
||||||
|
if (!suggestions.length) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.forEach(entry => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'tag-suggestion-item';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'tag-suggestion-name';
|
||||||
|
name.textContent = entry.tag;
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'tag-suggestion-meta';
|
||||||
|
const scoreStr = typeof entry.score === 'number' ? entry.score.toFixed(2) : '0.00';
|
||||||
|
meta.textContent = `${entry.tagged || 0}× · ${scoreStr}`;
|
||||||
|
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
// Desktop: mousedown fires before focusout, preventing premature close
|
||||||
|
row.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
input.value = entry.tag;
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
form.requestSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile: distinguish tap from scroll using touch distance
|
||||||
|
let touchStartY = 0;
|
||||||
|
let touchStartX = 0;
|
||||||
|
row.addEventListener('touchstart', (e) => {
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
}, { passive: true });
|
||||||
|
row.addEventListener('touchend', (e) => {
|
||||||
|
const dx = Math.abs(e.changedTouches[0].clientX - touchStartX);
|
||||||
|
const dy = Math.abs(e.changedTouches[0].clientY - touchStartY);
|
||||||
|
if (dx < 10 && dy < 10) {
|
||||||
|
// Clean tap — select this suggestion
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
input.value = entry.tag;
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(row);
|
||||||
|
});
|
||||||
|
dropdown.style.display = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Events --
|
||||||
|
const onInput = () => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < MIN_QUERY_LEN) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
lastQuery = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (q === lastQuery) return;
|
||||||
|
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
debounceTimer = null;
|
||||||
|
lastQuery = q;
|
||||||
|
const suggestions = await fetchSuggestions(q);
|
||||||
|
// Only render if input hasn't changed while we awaited
|
||||||
|
if (input.value.trim() === q) {
|
||||||
|
renderDropdown(suggestions);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('input', onInput);
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = dropdown.querySelectorAll('.tag-suggestion-item');
|
||||||
|
if (!items.length || dropdown.style.display === 'none') return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = highlightIndex < items.length - 1 ? highlightIndex + 1 : 0;
|
||||||
|
updateHighlight(items, next);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = highlightIndex > 0 ? highlightIndex - 1 : items.length - 1;
|
||||||
|
updateHighlight(items, prev);
|
||||||
|
} else if (e.key === 'Enter' && highlightIndex >= 0) {
|
||||||
|
// Enter with a highlighted item — submit that tag
|
||||||
|
e.preventDefault();
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const tag = input.value.trim();
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(tag)) {
|
||||||
|
window.flashMessage('Post that in the comments', 3000, 'error');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingTags.includes(tag)) {
|
||||||
|
window.flashMessage('Tag already exists', 3000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await onSubmit(tag);
|
||||||
|
if (!res.success) {
|
||||||
|
window.flashMessage(res.msg || 'Error adding tag', 3000, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTags(res.tags, tag);
|
||||||
|
destroy();
|
||||||
|
// Re-open for adding more tags
|
||||||
|
open(opts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close when clicking/tapping outside
|
||||||
|
const onDocClick = (e) => {
|
||||||
|
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('touchstart', onDocClick);
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Delay attaching to avoid capturing the opening click
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('touchstart', onDocClick, { passive: true });
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Click on the wrapper area should refocus the input
|
||||||
|
wrapper.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target !== input) {
|
||||||
|
e.preventDefault(); // prevent blur
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('focusout', () => {
|
||||||
|
// Delay to allow suggestion tap/scroll to complete first
|
||||||
|
setTimeout(() => {
|
||||||
|
if (dropdownTouching) return; // user is interacting with dropdown
|
||||||
|
// Don't close if focus is still within the wrapper
|
||||||
|
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
||||||
|
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open, destroy };
|
||||||
|
})();
|
||||||
64
public/s/js/theme.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const Cookie = {
|
||||||
|
get: name => {
|
||||||
|
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||||
|
if (c) return decodeURIComponent(c);
|
||||||
|
},
|
||||||
|
set: (name, value, opts = {}) => {
|
||||||
|
if (opts.days) {
|
||||||
|
opts['max-age'] = opts.days * 60 * 60 * 24;
|
||||||
|
delete opts.days;
|
||||||
|
}
|
||||||
|
opts.SameSite = 'Strict';
|
||||||
|
opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, '');
|
||||||
|
document.cookie = name + '=' + encodeURIComponent(value) + opts;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||||
|
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||||
|
|
||||||
|
const setTheme = (theme) => {
|
||||||
|
if (!themes.includes(theme)) theme = defaultTheme;
|
||||||
|
document.documentElement.setAttribute('theme', theme);
|
||||||
|
Cookie.set('theme', theme, { path: '/', days: 360 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const currentTheme = document.documentElement.getAttribute('theme') || Cookie.get('theme') || defaultTheme;
|
||||||
|
let i = themes.indexOf(currentTheme);
|
||||||
|
if (i === -1 || ++i >= themes.length) i = 0;
|
||||||
|
setTheme(themes[i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load — sync cookie → document attribute
|
||||||
|
const acttheme = Cookie.get('theme') || defaultTheme;
|
||||||
|
if (!themes.includes(acttheme) || acttheme !== document.documentElement.getAttribute('theme')) {
|
||||||
|
setTheme(acttheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||||
|
if (e.key === 't') {
|
||||||
|
e.preventDefault();
|
||||||
|
cycleTheme();
|
||||||
|
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
|
||||||
|
// Use scroller toast if available, otherwise site-wide flashMessage
|
||||||
|
if (typeof window._scrollerThemeToast === 'function') {
|
||||||
|
window._scrollerThemeToast(newTheme);
|
||||||
|
} else if (typeof window.flashMessage === 'function') {
|
||||||
|
window.flashMessage(`Theme: ${newTheme}`, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (e.target.id === 'shortcut-theme' || e.target.closest('#shortcut-theme')) {
|
||||||
|
cycleTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose globally so other scripts (e.g. scroller.js) can call cycle/set
|
||||||
|
window.f0ckCycleTheme = cycleTheme;
|
||||||
|
window.f0ckSetTheme = setTheme;
|
||||||
|
})();
|
||||||
147
public/s/js/upload-common.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* common upload logic for f0ck
|
||||||
|
* shared between /upload page and global drag-and-drop modal
|
||||||
|
*/
|
||||||
|
window.F0ckUpload = class {
|
||||||
|
constructor(options) {
|
||||||
|
this.form = options.form;
|
||||||
|
this.config = options.config || {};
|
||||||
|
this.onProgress = options.onProgress || (() => {});
|
||||||
|
this.onComplete = options.onComplete || (() => {});
|
||||||
|
this.onError = options.onError || (() => {});
|
||||||
|
this.onStatusChange = options.onStatusChange || (() => {});
|
||||||
|
|
||||||
|
this.selectedFile = null;
|
||||||
|
this.tags = [];
|
||||||
|
this.minTags = options.minTags || 3;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.form) return;
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Tag input handling (if exists in this form instance)
|
||||||
|
const tagInput = this.form.querySelector('.tag-input');
|
||||||
|
if (tagInput) {
|
||||||
|
tagInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addTag(tagInput.value);
|
||||||
|
tagInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
validateFile(file) {
|
||||||
|
if (!file) return false;
|
||||||
|
const allowedMimes = this.config.allowedMimes || [];
|
||||||
|
if (allowedMimes.length > 0 && !allowedMimes.includes(file.type)) {
|
||||||
|
return { error: `File type ${file.type} is not allowed.` };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
addTag(tagName) {
|
||||||
|
tagName = tagName.trim();
|
||||||
|
if (!tagName || this.tags.some(t => t.toLowerCase() === tagName.toLowerCase())) return;
|
||||||
|
if (['sfw', 'nsfw', 'nsfl'].includes(tagName.toLowerCase())) return;
|
||||||
|
|
||||||
|
this.tags.push(tagName);
|
||||||
|
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTag(tagName) {
|
||||||
|
this.tags = this.tags.filter(t => t !== tagName);
|
||||||
|
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTags() {
|
||||||
|
this.tags = [];
|
||||||
|
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
setFile(file) {
|
||||||
|
const validation = this.validateFile(file);
|
||||||
|
if (validation.error) {
|
||||||
|
this.onError(validation.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.selectedFile = file;
|
||||||
|
this.onStatusChange({ type: 'file_selected', file: file });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload() {
|
||||||
|
if (!this.selectedFile) {
|
||||||
|
this.onError('No file selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rating = this.form.querySelector('input[name="rating"]:checked');
|
||||||
|
if (!rating) {
|
||||||
|
this.onError('Please select a rating');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tags.length < this.minTags) {
|
||||||
|
this.onError(`At least ${this.minTags} tags are required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', this.selectedFile);
|
||||||
|
formData.append('rating', rating.value);
|
||||||
|
formData.append('tags', this.tags.join(','));
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percent = Math.round((e.loaded / e.total) * 100);
|
||||||
|
this.onProgress(percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
try {
|
||||||
|
const res = JSON.parse(xhr.responseText);
|
||||||
|
if (res.success) {
|
||||||
|
this.onComplete(res);
|
||||||
|
resolve(res);
|
||||||
|
} else {
|
||||||
|
this.onError(res.msg, res);
|
||||||
|
reject(res);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.onError('Upload failed. Server returned invalid response.');
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = (err) => {
|
||||||
|
this.onError('Network error occurred during upload.');
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.open('POST', '/api/v2/upload');
|
||||||
|
xhr.setRequestHeader('X-CSRF-Token', window.f0ckSession?.csrf_token || '');
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
1331
public/s/js/upload.js
Normal file
219
public/s/js/user.js
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
(async () => {
|
||||||
|
// Helper to get dynamic context from the DOM
|
||||||
|
const getContext = () => {
|
||||||
|
const idLink = document.querySelector("a.id-link");
|
||||||
|
if (!idLink) return null;
|
||||||
|
const tagsContainer = document.querySelector("#tags");
|
||||||
|
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||||
|
return {
|
||||||
|
postid: +idLink.innerText,
|
||||||
|
poster: document.querySelector("a#a_username")?.innerText,
|
||||||
|
tags: [...inner.querySelectorAll(".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",
|
||||||
|
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||||
|
},
|
||||||
|
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, highlightTag = null) => {
|
||||||
|
const tagsContainer = document.querySelector("#tags");
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||||
|
|
||||||
|
// Only remove existing dynamically generated tags
|
||||||
|
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||||
|
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
||||||
|
if (!tag.querySelector('#a_addtag') && !tag.querySelector('#a_toggle') && !tag.classList.contains('tag-ac-wrapper')) {
|
||||||
|
tag.parentElement.removeChild(tag);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_tags.reverse().forEach(tag => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = `/tag/${tag.normalized}`;
|
||||||
|
a.style = "color: inherit !important";
|
||||||
|
a.textContent = tag.tag;
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.classList.add("badge", "mr-2");
|
||||||
|
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||||
|
span.classList.add('new-tag-glow');
|
||||||
|
}
|
||||||
|
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||||
|
|
||||||
|
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||||
|
|
||||||
|
span.insertAdjacentElement("beforeend", a);
|
||||||
|
|
||||||
|
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
|
||||||
|
const space = document.createTextNode('\u00A0'); //
|
||||||
|
span.appendChild(space);
|
||||||
|
|
||||||
|
const del = document.createElement("a");
|
||||||
|
del.className = "removetag admin-deltag";
|
||||||
|
del.href = "javascript:void(0)";
|
||||||
|
del.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||||
|
span.insertAdjacentElement("beforeend", del);
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.insertAdjacentElement("afterbegin", span);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle show more/less toggle visibility and count
|
||||||
|
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||||
|
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||||
|
|
||||||
|
let toggle = tagsContainer.querySelector(".show-tags-toggle");
|
||||||
|
|
||||||
|
if (realTags.length > 10) {
|
||||||
|
if (!toggle) {
|
||||||
|
toggle = document.createElement("a");
|
||||||
|
toggle.href = "#";
|
||||||
|
toggle.className = "show-tags-toggle";
|
||||||
|
tagsContainer.appendChild(toggle);
|
||||||
|
}
|
||||||
|
const hiddenCount = realTags.length - 10;
|
||||||
|
toggle.dataset.count = hiddenCount;
|
||||||
|
|
||||||
|
// Auto-expand when rendering new tags (e.g. after adding one) as requested
|
||||||
|
tagsContainer.classList.add('tags-expanded');
|
||||||
|
toggle.textContent = "show less";
|
||||||
|
|
||||||
|
} else if (toggle) {
|
||||||
|
toggle.remove();
|
||||||
|
tagsContainer.classList.remove('tags-expanded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.renderTags = renderTags;
|
||||||
|
|
||||||
|
const addtagClick = (e) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid, tags } = ctx;
|
||||||
|
const anchor = document.querySelector("a#a_addtag");
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
TagAutocomplete.open({
|
||||||
|
postid,
|
||||||
|
existingTags: tags,
|
||||||
|
anchorEl: anchor,
|
||||||
|
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||||
|
renderTags
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEvent = async (e) => {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid } = ctx;
|
||||||
|
|
||||||
|
const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||||
|
})).json();
|
||||||
|
|
||||||
|
renderTags(res.tags);
|
||||||
|
const isNsfw = res.tags.some(t => t.id == 2);
|
||||||
|
const isUntagged = res.tags.length === 0;
|
||||||
|
const toggleBtn = document.querySelector('button#a_toggle');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged);
|
||||||
|
toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged);
|
||||||
|
toggleBtn.classList.toggle('is-untagged', isUntagged);
|
||||||
|
const labels = { true: 'NSFW', false: 'SFW' };
|
||||||
|
toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavEvent = async (e) => {
|
||||||
|
// e is the click event or undefined
|
||||||
|
const ctx = getContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const { postid } = ctx;
|
||||||
|
|
||||||
|
// Read state BEFORE the API call so we know which direction to toggle
|
||||||
|
const favoBtn = document.querySelector("#a_favo");
|
||||||
|
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||||
|
|
||||||
|
const res = await post('/api/v2/togglefav', {
|
||||||
|
postid: postid
|
||||||
|
});
|
||||||
|
if (res.success) {
|
||||||
|
// New state is the logical opposite of what it was before the API call
|
||||||
|
const isNowFav = !wasAlreadyFav;
|
||||||
|
|
||||||
|
if (favoBtn) {
|
||||||
|
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||||
|
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||||
|
}
|
||||||
|
|
||||||
|
// span#favs
|
||||||
|
const favcontainer = document.querySelector('#favs');
|
||||||
|
favcontainer.innerHTML = "";
|
||||||
|
if (res.favs.length > 0) {
|
||||||
|
res.favs.forEach(f => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/user/${f.user}`;
|
||||||
|
a.setAttribute('tooltip', f.display_name || f.user);
|
||||||
|
a.setAttribute('flow', 'up');
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||||
|
img.style.height = "32px";
|
||||||
|
img.style.width = "32px";
|
||||||
|
if (f.username_color) img.style.borderColor = f.username_color;
|
||||||
|
|
||||||
|
a.appendChild(img);
|
||||||
|
favcontainer.appendChild(a);
|
||||||
|
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||||
|
});
|
||||||
|
favcontainer.hidden = false;
|
||||||
|
} else {
|
||||||
|
favcontainer.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||||
|
if (navigator.vibrate) navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// lul
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event Delegation
|
||||||
|
document.addEventListener("click", e => {
|
||||||
|
if (document.querySelector('script[src*="admin.js"]')) return;
|
||||||
|
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
||||||
|
if (target.closest("a#a_addtag")) {
|
||||||
|
addtagClick(e);
|
||||||
|
} else if (target.closest("#a_favo")) {
|
||||||
|
toggleFavEvent(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
})();
|
||||||
339
public/s/js/user_comments.js
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
class UserCommentSystem {
|
||||||
|
constructor() {
|
||||||
|
this.container = document.getElementById('user-comments-container');
|
||||||
|
this.username = this.container ? this.container.dataset.user : null;
|
||||||
|
this.page = 1;
|
||||||
|
this.loading = false;
|
||||||
|
this.finished = false;
|
||||||
|
this.userColor = null;
|
||||||
|
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||||
|
|
||||||
|
this.icons = {
|
||||||
|
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||||
|
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.username) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle live updates for edited comments
|
||||||
|
this.editListener = (e) => this.handleLiveEdit(e.detail);
|
||||||
|
window.addEventListener('f0ck:comment_edited', this.editListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLiveEdit(data) {
|
||||||
|
if (!this.container) return;
|
||||||
|
const el = document.getElementById('c' + data.comment_id);
|
||||||
|
if (el && this.container.contains(el)) {
|
||||||
|
const contentEl = el.querySelector('.comment-content');
|
||||||
|
if (contentEl) {
|
||||||
|
contentEl.innerHTML = this.renderCommentContent(data.content);
|
||||||
|
el.classList.remove('new-item-fade');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('new-item-fade');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.loadEmojis();
|
||||||
|
this.loadMore();
|
||||||
|
this.loadMore();
|
||||||
|
this.bindEvents();
|
||||||
|
this.startLiveTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEmojis() {
|
||||||
|
if (UserCommentSystem.emojiCache) {
|
||||||
|
this.customEmojis = UserCommentSystem.emojiCache;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/emojis');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.customEmojis = {};
|
||||||
|
data.emojis.forEach(e => {
|
||||||
|
this.customEmojis[e.name] = e.url;
|
||||||
|
});
|
||||||
|
UserCommentSystem.emojiCache = this.customEmojis;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load emojis", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (this.loading || this.finished) return;
|
||||||
|
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
|
||||||
|
this.loadMore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for mode changes
|
||||||
|
document.addEventListener('f0ck:modeChange', (e) => {
|
||||||
|
// Check if this instance is still active
|
||||||
|
if (!document.body.contains(this.container)) return;
|
||||||
|
|
||||||
|
console.log('Mode changed, reloading comments...');
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
this.page = 1;
|
||||||
|
this.finished = false;
|
||||||
|
this.loadMore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMore() {
|
||||||
|
if (this.loading || this.finished) return;
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
const loader = document.createElement('div');
|
||||||
|
loader.className = 'loader-placeholder';
|
||||||
|
loader.innerText = 'Loading...';
|
||||||
|
loader.style.textAlign = 'center';
|
||||||
|
loader.style.padding = '10px';
|
||||||
|
this.container.appendChild(loader);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = window.activeMode || 'sfw';
|
||||||
|
const res = await fetch('/user/' + encodeURIComponent(this.username) + '/comments?page=' + this.page + '&json=true&mode=' + mode);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
loader.remove();
|
||||||
|
|
||||||
|
if (json.success && json.comments.length > 0) {
|
||||||
|
if (json.user && json.user.username_color) {
|
||||||
|
this.userColor = json.user.username_color;
|
||||||
|
}
|
||||||
|
json.comments.forEach(c => {
|
||||||
|
console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
|
||||||
|
const html = this.renderComment(c);
|
||||||
|
this.container.insertAdjacentHTML('beforeend', html);
|
||||||
|
});
|
||||||
|
this.page++;
|
||||||
|
} else {
|
||||||
|
this.finished = true;
|
||||||
|
if (this.page === 1 && (!json.comments || json.comments.length === 0)) {
|
||||||
|
this.container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No comments found.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
loader.remove();
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEmoji(match, name) {
|
||||||
|
if (this.customEmojis && this.customEmojis[name]) {
|
||||||
|
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCommentContent(content) {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Anti-recursion / Performance safeguard for extremely long comments
|
||||||
|
if (content.length > 50000) {
|
||||||
|
console.warn('UserComments: Comment too long, skipping markdown');
|
||||||
|
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0; padding: 0; background: none; border: none; font-size: inherit; color: inherit;">${this.escapeHtml(content)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof marked === 'undefined') {
|
||||||
|
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Initial escaping using native method. Restore > for markdown markers.
|
||||||
|
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||||
|
|
||||||
|
// 2. Mentions
|
||||||
|
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
||||||
|
|
||||||
|
const siteOrigin = window.location.origin;
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.blockquote = function (quote) {
|
||||||
|
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||||
|
text = text.replace(/<p>|<\/p>/g, '');
|
||||||
|
return text.split('\n').map(line => {
|
||||||
|
if (!line.trim()) return '';
|
||||||
|
return `<span class="greentext">>${line}</span>`;
|
||||||
|
}).join('\n');
|
||||||
|
};
|
||||||
|
renderer.paragraph = function (text) {
|
||||||
|
return (typeof text === 'string') ? text : (text.text || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer.link = function (href, title, text) {
|
||||||
|
if (typeof href === 'object' && href !== null) {
|
||||||
|
title = href.title; text = href.text || text; href = href.href;
|
||||||
|
}
|
||||||
|
if (!href) return text || '';
|
||||||
|
const titleAttr = title ? ` title="${title}"` : '';
|
||||||
|
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
||||||
|
const isSameSite = href.startsWith(siteOrigin);
|
||||||
|
|
||||||
|
let displayText = text;
|
||||||
|
if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) {
|
||||||
|
try {
|
||||||
|
const url = new URL(href.startsWith('http') ? href : siteOrigin + (href.startsWith('/') ? '' : '/') + href);
|
||||||
|
displayText = url.pathname + url.search + url.hash;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExternal && !isSameSite) {
|
||||||
|
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${displayText}</a>`;
|
||||||
|
}
|
||||||
|
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||||
|
const renderedLines = escaped.split('\n').map(line => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (trimmed.startsWith('>')) {
|
||||||
|
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||||
|
return `<span class="greentext">>${quoteContent}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-line limit
|
||||||
|
if (line.length > 10000) return line;
|
||||||
|
if (!line.trim()) return ' ';
|
||||||
|
|
||||||
|
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||||
|
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||||
|
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||||
|
|
||||||
|
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||||
|
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||||
|
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||||
|
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = renderedLines.join('\n');
|
||||||
|
|
||||||
|
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
||||||
|
let prevMd;
|
||||||
|
let iterations = 0;
|
||||||
|
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
|
||||||
|
do {
|
||||||
|
prevMd = html;
|
||||||
|
html = html.replace(spoilerRegex, (match, content) => {
|
||||||
|
return `<span class="spoiler">${content}</span>`;
|
||||||
|
});
|
||||||
|
iterations++;
|
||||||
|
} while (html !== prevMd && iterations < 10);
|
||||||
|
|
||||||
|
// Handle blur [blur]text[/blur] (supports nesting)
|
||||||
|
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
|
||||||
|
iterations = 0;
|
||||||
|
do {
|
||||||
|
prevMd = html;
|
||||||
|
html = html.replace(blurRegex, (match, content) => {
|
||||||
|
return `<span class="blur-text">${content}</span>`;
|
||||||
|
});
|
||||||
|
iterations++;
|
||||||
|
} while (html !== prevMd && iterations < 10);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('UserCommentSystem Markdown Render Error:', e);
|
||||||
|
return this.escapeHtml(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComment(c) {
|
||||||
|
const timeAgo = this.timeAgo(c.created_at);
|
||||||
|
const fullDate = new Date(c.created_at).toISOString();
|
||||||
|
const content = this.renderCommentContent(c.content);
|
||||||
|
|
||||||
|
// Replicating the structure of comments.js but adapting for the list view
|
||||||
|
// We add a header indicating which item this comment belongs to
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment" id="c${c.id}">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<a href="/${c.item_id}">
|
||||||
|
<img src="/t/${c.item_id}.webp" alt="">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-header-left">
|
||||||
|
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
||||||
|
</div>
|
||||||
|
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">${content}</div>
|
||||||
|
<div class="comment-footer">
|
||||||
|
<div class="comment-footer-right">
|
||||||
|
<div class="comment-actions">
|
||||||
|
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
startLiveTimestamps() {
|
||||||
|
// Update timestamps every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
const timestamps = this.container ? this.container.querySelectorAll('.comment-time.timeago') : [];
|
||||||
|
timestamps.forEach(el => {
|
||||||
|
const dateStr = el.getAttribute('tooltip');
|
||||||
|
if (dateStr) {
|
||||||
|
el.textContent = this.timeAgo(dateStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeAgo(date) {
|
||||||
|
if (window.f0ckTimeAgo) return window.f0ckTimeAgo(date);
|
||||||
|
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
|
||||||
|
if (seconds < 5) return 'just now';
|
||||||
|
const intervals = [
|
||||||
|
{ label: 'year', seconds: 31536000 },
|
||||||
|
{ label: 'month', seconds: 2592000 },
|
||||||
|
{ label: 'day', seconds: 86400 },
|
||||||
|
{ label: 'hour', seconds: 3600 },
|
||||||
|
{ label: 'minute', seconds: 60 },
|
||||||
|
{ label: 'second', seconds: 1 }
|
||||||
|
];
|
||||||
|
for (const interval of intervals) {
|
||||||
|
const count = Math.floor(seconds / interval.seconds);
|
||||||
|
if (count >= 1) {
|
||||||
|
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = unsafe;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initializer for AJAX and standard load
|
||||||
|
window.initUserComments = () => {
|
||||||
|
// Prevent multiple instances if already running on this container
|
||||||
|
if (document.getElementById('user-comments-container')) {
|
||||||
|
new UserCommentSystem();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.initUserComments();
|
||||||
|
});
|
||||||
548
public/s/js/v0ck.js
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
(function() {
|
||||||
|
const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
||||||
|
<div class="v0ck_progress">
|
||||||
|
<div class="v0ck_progress_buffered"></div>
|
||||||
|
<div class="v0ck_progress_filled"></div>
|
||||||
|
<div class="v0ck_seek_marker"></div>
|
||||||
|
</div>
|
||||||
|
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play">
|
||||||
|
<svg style="width: 20px; height: 20px;">
|
||||||
|
<use id="v0ck_svg_play" href="${svg}#play"></use>
|
||||||
|
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="v0ck_volume_group">
|
||||||
|
<button class="v0ck_player_button v0ck_volume">
|
||||||
|
<svg style="width: 20px; height: 20px;">
|
||||||
|
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
|
||||||
|
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||||
|
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" />
|
||||||
|
</div>
|
||||||
|
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button>
|
||||||
|
<span style="flex: 30"></span>
|
||||||
|
|
||||||
|
<div class="v0ck_settings_container">
|
||||||
|
<button class="v0ck_player_button v0ck_settings_btn" title="Settings">
|
||||||
|
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
|
||||||
|
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="v0ck_settings_menu v0ck_hidden">
|
||||||
|
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button>
|
||||||
|
<div class="v0ck_menu_item v0ck_bg_row">
|
||||||
|
<span class="v0ck_switch_label">Background</span>
|
||||||
|
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
|
||||||
|
</div>
|
||||||
|
<div class="v0ck_menu_item v0ck_bg_row">
|
||||||
|
<span class="v0ck_switch_label">Autonext</span>
|
||||||
|
<div id="toggleautoplay" class="v0ck_cool_switch" title="Toggle Autonext"></div>
|
||||||
|
</div>
|
||||||
|
<div class="v0ck_menu_item v0ck_bg_row">
|
||||||
|
<span class="v0ck_switch_label">Danmaku</span>
|
||||||
|
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
|
||||||
|
</div>
|
||||||
|
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen">
|
||||||
|
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
||||||
|
<div class="v0ck_overlay">
|
||||||
|
<svg style="width: 60px; height: 60px;">
|
||||||
|
<use href="${svg}#play"></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="v0ck_hud v0ck_hidden">
|
||||||
|
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
|
||||||
|
<div class="v0ck_hud_bar_container">
|
||||||
|
<div class="v0ck_hud_bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const isMobile = /Mobi/i.test(navigator.userAgent) || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||||
|
|
||||||
|
let mouseX = -1, mouseY = -1;
|
||||||
|
const updateHoverStates = () => {
|
||||||
|
// Block interaction if content warning is visible or on mobile
|
||||||
|
const cwModal = document.getElementById('content-warning-modal');
|
||||||
|
if ((cwModal && cwModal.style.display !== 'none') || isMobile) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.v0ck').forEach(p => {
|
||||||
|
const rect = p.getBoundingClientRect();
|
||||||
|
const isOver = mouseX >= rect.left && mouseX <= rect.right &&
|
||||||
|
mouseY >= rect.top && mouseY <= rect.bottom;
|
||||||
|
p.classList.toggle("v0ck_hover", isOver);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', e => {
|
||||||
|
mouseX = e.clientX;
|
||||||
|
mouseY = e.clientY;
|
||||||
|
updateHoverStates();
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateHoverStates, { passive: true });
|
||||||
|
window.addEventListener('wheel', updateHoverStates, { passive: true });
|
||||||
|
|
||||||
|
class v0ck {
|
||||||
|
constructor(elem) {
|
||||||
|
const tagName = elem.tagName.toLowerCase();
|
||||||
|
if (["video", "audio"].includes(tagName)) {
|
||||||
|
const parent = elem.parentElement;
|
||||||
|
if (parent.querySelector('.v0ck_player_controls')) {
|
||||||
|
console.log("[v0ck] Player controls already exist, skipping injection and init");
|
||||||
|
return elem; // Return the video element as the constructor result
|
||||||
|
} else {
|
||||||
|
parent.classList.add("v0ck", "paused");
|
||||||
|
elem.classList.add("v0ck_video", "viewer");
|
||||||
|
|
||||||
|
// Check if mouse is already inside the element synchronously to avoid transition flicker
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
if (mouseX >= rect.left && mouseX <= rect.right &&
|
||||||
|
mouseY >= rect.top && mouseY <= rect.bottom) {
|
||||||
|
parent.classList.add("v0ck_hover", "v0ck_no_transition");
|
||||||
|
// Remove no-transition after a frame
|
||||||
|
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
|
||||||
|
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
||||||
|
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use absolute path for reliable asset loading
|
||||||
|
const size = elem.getAttribute('data-size');
|
||||||
|
elem.insertAdjacentHTML("afterend", tpl_player(`/s/img/v0ck.svg`, size));
|
||||||
|
console.log("[v0ck] Player initialized for", tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
|
||||||
|
const player = document.querySelector('.v0ck');
|
||||||
|
player.style.backgroundImage = `url('${elem.getAttribute('poster')}')`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return console.error("nope");
|
||||||
|
return this.init(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
init(elem) {
|
||||||
|
const player = document.querySelector('.v0ck');
|
||||||
|
const video = elem;
|
||||||
|
video.removeAttribute('controls');
|
||||||
|
video.removeAttribute('autoplay');
|
||||||
|
video.addEventListener('contextmenu', e => {
|
||||||
|
if (isMobile) e.preventDefault(); // Block native download/options menu on mobile only
|
||||||
|
});
|
||||||
|
const progress = player.querySelector('.v0ck_progress');
|
||||||
|
const progressBar = player.querySelector('.v0ck_progress_filled');
|
||||||
|
const bufferBar = player.querySelector('.v0ck_progress_buffered');
|
||||||
|
const seekMarker = player.querySelector('.v0ck_seek_marker');
|
||||||
|
const loader = player.querySelector('.v0ck_loader');
|
||||||
|
const toggle = player.querySelector('.v0ck_toggle');
|
||||||
|
const skipButtons = player.querySelectorAll('.v0ck [data-skip]');
|
||||||
|
const ranges = player.querySelectorAll('.v0ck input[type="range"]');
|
||||||
|
const volumeSlider = player.querySelector('.v0ck input[type="range"][name="volume"]');
|
||||||
|
const fullscreen = player.querySelector('.v0ck_fs_btn');
|
||||||
|
const playtime = player.querySelector('.v0ck_playtime');
|
||||||
|
const overlay = player.querySelector('.v0ck_overlay');
|
||||||
|
const volumeButton = player.querySelector('.v0ck_volume');
|
||||||
|
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
|
||||||
|
|
||||||
|
const defaultVolume = 0.5;
|
||||||
|
let mousedown = false;
|
||||||
|
let _volume;
|
||||||
|
|
||||||
|
function handleVolumeButton(vol) {
|
||||||
|
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
|
||||||
|
switch (true) {
|
||||||
|
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
|
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
|
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
|
||||||
|
}
|
||||||
|
localStorage.setItem("volume", vol);
|
||||||
|
}
|
||||||
|
function togglePlay() {
|
||||||
|
return video[video.paused ? 'play' : 'pause']();
|
||||||
|
}
|
||||||
|
function updatePlayIcon() {
|
||||||
|
toggle.classList.toggle('playing');
|
||||||
|
player.classList.toggle('paused');
|
||||||
|
toggle.setAttribute('title', toggle.classList.contains('playing') ? 'Pause' : 'Play');
|
||||||
|
[...toggle.querySelectorAll('use')].forEach(icon => icon.classList.toggle('v0ck_hidden'));
|
||||||
|
}
|
||||||
|
function toggleMute(e) {
|
||||||
|
if (video.volume === 0)
|
||||||
|
video.volume = volumeSlider.value = _volume === 0 ? defaultVolume : _volume;
|
||||||
|
else {
|
||||||
|
_volume = video.volume;
|
||||||
|
video.volume = volumeSlider.value = 0;
|
||||||
|
}
|
||||||
|
handleVolumeButton(video.volume);
|
||||||
|
}
|
||||||
|
function skip() {
|
||||||
|
video.currentTime += +this.dataset.skip;
|
||||||
|
}
|
||||||
|
function handleRangeUpdate() {
|
||||||
|
video[this.name] = this.value;
|
||||||
|
_volume = video.volume;
|
||||||
|
handleVolumeButton(video.volume);
|
||||||
|
}
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const minutes = (~~(seconds / 60)).toString().padStart(2, "0");
|
||||||
|
seconds = (~~(seconds % 60)).toString().padStart(2, "0");
|
||||||
|
return minutes + ":" + seconds;
|
||||||
|
}
|
||||||
|
function handleProgress() {
|
||||||
|
const percent = (video.currentTime / video.duration) * 100;
|
||||||
|
progressBar.style.flexBasis = percent + '%';
|
||||||
|
playtime.innerText = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
|
||||||
|
|
||||||
|
if (video.buffered.length > 0) {
|
||||||
|
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||||
|
const duration = video.duration;
|
||||||
|
if (duration > 0) {
|
||||||
|
bufferBar.style.width = (bufferedEnd / duration) * 100 + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scrub(e) {
|
||||||
|
let x;
|
||||||
|
if (e.type.startsWith('touch')) {
|
||||||
|
const rect = progress.getBoundingClientRect();
|
||||||
|
x = e.touches[0].clientX - rect.left;
|
||||||
|
if (e.type === 'touchmove' && e.cancelable) e.preventDefault();
|
||||||
|
} else {
|
||||||
|
x = e.offsetX;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = progress.offsetWidth;
|
||||||
|
// Clamp x between 0 and width
|
||||||
|
x = Math.max(0, Math.min(x, width));
|
||||||
|
|
||||||
|
const scrubTime = (x / width) * video.duration;
|
||||||
|
if (!Number.isFinite(scrubTime)) return;
|
||||||
|
|
||||||
|
video.currentTime = scrubTime;
|
||||||
|
|
||||||
|
// Visual seek marker
|
||||||
|
seekMarker.style.left = `${(x / width) * 100}%`;
|
||||||
|
seekMarker.classList.remove('active');
|
||||||
|
void seekMarker.offsetWidth; // trigger reflow
|
||||||
|
seekMarker.classList.add('active');
|
||||||
|
}
|
||||||
|
function enterFullScreen() {
|
||||||
|
if (document.fullscreenElement) return;
|
||||||
|
const target = document.getElementById('main') || player;
|
||||||
|
if (/(iPad|iPhone|iPod)/gi.test(navigator.platform))
|
||||||
|
video.webkitEnterFullscreen();
|
||||||
|
else
|
||||||
|
target.requestFullscreen();
|
||||||
|
}
|
||||||
|
function toggleFullScreen(e) {
|
||||||
|
if (document.fullscreenElement) // exit fullscreen
|
||||||
|
document.exitFullscreen();
|
||||||
|
else { // request fullscreen
|
||||||
|
enterFullScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleFullScreenClasses() {
|
||||||
|
const fsElem = document.fullscreenElement;
|
||||||
|
const isThisPlayerFS = fsElem && (fsElem === player || fsElem.contains(player));
|
||||||
|
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
||||||
|
}
|
||||||
|
|
||||||
|
player.addEventListener('click', e => {
|
||||||
|
const path = e.path || (e.composedPath && e.composedPath());
|
||||||
|
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||||
|
if (!isControls) {
|
||||||
|
if (isMobile && !player.classList.contains('v0ck_hover')) {
|
||||||
|
player.classList.add('v0ck_hover');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
togglePlay(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toggle.addEventListener('click', togglePlay);
|
||||||
|
overlay.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
player.classList.add('v0ck_hover');
|
||||||
|
togglePlay();
|
||||||
|
});
|
||||||
|
video.addEventListener('play', updatePlayIcon);
|
||||||
|
|
||||||
|
// Robust initial overlay removal
|
||||||
|
const removeInitial = () => player.classList.remove('v0ck_initial');
|
||||||
|
video.addEventListener('play', removeInitial);
|
||||||
|
video.addEventListener('playing', removeInitial);
|
||||||
|
video.addEventListener('timeupdate', () => {
|
||||||
|
if (video.currentTime > 0.1 && !video.paused && !video.ended) removeInitial();
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('pause', updatePlayIcon);
|
||||||
|
video.addEventListener('timeupdate', handleProgress);
|
||||||
|
video.addEventListener('progress', handleProgress);
|
||||||
|
video.addEventListener('ended', () => {
|
||||||
|
if (localStorage.getItem('autoplay') === 'true') {
|
||||||
|
const nextBtn = document.getElementById('next');
|
||||||
|
if (nextBtn && nextBtn.href && !nextBtn.href.endsWith('#')) {
|
||||||
|
nextBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loader events
|
||||||
|
const showLoader = () => loader.classList.remove('v0ck_hidden');
|
||||||
|
const hideLoader = () => loader.classList.add('v0ck_hidden');
|
||||||
|
video.addEventListener('waiting', showLoader);
|
||||||
|
video.addEventListener('stalled', showLoader);
|
||||||
|
video.addEventListener('canplay', hideLoader);
|
||||||
|
video.addEventListener('playing', hideLoader);
|
||||||
|
video.addEventListener('seeked', hideLoader);
|
||||||
|
video.addEventListener('loadeddata', hideLoader);
|
||||||
|
volumeButton.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMute(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hud = player.querySelector('.v0ck_hud');
|
||||||
|
const hudBar = hud.querySelector('.v0ck_hud_bar');
|
||||||
|
const hudIcon = hud.querySelector('.v0ck_hud_icon');
|
||||||
|
let startX, startY, startVol, isRightSide, gestureType;
|
||||||
|
let hudTimer;
|
||||||
|
|
||||||
|
function showHUD(vol) {
|
||||||
|
hud.classList.remove('v0ck_hidden');
|
||||||
|
hudBar.style.width = `${vol * 100}%`;
|
||||||
|
|
||||||
|
// Update HUD icon based on volume
|
||||||
|
let icon = 'volume_full';
|
||||||
|
if (vol === 0) icon = 'volume_mute';
|
||||||
|
else if (vol <= 0.5) icon = 'volume_mid';
|
||||||
|
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
|
||||||
|
|
||||||
|
clearTimeout(hudTimer);
|
||||||
|
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
player.addEventListener('touchstart', e => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const rect = player.getBoundingClientRect();
|
||||||
|
const x = touch.clientX - rect.left;
|
||||||
|
isRightSide = x > rect.width / 2;
|
||||||
|
gestureType = 'none';
|
||||||
|
|
||||||
|
if (isRightSide) {
|
||||||
|
startX = touch.clientX;
|
||||||
|
startY = touch.clientY;
|
||||||
|
startVol = video.volume;
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
player.addEventListener('touchmove', e => {
|
||||||
|
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = Math.abs(touch.clientX - startX);
|
||||||
|
const dy = Math.abs(touch.clientY - startY);
|
||||||
|
|
||||||
|
// Identify gesture type if not yet locked
|
||||||
|
if (gestureType === 'none') {
|
||||||
|
if (dy > dx && dy > 5) {
|
||||||
|
gestureType = 'volume';
|
||||||
|
} else if (dx > dy && dx > 5) {
|
||||||
|
gestureType = 'other'; // Probably seeking or horizontal swipe
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return; // Too small movement to decide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gestureType === 'volume') {
|
||||||
|
const deltaY = startY - touch.clientY; // swipe up is positive
|
||||||
|
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
||||||
|
let newVol = startVol + (deltaY / sensitivity);
|
||||||
|
newVol = Math.max(0, Math.min(1, newVol));
|
||||||
|
|
||||||
|
video.volume = newVol; // Set directly for smoothness
|
||||||
|
volumeSlider.value = newVol; // Update visual slider
|
||||||
|
_volume = newVol;
|
||||||
|
handleVolumeButton(newVol);
|
||||||
|
showHUD(newVol);
|
||||||
|
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
skipButtons.forEach(button => button.addEventListener('click', skip));
|
||||||
|
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
||||||
|
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
||||||
|
progress.addEventListener('mousedown', scrub);
|
||||||
|
progress.addEventListener('touchstart', scrub, { passive: false });
|
||||||
|
progress.addEventListener('touchmove', scrub, { passive: false });
|
||||||
|
fullscreen.addEventListener('click', toggleFullScreen);
|
||||||
|
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
||||||
|
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
||||||
|
|
||||||
|
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
||||||
|
handleVolumeButton(video.volume);
|
||||||
|
|
||||||
|
// Attempt autoplay and show overlay if blocked
|
||||||
|
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
|
||||||
|
if (shouldAutoplay) {
|
||||||
|
const playPromise = togglePlay();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise.catch(() => {
|
||||||
|
player.classList.add('v0ck_initial');
|
||||||
|
});
|
||||||
|
} else if (video.paused) {
|
||||||
|
player.classList.add('v0ck_initial');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.classList.add('v0ck_initial');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings Menu Logic
|
||||||
|
const settingsBtn = player.querySelector('.v0ck_settings_btn');
|
||||||
|
const settingsMenu = player.querySelector('.v0ck_settings_menu');
|
||||||
|
const toggleBgSwitch = player.querySelector('#togglebg');
|
||||||
|
const downloadBtn = player.querySelector('#v0ck_download');
|
||||||
|
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = video.src;
|
||||||
|
a.download = video.src.split('/').pop();
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize switch state (defaults to ON if not explicitly 'false')
|
||||||
|
if (toggleBgSwitch && localStorage.getItem('background') !== 'false') {
|
||||||
|
toggleBgSwitch.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAutoplaySwitch = player.querySelector('#toggleautoplay');
|
||||||
|
if (toggleAutoplaySwitch && localStorage.getItem('autoplay') === 'true') {
|
||||||
|
toggleAutoplaySwitch.classList.add('active');
|
||||||
|
video.loop = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(settingsBtn && settingsMenu) {
|
||||||
|
settingsBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
settingsMenu.classList.toggle('v0ck_hidden');
|
||||||
|
if (settingsMenu.classList.contains('v0ck_hidden')) {
|
||||||
|
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu/panel when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const isFlashYankUI = e.target.closest('#flash-yank-ui');
|
||||||
|
const isInsidePlayer = player.contains(e.target);
|
||||||
|
|
||||||
|
if(!settingsMenu.contains(e.target) && !settingsBtn.contains(e.target) && !isFlashYankUI) {
|
||||||
|
if (!settingsMenu.classList.contains('v0ck_hidden')) {
|
||||||
|
settingsMenu.classList.add('v0ck_hidden');
|
||||||
|
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile && !isInsidePlayer && !isFlashYankUI) {
|
||||||
|
player.classList.remove('v0ck_hover');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent menu click from pausing video (only if clicked on non-button area)
|
||||||
|
settingsMenu.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('button') && !e.target.closest('.v0ck_bg_row')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visual toggle for background switch
|
||||||
|
if (toggleBgSwitch) {
|
||||||
|
const bgRow = toggleBgSwitch.closest('.v0ck_bg_row');
|
||||||
|
const handleBgToggle = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.toggleBackground) {
|
||||||
|
window.toggleBackground();
|
||||||
|
} else {
|
||||||
|
toggleBgSwitch.classList.toggle('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (bgRow) bgRow.addEventListener('click', handleBgToggle);
|
||||||
|
else toggleBgSwitch.addEventListener('click', handleBgToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleAutoplaySwitch) {
|
||||||
|
const autoplayRow = toggleAutoplaySwitch.closest('.v0ck_bg_row');
|
||||||
|
const handleAutoplayToggle = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.toggleAutoplay) {
|
||||||
|
window.toggleAutoplay();
|
||||||
|
} else {
|
||||||
|
toggleAutoplaySwitch.classList.toggle('active');
|
||||||
|
}
|
||||||
|
video.loop = !toggleAutoplaySwitch.classList.contains('active');
|
||||||
|
};
|
||||||
|
if (autoplayRow) autoplayRow.addEventListener('click', handleAutoplayToggle);
|
||||||
|
else toggleAutoplaySwitch.addEventListener('click', handleAutoplayToggle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danmaku toggle
|
||||||
|
const toggleDanmakuSwitch = player.querySelector('#toggledanmaku');
|
||||||
|
if (toggleDanmakuSwitch) {
|
||||||
|
// Sync initial state from localStorage vs site-wide config default
|
||||||
|
const configDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
|
||||||
|
? !!window.f0ckSession.enable_danmaku
|
||||||
|
: true;
|
||||||
|
|
||||||
|
const savedPref = localStorage.getItem('danmaku');
|
||||||
|
const isEnabled = (savedPref !== null) ? (savedPref !== 'false') : configDefault;
|
||||||
|
|
||||||
|
if (isEnabled) {
|
||||||
|
toggleDanmakuSwitch.classList.add('active');
|
||||||
|
}
|
||||||
|
const danmakuRow = toggleDanmakuSwitch.closest('.v0ck_bg_row');
|
||||||
|
const handleDanmakuToggle = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.danmakuInstance) {
|
||||||
|
window.danmakuInstance.toggle();
|
||||||
|
const on = window.danmakuInstance.isEnabled();
|
||||||
|
toggleDanmakuSwitch.classList.toggle('active', on);
|
||||||
|
if (window.flashMessage) window.flashMessage(on ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||||
|
} else {
|
||||||
|
toggleDanmakuSwitch.classList.toggle('active');
|
||||||
|
const newVal = toggleDanmakuSwitch.classList.contains('active');
|
||||||
|
localStorage.setItem('danmaku', newVal ? 'true' : 'false');
|
||||||
|
if (window.flashMessage) window.flashMessage(newVal ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (danmakuRow) danmakuRow.addEventListener('click', handleDanmakuToggle);
|
||||||
|
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.toggleFullScreen = toggleFullScreen;
|
||||||
|
this.enterFullScreen = enterFullScreen;
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.v0ck = v0ck;
|
||||||
|
})();
|
||||||
526
public/s/js/wordlist.js
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
/**
|
||||||
|
* wordlist.js — German recovery phrase wordlist
|
||||||
|
*
|
||||||
|
* 4096 common German words, lowercase, 4–10 characters.
|
||||||
|
* Sourced from: github.com/MarvinJWendt/wordlist-german
|
||||||
|
* Filtered to: lowercase letters only (including umlauts), 4–10 chars, deduplicated.
|
||||||
|
*
|
||||||
|
* Entropy: 12 words × log2(4096) ≈ 144 bits
|
||||||
|
*
|
||||||
|
* Consumed by messages.js as window.DM_WORDLIST
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
window.DM_WORDLIST = [
|
||||||
|
'aasgeiers','abbeiztet','abbildes','abblasend','abchecktet','abdackelte','abdämmst','abdanktet',
|
||||||
|
'abdeckband','abdruckten','abebben','abendkreis','abfallglas','abfange','abfederst','abfege',
|
||||||
|
'abflachten','abfliegt','abforderte','abgasarmen','abgebaut','abgehebelt','abgeklärt','abgemühtes',
|
||||||
|
'abgenickte','abgesparte','abgetautem','abgewandte','abgleiche','abgösse','abgrinst','abgusses',
|
||||||
|
'abhärtete','abhefte','abheilt','abheiltest','abholten','abhörern','abhörtet','abkapseln',
|
||||||
|
'abladender','abledern','ablegende','ablehnten','abliefern','abmärschen','abmeldende','abmolkt',
|
||||||
|
'abmühendem','abnähmest','abnehmerin','abnicker','abopreises','abplagt','abplatzten','abprallern',
|
||||||
|
'abpufferte','abpumpt','abräumers','abrechnest','abregnet','abrüstete','abrüttele','absackten',
|
||||||
|
'abschiebt','abschösset','absegele','absegelst','abseiftet','absetztet','absiedeln','absitzest',
|
||||||
|
'abspaltete','absparte','abspielten','abspreize','abspreizte','abstammst','abstellten','abstrahlt',
|
||||||
|
'abstruses','abtauschte','abtreibers','abtrudeln','abtsberg','abwerfen','abwrackend','abzahltest',
|
||||||
|
'abzapfende','abzäunte','abziehend','abzugsarm','abzuordnen','abzurufen','abzuwiegen','accras',
|
||||||
|
'acetate','acetats','acetyl','ächtetest','achthabens','achtmeter','ackerndem','adac',
|
||||||
|
'adaptec','additionen','adelberger','adelsclub','aderfigur','adle','adlerfarns','adlerzange',
|
||||||
|
'adlungen','adretterem','adriahäfen','aeroben','aerophonen','afdler','affenbrot','affenhirn',
|
||||||
|
'afterfeder','aglycone','ahnend','ähnliches','ahornbergs','akkuzellen','aktenstück','aktfigur',
|
||||||
|
'aktivist','aktivseins','aktivzins','aktuellste','akzeptor','alarich','alarmtönen','albertina',
|
||||||
|
'algenfeind','algier','alimente','alkaliurie','allicin','alpstein','alsdorf','altaisch',
|
||||||
|
'altangaben','altbadener','altberg','altbezirke','altkunden','altliga','altmaiers','altvordere',
|
||||||
|
'alukreuzen','alzeys','amalfi','ambitiös','amboss','anacapris','anadyr','anaheim',
|
||||||
|
'analficken','analvene','analyse','anaphase','anatom','anbeiße','anbellend','anbrenne',
|
||||||
|
'anbringens','andeute','andinen','andiner','andragogik','androgyn','anekelnden','anfährt',
|
||||||
|
'anfangsgag','anfassbare','anfordert','anfühle','anfüllst','angebote','angelobtes','angelötete',
|
||||||
|
'angepisst','angespült','angestoßen','anginöses','anglikaner','angrinste','ängstlich','angstspiel',
|
||||||
|
'angucktest','anhustete','anises','anissamen','ankarrtet','ankaufende','anker','ankerndem',
|
||||||
|
'anklamer','anklebst','anklingens','anknüpfte','ankokelte','ankurble','anlaufzone','anlauten',
|
||||||
|
'anlegst','anmachtour','anmahnt','anmaßt','anmeierten','anmietbare','annagelte','annähtet',
|
||||||
|
'anne','annette','anonymem','anpochten','anregbarem','anreistest','anrempelte','anrührend',
|
||||||
|
'ansähe','ansäuerten','ansaugend','ansaugtakt','anschaffen','anschient','anschosst','ansetzen',
|
||||||
|
'ansogst','anspielten','anspien','anstrebten','anteils','antigua','antipagane','antitypus',
|
||||||
|
'anwehen','anwidernde','anzahlend','anzapfbox','anzulachen','anzündetet','anzündung','apfellogos',
|
||||||
|
'apfelmuse','apleona','appetizers','aprilluft','aquarelle','aquasphäre','arachnoide','aral',
|
||||||
|
'archipelen','areole','ariern','armenden','armenhof','armmolche','armstuhls','armzeug',
|
||||||
|
'arschklaps','arsenoxide','artistisch','arzneibuch','arztberuf','asebie','aspartam','assessorin',
|
||||||
|
'assoziiert','astheim','asthmoides','aststumpf','asylpakete','atomsymbol','atonalem','aufbausche',
|
||||||
|
'aufbaust','aufblasbar','aufblinke','aufbrüllte','aufdrängt','auffädeln','auffindet','auffing',
|
||||||
|
'aufführe','aufgössen','aufgrund','aufguckt','aufhackst','aufhalse','aufhellte','aufheulte',
|
||||||
|
'aufholende','aufkäme','aufkehrt','aufklärend','aufklaubt','aufkleber','aufladend','auflebende',
|
||||||
|
'aufließ','aufpasste','aufpralls','aufputzend','aufriegeln','aufriss','aufruft','aufrufziel',
|
||||||
|
'aufschalte','aufstecke','aufstieg','aufteilten','aufwachsen','aufwand','aufwärmend','aufwiegt',
|
||||||
|
'aufwirfst','aufzäume','aufzöge','aufzuckst','aufzuckten','aufzugkern','aufzugs','augenloch',
|
||||||
|
'augenwurm','ausbadest','ausbilder','ausbliebst','ausdehnten','ausfasernd','ausgesöhnt','ausgräbers',
|
||||||
|
'auskostest','auskramst','auskugelst','auskühlst','auslangen','ausliege','ausliest','auslöstet',
|
||||||
|
'ausnützest','ausnutzung','auspuste','ausrenkt','ausrufern','ausrufs','aussagtet','ausschält',
|
||||||
|
'ausschöpfe','außenfront','außenraums','äußernder','ausspielte','ausspracht','ausspruchs','ausspülung',
|
||||||
|
'aussteige','austraten','auswärtige','auswiche','auszahlend','autobomben','autodeltas','autofreak',
|
||||||
|
'autogarage','autohandel','autonymer','autoöle','autostrich','aztekisch','babyjahren','babykatze',
|
||||||
|
'babypuder','backofens','badeleben','badenische','badetasche','bahlsens','bahnarten','bahnrad',
|
||||||
|
'bahnringen','baikal','balaton','baldowerte','ballerina','ballerst','ballerten','balzkampf',
|
||||||
|
'balzrevier','banalen','bandartig','bandleiter','bangendem','bangor','bankdatum','bänken',
|
||||||
|
'bankgulden','bankkredit','bankrate','banne','bannenden','barbusigem','bärenauen','bärenmarke',
|
||||||
|
'bargau','bärlauchs','barockoboe','basaleres','basic','basisnoten','baskisches','basssänger',
|
||||||
|
'bastard','bauanfang','baubohrer','bauchgurt','bauchtanz','baud','bauernkaro','bauernkind',
|
||||||
|
'bauernweg','bauetappe','baufuge','baufunde','bauleiters','baumastes','baumborke','baumfarm',
|
||||||
|
'baumharzen','baumhütten','baumkreise','baummaus','baumplätze','bauopfers','bauplanes','bauportale',
|
||||||
|
'baureis','bayerin','bdst','beamtentum','bebartetem','bebauendem','bebrüteter','becherling',
|
||||||
|
'bedächten','bedachtest','bedenkende','beete','befehdete','beflagge','beflaggst','befriedens',
|
||||||
|
'befruchtet','befugten','begabung','behaftetes','behebendes','beheizten','beherzigen','beherzigte',
|
||||||
|
'behobene','behobst','beichtgeld','beiges','beiköchin','beimäßen','beinbergs','beinharter',
|
||||||
|
'beinschlag','beiwache','bejagendes','beklauten','beknien','bekrönte','beladungen','belang',
|
||||||
|
'belastetes','belegzelle','beleibten','belgischer','belgrader','beliebigem','bellende','belüde',
|
||||||
|
'belügendes','bemüßigend','benannte','benettons','benötigter','benutzers','bepuderter','beraubende',
|
||||||
|
'bereichern','bereift','bereinige','berentetem','bergfeste','bergflohes','berggesetz','berghase',
|
||||||
|
'bergheimer','berghügel','bergponys','bergtruppe','bergzug','berlinale','bernardus','bernhards',
|
||||||
|
'bertrams','berufsfeld','beruhendem','berührend','besabbelnd','besaitens','besaßest','beschäme',
|
||||||
|
'beschere','besenkeil','besprengst','bestärkt','bestiegt','bestürzung','bestussten','besuchte',
|
||||||
|
'betern','betonarme','betonlatte','betonrohre','beträte','betrauerte','betreuers','betriebe',
|
||||||
|
'betrübt','bette','bettelei','bettnässen','betüddelnd','betupfende','beugender','bewandtem',
|
||||||
|
'bewegender','beweise','bewohnerin','bezahlten','bezeichne','bezugserde','bezwang','biberhüte',
|
||||||
|
'bierdepots','biermaß','bierreisen','bilderberg','bildfarbe','bildform','bildideen','bildlesers',
|
||||||
|
'bildstil','bimssteine','binaurales','binomen','biofenster','biomineral','biotin','biowäscher',
|
||||||
|
'bissfester','bitbusse','bitmusters','blanchiere','blätterte','blattlänge','blättrige','blatttyp',
|
||||||
|
'blatttyps','blaubrust','blauroter','blauzungen','blechle','bleidraht','bleikugel','bleistifts',
|
||||||
|
'bleiverbot','blickfang','blinktest','blochs','blockbuchs','blöcken','blocktest','blöderem',
|
||||||
|
'blonden','bloßlag','blutbanner','blutebene','blutgift','blutigeres','blutmonde','blutopfers',
|
||||||
|
'blutrest','blutreste','bluttriebe','blutumlauf','blutzeuge','bobachter','bockballs','bockgesang',
|
||||||
|
'bodengut','bogenkurve','böhnchens','bohnenfeld','bohrender','bohrkäfern','bollwerken','bombay',
|
||||||
|
'bomberbaus','bookshops','boomlandes','bootendem','böotisches','bootrennen','bootsteil','borde',
|
||||||
|
'bordell','bordhund','börsenbai','borsten','bossele','botenstab','bowle','boxkampf',
|
||||||
|
'bpatg','brandopfer','brandstatt','brandweges','brasilias','braunton','brautamt','brautauto',
|
||||||
|
'braver','breeches','breiigem','bremsender','bremskabel','brentano','breyell','briefoper',
|
||||||
|
'brigant','britney','britpop','bromdampf','bronx','brotgaben','brottyp','brownings',
|
||||||
|
'bruchacker','bruchhagen','brückenweg','brühkolben','brûlée','brummenden','brummt','brünetter',
|
||||||
|
'brunnenkur','brüsselern','brütendes','brutheißes','brutstoffs','buchende','buchkästen','buchkörper',
|
||||||
|
'buchmarke','buchtexte','büffele','büffelfell','büffelnden','bügelstube','bühnenbau','bumsenden',
|
||||||
|
'bündelchen','bundesbüro','bündisch','bunds','buntbrust','burberry','bürgersitz','bürgerstil',
|
||||||
|
'burggutes','burglehm','burgplatz','burgrechte','burgtürmen','burisches','buschbank','buschheide',
|
||||||
|
'busreise','bußgang','bußkleid','bußkreuzen','büstenhebe','cafeterien','camelots','carbonaten',
|
||||||
|
'cartridges','causa','cellosaite','charta','cheatest','chemisches','chemtura','cherbourg',
|
||||||
|
'chinolin','chorbaues','chorbube','christians','cidre','classico','cleveren','clipboards',
|
||||||
|
'clownerei','clubmaster','clubnadel','clubnamen','coate','codierten','codiertes','containern',
|
||||||
|
'covers','coversongs','crèmes','cremten','dableiben','dachboxen','dachhelme','dachmodell',
|
||||||
|
'daherläuft','dahingibst','dahinstarb','damenwege','dammbruchs','dammendorf','dampfnebel','dancing',
|
||||||
|
'dankbrief','danksagen','danktage','dankwarten','danutas','darmatmung','darmsaite','darmstein',
|
||||||
|
'darreichen','dasssätzen','datenhelm','datenkrieg','datenlänge','datenwert','datierbar','datum',
|
||||||
|
'dauerbruch','dauereier','dauereis','dauerton','daumenkino','davontrüge','dazutätest','deckbergen',
|
||||||
|
'deckende','deckenteil','defäkation','defektiver','definierst','deklarant','dekokissen','delawares',
|
||||||
|
'delhi','dengelnd','denkbar','denkbombe','denktest','denunziert','deosprays','dependenz',
|
||||||
|
'derbyfelds','dergestalt','derivateme','detailfoto','detonieren','dettingen','deuteten','deutlichen',
|
||||||
|
'deutz','dezimales','diakonaten','diameter','diaphanes','dichtend','dickwurzel','dienstmann',
|
||||||
|
'dieselöl','diesels','dieseltank','diethelm','diktion','dildo','dinkelmehl','direktcode',
|
||||||
|
'dirham','disparate','dispatcher','diwans','dock','doktere','dokument','dolchen',
|
||||||
|
'domfelsens','domtreppe','donaucitys','donezk','doppeldeck','doppeltüre','dorfbrief','dorffehden',
|
||||||
|
'dorfführer','dorfkatze','dorfmeilen','dorfvikare','dorian','dornenwall','dörrens','dortbleibt',
|
||||||
|
'dotzheims','drahtanker','drahtenden','drahtes','drahtmaß','drangehe','drankommt','dranlässt',
|
||||||
|
'draufstand','dreckbude','drehbilder','drehgriff','drehhaken','drehimpuls','drehlagen','drehzahns',
|
||||||
|
'dresdner','dropbox','druckbarem','druckgase','druckgefäß','drucköl','drusischer','dümpeln',
|
||||||
|
'dung','düngungen','durchsahen','dürrem','durstige','duschtuch','düsterheit','ebenen',
|
||||||
|
'ebook','ecktisch','ecktoren','edelgas','edeljoker','edelkatzen','edelstes','effektarm',
|
||||||
|
'ehejahrs','ehepaars','ehescheuer','ehrenbild','ehrenburgs','ehrenkleid','ehrensolde','eichbäume',
|
||||||
|
'eichdorf','eichelberg','eierkarton','eigendruck','eigenklang','eigenraum','eigenwelt','eignend',
|
||||||
|
'einbohrte','einbremst','einbringst','eincremens','eineiigen','einendes','einfeilten','einflogt',
|
||||||
|
'eingefasst','eingelesen','eingewöhne','einhaktet','einhandeln','einholte','einhufers','einjagen',
|
||||||
|
'einknüpfte','einlagige','einloggte','einmachten','einmottete','einmündete','einnähme','einnähmest',
|
||||||
|
'einnähst','einnehme','einredeten','einreibens','einschlägt','einschulte','einsehens','einsingens',
|
||||||
|
'einsitzig','einstufe','einstufige','einstürmst','eintauche','eintrag','einwillige','einzähnig',
|
||||||
|
'einzelnote','einzögest','einzunähen','eisblock','eisdolche','eisenkufen','eisfischen','eisgürtel',
|
||||||
|
'eishäusern','eiskaffees','eisklima','eisklotz','eiskrieg','eislinien','eispapier','eisriegel',
|
||||||
|
'eisweihern','ekelns','ekmnesien','elbkähne','elbseite','elflandes','elfstündig','ellerlinge',
|
||||||
|
'elternbund','eluiert','emdens','empfandest','endblüten','endendem','endes','endformen',
|
||||||
|
'endkosten','endlaut','endung','engelauts','englein','englewood','ensemble','entbietet',
|
||||||
|
'enteisest','entenküken','entenstall','enterisch','entfernen','entflogene','entführst','entlarvtet',
|
||||||
|
'entrausche','entspanne','entsprach','entstört','entwischte','epigonalen','epiphanias','epochaler',
|
||||||
|
'epoxide','erbauender','erbt','erbtanten','erdbaues','erdendem','erdferkeln','erdgase',
|
||||||
|
'erdichte','erdjahrs','erdknollen','erdkunde','erdmonate','erdsteinen','erdstelle','erdsternen',
|
||||||
|
'erduldetet','erdwärts','erdzunge','ereifert','ererbtet','erfuhr','ergauner','ergötzten',
|
||||||
|
'ergründens','ergründung','erhabenes','erhandelst','erhebbaren','erhöbet','erhöhens','erhörst',
|
||||||
|
'erisäpfeln','erkrather','erlittene','erlöschen','ermattung','ermogelnd','ermogelte','ermüdbarer',
|
||||||
|
'ermüdet','erneuern','erneuerter','ernsteste','erntetest','erntewege','eroberern','eroberns',
|
||||||
|
'erörtertem','erosiven','erschöpfen','erspähtet','ersten','erstflug','erstformen','erstkosten',
|
||||||
|
'erstzucht','ersuchend','erteilter','erwägt','erweichter','erwirkens','erzeugerin','erzeugnis',
|
||||||
|
'erzgefäßes','erzhalunke','erzlager','erzogenes','erzstatue','erzstock','erzsünden','eselgrauer',
|
||||||
|
'eskalieren','espresso','essigkraut','essnische','esssessel','estg','ethikbüros','ethnie',
|
||||||
|
'etiennes','etruskers','eulenbäume','eulenhof','euren','euronotruf','europaring','eurovideos',
|
||||||
|
'evertebrat','exakteren','exakterer','exakteste','exiljahre','exilrusse','exocytose','exportland',
|
||||||
|
'exsoldat','externem','extertaler','extremem','fabio','fachgebiet','fachhelfer','fachkenner',
|
||||||
|
'fachmänner','fachreise','fachteilen','fachtitel','fackelnd','fadenalgen','fahl','fahlrotem',
|
||||||
|
'fahr','fahrbaren','fahrdammes','fährdorfes','fährführer','fahrigem','fährkahn','fährnissen',
|
||||||
|
'fahrorte','fahrsystem','fahrtiefe','fahrtitel','fährtypen','fahrwegs','fallarten','fallbäume',
|
||||||
|
'fallblöcke','fallentyp','fallkapsel','falllaub','fallserien','falltritte','fälschers','faltblatt',
|
||||||
|
'falträdern','famagustas','fändest','fanganoden','fangdamm','fangt','farbeigen','farberden',
|
||||||
|
'farbkanäle','farbloser','farbräumen','farbreiner','farbsorte','farbsymbol','färbtest','farbtöne',
|
||||||
|
'farmzäune','fasernde','fasernetze','faserstamm','fassende','fastenplan','fatburnern','fauchenden',
|
||||||
|
'faulige','fauliger','faunischem','faustfilms','fechtens','fechtsport','federbäume','federhaube',
|
||||||
|
'federhebel','federpose','fegten','fehlerhaft','feigheit','feinsieben','feldform','feldhof',
|
||||||
|
'feldkerze','felsfarbe','felshüpfer','fenneks','fernbahn','ferngases','fernhält','fernkabels',
|
||||||
|
'fernmessen','fernraum','fesche','fesselnder','festbühnen','festfahrt','festinhalt','festkonto',
|
||||||
|
'festlegung','festmachte','festpunkt','festsauge','festszenen','festtinten','festtreppe','festwagens',
|
||||||
|
'festziehe','festziehen','fettbergen','fettschutz','fettwerten','feuchtkalt','feudale','feudalen',
|
||||||
|
'feudel','feuerelfe','feuergase','feuerkreis','feuerrabe','feuerst','ficklanze','fickten',
|
||||||
|
'fiepsender','fiesem','figürlich','filiation','filmbarem','filmblatts','filmduo','filmfan',
|
||||||
|
'filterwerk','filziges','filzstift','finalserie','finanzhof','finanzwirt','findig','finesse',
|
||||||
|
'fingernde','fingeryoga','fingiert','finitismus','finnair','finnische','finnischer','fischerort',
|
||||||
|
'fischeulen','fischfangs','fischtrane','fixgehalt','fixiersalz','flachboot','fladen','flammentod',
|
||||||
|
'flanierens','flansches','flaschners','flaut','fläzend','flexoskope','fließtiefe','flinkeren',
|
||||||
|
'flippe','flitzt','flockst','flockt','floppy','florierens','floskel','flottes',
|
||||||
|
'flüchteten','fluchtfall','flugfeld','flugkarte','flugmotors','flummis','flurtüren','flüssigere',
|
||||||
|
'flussszene','flutgraben','flutzeiten','fohlenhöfe','folgesiegs','folgsamere','foltermord','fordernd',
|
||||||
|
'fordernde','fordismus','formfinder','formkunst','formmassen','formnestes','fortfegtet','fortgingst',
|
||||||
|
'fortglitt','fortsetzt','fortstieg','fortströme','fotoblogs','fotomotive','frachten','fräcke',
|
||||||
|
'fragerecht','fraunhofer','freikamst','freiließen','freinehme','freising','freistaate','fremdes',
|
||||||
|
'fremdstart','freudscher','frickeln','fridolins','friedsames','frostnacht','frottiert','frotzele',
|
||||||
|
'fruchten','frühkohl','fuffziger','fühlend','füllsteine','fünfendern','fünfjahrs','fünfphasig',
|
||||||
|
'fünftel','funkteilen','funkzünder','furchen','fürchtetet','furios','fußbades','fusselnde',
|
||||||
|
'fußgelenke','fußhebel','füßling','fußrad','fußwegnetz','futtersack','futures','gabriela',
|
||||||
|
'gabriele','gähnende','gaisberg','gallseife','gamsbartes','gangrades','gangschar','gangtiefen',
|
||||||
|
'gangtür','gangway','ganzstelle','garden','gärmitteln','garnele','gartenvase','gasalarme',
|
||||||
|
'gasgewehr','gasimpuls','gaslagers','gästefarm','gästeliste','gästeraums','gästesuite','gastlehrer',
|
||||||
|
'gastrogen','gastweise','gasuhren','gauligen','gaulle','gauredner','gazetten','gealtertem',
|
||||||
|
'geäugt','gebärender','gebetsort','gebettetes','gebogener','gebrätelte','gebröselt','gecken',
|
||||||
|
'gedrängeln','gedrängten','gedrehtes','gedunsener','gefallener','geflicktes','geflüster','gefoulter',
|
||||||
|
'gefoultes','gegebenem','gegenfrage','gegenstück','gegenwurf','geglaubte','gegossenen','gegürteter',
|
||||||
|
'gehöhnt','gehorchtet','gehülltes','geigensoli','geißle','gejapst','gekappter','gekapselte',
|
||||||
|
'gekarrt','geklemmtem','geklonte','gekloppte','geknicktem','geköderte','gekröpften','gekröses',
|
||||||
|
'gekuppelt','gekürtes','gelabtem','gelangt','gelbäugige','geldvorrat','gelenkt','gelernter',
|
||||||
|
'gelierten','gellten','gelobendes','gemächer','gemasert','gemäuern','gemeinst','gemeißelte',
|
||||||
|
'gemessene','gemimtem','gemütvoll','genagelt','genecktes','genevers','genialstem','genotypus',
|
||||||
|
'gent','gentest','genugtut','genus','geopferten','geordnet','gepennt','gepfiffen',
|
||||||
|
'gepiercte','gepimpte','gepolter','gepralltes','gerät','geräumigem','gerbte','gereckten',
|
||||||
|
'geringst','gerissen','gerobbten','gerold','gerumpelt','gerundivum','gerüttel','gesabbel',
|
||||||
|
'gesalzenen','gesamtbau','gesang','geschellt','geschlüpft','geschwärmt','gesinnte','gespultem',
|
||||||
|
'gestattung','gestirnter','gestülptem','gestürzter','gestyltes','gesunder','getempert','gethsemane',
|
||||||
|
'getreues','getrieft','gewächses','gewälzte','geweiht','gewindetyp','gewinnler','gewitzel',
|
||||||
|
'gewitztem','gewitzten','gezähnte','gezäumtem','gezwicktem','gianna','gierst','gießhauses',
|
||||||
|
'giftdorne','giftfreier','giftzwerge','gigatonne','gilbertos','gildefeste','gipfelpaar','gipsbeins',
|
||||||
|
'gipshöhle','gipsloch','gipslöcher','gipswerks','gladbach','glanzstar','glashelles','glasiertem',
|
||||||
|
'glasputz','glasraums','glassärge','glasurriss','glasvasen','glatteises','gleichlauf','gleichtut',
|
||||||
|
'gleitsohle','glitschig','glitzernde','globale','glotzend','gluckernd','glühphasen','glukagon',
|
||||||
|
'glutwolke','glyphen','goldadler','goldauges','goldballes','goldbären','golddistel','goldfieber',
|
||||||
|
'goldrotem','goldsäcke','goldschiff','goldschuhe','goldspuren','goldstücke','goldturmes','goldvögel',
|
||||||
|
'golfkröte','golfseiten','gösse','göttertee','gottesrede','göttin','götzenbild','grabkulten',
|
||||||
|
'grabschten','gräbst','grabtempel','grabtuch','graden','grafenpaar','grafikfeld','grasplatz',
|
||||||
|
'grasratte','graugrüne','gravidem','greifern','grenzball','grenzenlos','grenzmaße','grenzort',
|
||||||
|
'grenzwölfe','grenzzuges','greven','griffteile','griffwulst','griffzone','griffzonen','grillsoße',
|
||||||
|
'großberg','größenberg','großenhain','großgepäck','großplatte','großpudel','großpudels','großruck',
|
||||||
|
'großtaxi','großträger','grottigen','grugahalle','grünäugig','grundkäfer','grundmann','gründorf',
|
||||||
|
'grundtypen','grünzeugs','grüßaugust','guanoinsel','guaven','gucke','guckloches','gucktet',
|
||||||
|
'güldene','gummiband','gummibooms','gummidruck','gummifeder','gummihaube','gummilöwen','gummisohle',
|
||||||
|
'gurgelns','gurkendem','gurtnägeln','gurtrohr','gussmörtel','gussöfen','gütegrad','guthat',
|
||||||
|
'gutmütiger','haare','haarendem','haarigsten','haarlem','haarpore','haarrest','habsburger',
|
||||||
|
'hackevolle','hafenfigur','hafenmusik','hafentages','haftballen','hafthauses','haftlinsen','haftsumme',
|
||||||
|
'hagebuchen','hagelnden','hageltürme','hagerste','hakelig','hakeln','häkelnd','hakennasen',
|
||||||
|
'halbfestem','halbformat','halbnonnen','halbruinen','halbstarre','half','hallenstil','halsgegend',
|
||||||
|
'halsketten','halsrippe','halsschuss','hammerkopf','hamptons','handgarn','handgeste','handgröße',
|
||||||
|
'handhabend','handlaufs','handlötung','handwinden','hanfgarn','hänflings','hanfmuseum','hängebusig',
|
||||||
|
'hängefußes','hängens','hangseite','hanseatin','hänselte','härteofen','hartplatz','hartstöcke',
|
||||||
|
'harzrand','haselblüte','hasenbahn','hatewellen','hätscheln','hätte','häufte','hauptburg',
|
||||||
|
'hauptforum','hauptgrund','hauptkoch','hauptkonto','hauptliste','hauptmaß','hauptmast','hauptquark',
|
||||||
|
'hauptrohrs','hauptspaß','hausbacken','hauscrew','haushoch','hauskater','häusle','hausregeln',
|
||||||
|
'hausrockes','haustaube','hauswaffe','hautbanken','hautdelle','hautflügel','hautgiften','hautzipfel',
|
||||||
|
'headliners','hebelst','hebende','hebt','heftweise','hegelschen','hehr','heidengeld',
|
||||||
|
'heidis','heikes','heilbare','heilbarem','heilpulver','heiltest','heimatkurs','heimspiel',
|
||||||
|
'heimweber','heimzuges','heiserem','heiterere','heizbandes','heizleiter','heizwagen','hektisches',
|
||||||
|
'hellhörige','hellweißer','helmzier','hepburns','herabkam','herausließ','herausluge','heraussah',
|
||||||
|
'herbringst','herbstwahl','herdrehtet','herforder','herhatte','hermitage','herrischem','herschenkt',
|
||||||
|
'herumband','herumlagen','hervorkam','herwagten','herzass','herzblutes','herzigel','herzkrebs',
|
||||||
|
'herzmasse','herznahem','herzogsgut','herzrisse','herzschlag','herzwärme','herzwürmer','heulten',
|
||||||
|
'hexenrecht','hexwerte','hilfetexte','hilfslohn','hilfspilot','hilfsweg','himmelmann','hinabmusst',
|
||||||
|
'hinabwürfe','hinderte','hindustans','hineintrug','hingangs','hinguckst','hinhängst','hinken',
|
||||||
|
'hinkend','hinkens','hinnahmen','hinsehend','hinspucken','hinstreute','hintragt','hinzerrt',
|
||||||
|
'hinziehst','hinzukämet','hinzuzöget','hirnbeine','hirnleiste','hirnloses','hirschrufe','hitzegrad',
|
||||||
|
'hitzephase','hochbetagt','hochbett','hochladys','hochluden','hochphasen','hochpushst','hochsteht',
|
||||||
|
'höfer','hofflächen','hofftet','hofgartens','hofierst','hofleute','hofwehre','hofwirtes',
|
||||||
|
'höhe','hohlbirne','hohlgriff','hohlnadeln','hohlteil','hohnlachen','holarktis','holistisch',
|
||||||
|
'höllenlärm','höllenriff','holmtiefen','holsteins','holsten','holzbremse','holzeimers','holzfeind',
|
||||||
|
'holzhausen','holzkamm','holzkarren','holzkugeln','holzland','holzpforte','holzspitze','holzstube',
|
||||||
|
'holzstuhls','homologem','homologen','honigsenf','honorable','hopsen','hörerkreis','hornmoos',
|
||||||
|
'hornzelle','hörspulen','hortfundes','hubrädern','hubventile','hubwerk','hufeisen','hüftansatz',
|
||||||
|
'hüftköpfen','hügelreihe','hügelzonen','hühnergott','humorale','hundefarbe','hundeopas','hundepest',
|
||||||
|
'hundezucht','hungertest','hüpfspiels','hurritern','hütehunden','hutes','hyänen','hygiene',
|
||||||
|
'hymnologie','hyoscyamin','iberischem','ibuprofen','ideenreich','idiolatrie','ikonograph','illiquide',
|
||||||
|
'illoyale','immanenter','immotil','impfende','impfserums','impftet','importhaus','imposante',
|
||||||
|
'impotenz','indexes','indio','indirekter','infinitum','injizierst','inkurablen','inliners',
|
||||||
|
'innendekor','innenmaß','innennaht','innenrinde','inseldorf','inselstand','intershops','intimerem',
|
||||||
|
'introns','invarianz','inzests','ironikerin','islam','islamabads','isländern','isochron',
|
||||||
|
'iterierend','jacobys','jagdbeirat','jagdboot','jagdcamp','jagddolch','jagdhütten','jagdklub',
|
||||||
|
'jagdwildes','jago','jahresbuch','jahresgage','jährt','jainismus','jamaikaner','japsendes',
|
||||||
|
'jäten','jaunde','javanerin','jecken','jessy','jobcenters','jobfolge','jobmotors',
|
||||||
|
'jodlerin','jubelfest','jubelmesse','judaistik','judengasse','judengeist','jugendlich','jungadler',
|
||||||
|
'jungamseln','jüngere','jungrobben','jungwanzen','juryfreie','justament','juvenilen','kabanossi',
|
||||||
|
'kabeljau','kaffeebar','kaiserpilz','kalberst','kalbshaxen','kalilauge','kalkböden','kalkfreie',
|
||||||
|
'kaltbad','kältehochs','kaltluft','kampfblatt','kampfluken','kampfname','kampfwert','känguruart',
|
||||||
|
'kanntest','kanonikers','kant','kantische','kapokbaum','karavellen','karbon','karbonaten',
|
||||||
|
'karlstores','karotiden','karsten','kärtchen','käsegebäck','käseleinen','kashmir','käsiger',
|
||||||
|
'kasteiten','kastenbrot','kastor','kasuist','katzenkind','kaufteile','kehlenfick','kehlkopf',
|
||||||
|
'kehrreims','keiftest','keilartig','keilstöße','keimfaden','keimhaften','kellergang','kellerwand',
|
||||||
|
'keltisches','kennedys','kerbebäume','kerbend','kerkrade','kernhauses','kernlamina','kernmodul',
|
||||||
|
'kesselholz','kesseln','kesselst','kick','kickoffs','kidnappt','kieferlose','kiefernart',
|
||||||
|
'kieselalge','kiesgrube','killerwals','kinderbild','kinderehe','kindergang','kinderherz','kinderjury',
|
||||||
|
'kinokultur','kinolänge','kinomuseum','kioske','kioto','kippeliger','kippeliges','kipplauf',
|
||||||
|
'kiras','kirremacht','kirschauge','kirschberg','kistengrab','kitze','kitzelns','klammerfuß',
|
||||||
|
'klarerer','klarstelle','kleinbuchs','kleingerät','kleinhäfen','kleinmutes','kleinpferd','kleistern',
|
||||||
|
'klemmende','klemmhofes','klimastufe','klirrenden','kloake','klöntet','klopper','klubeigene',
|
||||||
|
'klubhütte','klumpfüßen','klüngel','knabenhemd','knaggen','knappere','kniefalls','kniesitz',
|
||||||
|
'kniestroms','knittrigen','knotest','knüpfend','kochäpfeln','kochapfels','kochkünste','kochrezept',
|
||||||
|
'kochseiten','kodexes','kodierten','kohlköpfe','kokswerke','kollergang','kolonialem','kommendes',
|
||||||
|
'kommode','kompagnie','konformem','kongolesen','konjunktur','konnotiere','konsumreiz','konterndes',
|
||||||
|
'kontert','kopffarben','kopfkohl','kopfstück','kopfteile','koppelmann','koppelzeug','korbball',
|
||||||
|
'kordon','kornspitze','körperfett','körperlos','kostenberg','kostenflut','kovariiert','kraftfeld',
|
||||||
|
'kräftigtet','kraftmann','kraftpaket','kraftwort','kralle','kratzigste','krebsherd','kreidens',
|
||||||
|
'kreisamtes','kreiselte','kreistest','kreiszahl','kreuzblume','kreuzmark','kriegsplan','kriegszugs',
|
||||||
|
'krokodils','kröntest','kröpfung','krümchen','krümeligem','kuhheide','kuhknochen','kuhköpfen',
|
||||||
|
'kullerte','kunstdieb','kunstfelle','kunstfront','kunstglied','kunsttrieb','kunstuhr','kupfer',
|
||||||
|
'kupferige','kuppelgrab','kuppelsaal','kürendem','kurgartens','kurlauben','kurssystem','kurst',
|
||||||
|
'kurszüge','kurvenrate','kurzführer','kurzgras','kurzsäbeln','kussechte','kutanen','laborkabel',
|
||||||
|
'lachfaktor','lachfalken','lachmund','lachsrote','lachszucht','lachtest','lackrote','ladbaren',
|
||||||
|
'ladebodens','laderampen','laderost','ladetanks','lagerbar','lähmendes','laminierte','landberg',
|
||||||
|
'landedecke','landegerät','landekufe','ländereien','landesdome','landetests','landheeren','landmark',
|
||||||
|
'landmauer','landmeere','landstand','landvogtes','landzoll','langdorf','langheck','langhornes',
|
||||||
|
'längsseite','längstal','lanker','läppisch','lärmender','lassos','lasttier','laubmulch',
|
||||||
|
'laudator','laufdauer','lauffläche','laufschuh','laufstegen','lauftreppe','lausigem','lausten',
|
||||||
|
'läutens','lautsystem','lavabodens','leanders','lebertrias','lebloser','leckeren','lederbogen',
|
||||||
|
'lederfarbe','ledergasse','lederhelms','lederseil','lederslips','leerläufe','leerzeile','leewärts',
|
||||||
|
'legendem','legenden','lehmglasur','lehmgruben','lehnseids','lehnsstaat','lehrhafte','lehrherr',
|
||||||
|
'lehrherrn','leidtun','leihamtes','leihbasis','leihrad','leihstimme','leim','leithäuser',
|
||||||
|
'lenkgabeln','lennestadt','leprösen','lernstils','lernwegen','lesebuches','lesefundes','leselupe',
|
||||||
|
'lesestäben','leseweise','lesezusatz','leugnenden','leugnens','lichtmaß','liebster','liedchen',
|
||||||
|
'liefe','liegegeld','liegendem','liegeräder','lifestyles','lily','liniertem','linkenden',
|
||||||
|
'linkerei','linné','linuxtag','lipizzaner','lippenpaar','litauens','literatur','litermaß',
|
||||||
|
'lizenzzeug','lobbyismus','lobotomie','lochmasken','lodertest','löffelbund','lohnanteil','lohndiktat',
|
||||||
|
'lohnsatz','lohnseiten','lohnsklave','lokalposse','lollis','lorbeer','lösbarem','lösbarstem',
|
||||||
|
'löschebene','losheulst','löslichere','losmüssten','lospoltert','lössen','losweine','lötend',
|
||||||
|
'lotweise','luandas','lüftchens','lüftender','lufthoheit','lügenbaron','luisenhof','lukrierst',
|
||||||
|
'luminal','lungenfell','lungernd','lutschmund','lutscht','luvseite','lyotrope','mäandernde',
|
||||||
|
'machos','mächtiges','machttrip','macke','madenwurms','mafiöses','magazin','magenkrebs',
|
||||||
|
'magenwurm','magererem','magnet','mahnbrief','maiclub','maidult','mairevolte','maivorgang',
|
||||||
|
'majuskel','makedonen','malchiner','malfelds','malignes','malikiten','maltet','mandarins',
|
||||||
|
'manfred','manifestem','manipels','männertaxi','männlein','manon','manschette','mantelmöwe',
|
||||||
|
'marastisch','maría','mariensaal','mark','markiertet','markschein','marktbreit','marktengen',
|
||||||
|
'marktlagen','marktlaufs','markts','martinshof','märtyrer','märzkämpfe','masern','maßanzügen',
|
||||||
|
'massenbach','massenware','maßgenaue','maßhaltend','maßhielt','massigsten','massimo','mästen',
|
||||||
|
'matrixverb','mattgrünes','mäuerchen','maulhöhle','maultieren','mausinnere','mausoleen','maustest',
|
||||||
|
'maxima','mediokres','meeradlern','meerestage','meerkatze','meernebel','mehltaus','mehlwurms',
|
||||||
|
'mehring','mehrmann','meierei','melbournes','meldegerät','meldelinie','melodrama','merkbücher',
|
||||||
|
'merkreim','messdiener','messing','messrekord','messwagen','methoden','metrischer','mette',
|
||||||
|
'mietstreit','migriert','mikrolage','mikrorille','milchbars','milchberg','milchblume','milchtopf',
|
||||||
|
'miltenberg','mimtest','minigenre','minikocher','minnesangs','miotisch','missendes','missgönnst',
|
||||||
|
'mistbiest','mitbieten','mitgeben','mithabt','mithelfen','mitkampf','mitkriegst','mitleids',
|
||||||
|
'mitreden','mitsenden','mitspielst','mitteldach','mnemosynes','möbelfüße','mobilem','möchtet',
|
||||||
|
'modischem','mohnblüte','molligster','moltkes','momente','mondaugen','mondbebens','mondfähren',
|
||||||
|
'mondgas','mondkreise','mondmobile','mondpreise','mondsüdpol','monetär','monets','mongolider',
|
||||||
|
'mongolides','monomanes','montiere','moorerde','moores','mooreschen','moors','moorstich',
|
||||||
|
'mordmesser','morgendorf','moschee','motorfalke','motorklub','mottolied','mountete','mühsale',
|
||||||
|
'mulatte','mullahs','müllerin','müllfrau','mummelsee','mummelst','mundanerer','münzreihe',
|
||||||
|
'münzstraße','musikpreis','musiktaxis','muskelfeld','muskels','müslis','mutagener','mutmaßt',
|
||||||
|
'muttermal','nabeln','nach','nachbuchen','nachdrehen','nacherben','nachfolgen','nachginge',
|
||||||
|
'nachgraben','nachhalft','nachprüfte','nachrechne','nachruhm','nachtrags','nachtun','nachtwerte',
|
||||||
|
'nackertem','nackten','nadelufers','nadelwalze','nagern','nahekommt','nahelagen','nahender',
|
||||||
|
'nähere','namenspate','nämliche','nansen','narrenberg','narrenräte','nasenzahn','nassem',
|
||||||
|
'nassfesten','naturliebe','nebelkappe','nebelkrähe','nebenapsis','nebenhalle','necktet','negerleins',
|
||||||
|
'neidender','neigen','nelkenöl','neotango','neozoen','nestes','netbotz','netzbuchse',
|
||||||
|
'netzebene','netzstrom','netzteil','netztrafos','neuanfänge','neuenegg','neufarn','neuloten',
|
||||||
|
'neunkraft','neurogenes','neusalzes','newsrooms','nichtigste','nicknamen','nietköpfe','nietzangen',
|
||||||
|
'nihilist','nikosias','nirosta','nisthilfen','nitrose','nobelclubs','noduläre','nordkai',
|
||||||
|
'nordleute','nordlicht','normalkost','nortorf','nörvenich','nostalgie','notabstieg','notar',
|
||||||
|
'notbischof','notenscans','notentyps','notierte','notkapsel','notsystem','notwahl','nudeldick',
|
||||||
|
'nufringen','nullphasen','numerik','nutzfelder','nutzkräfte','nutzpferd','nutzwertes','oberaichs',
|
||||||
|
'oberarmen','oberbilks','oberdevons','oberdonau','oberhöfe','obermengen','oberquinte','oberstift',
|
||||||
|
'oberufers','oberwagen','observator','obsthainen','obstkerns','obstkörbe','ochsenherz','öftesten',
|
||||||
|
'oftmaligem','ohrhämatom','ohrlupe','ohrolive','ohrpinseln','ohrwärmern','okklusive','okkupiere',
|
||||||
|
'oktalskala','oktamer','olekranon','ölenden','opakglases','opel','openoffice','operetten',
|
||||||
|
'opfergefäß','opulente','orangerien','ordnetest','originaler','orkane','ortsakte','ortsämtern',
|
||||||
|
'ortsfremde','ortssinne','oslos','osramlampe','ossärem','ostabhänge','ostdialekt','osterdaten',
|
||||||
|
'osterdeich','ostereis','osterkorn','ostgruppen','osthimmels','osthof','ostkante','ostlage',
|
||||||
|
'ostlinien','ostmeer','ostrings','ostspionin','osttarif','osttrakt','ostwald','oxidiertes',
|
||||||
|
'pachtsumme','pafftet','pagoden','palmfarn','palmfarne','panikmodus','pannonisch','pantschen',
|
||||||
|
'panzertape','papierener','päppele','pappigere','pappkrone','papstmesse','paracortex','paradebett',
|
||||||
|
'parfumduft','parkland','parkplanes','parktet','parlandos','parteifoto','parteiwahl','passende',
|
||||||
|
'passteils','patagonien','pathetik','pauker','paulette','pausbacke','pause','pechfällen',
|
||||||
|
'peepshows','peinigende','peinigst','pektin','pelvis','pendeltags','pendeluhr','pendenzen',
|
||||||
|
'penninikum','pepitahose','periskopen','perlenpfau','perleulen','perlweiße','perlweißem','peronist',
|
||||||
|
'perron','pershings','pestjahre','pfählt','pfandgeld','pfarrblatt','pfeffriger','pfeilnaht',
|
||||||
|
'pferdedarm','pferdeform','pferdeweg','pfleger','pflegeteam','pflügende','pfostens','pfröpfling',
|
||||||
|
'pfundkrise','phasenfrei','phazelie','phenazin','phoibos','photolyse','photophob','picheltest',
|
||||||
|
'pickerl','pickt','pilzart','pilzbefall','pilzbrut','pilzgift','pilzhutes','pilzreich',
|
||||||
|
'pilzsoßen','pilzstiel','pink','pinkerton','pinnt','pinnwand','pirschtest','pissigste',
|
||||||
|
'plankopfs','planten','plapperte','plauderei','pochendes','pokaltor','pökelfisch','polarem',
|
||||||
|
'policen','polizeiamt','polnäherem','polnäherer','polsterst','polygraf','polyphagem','polyvalent',
|
||||||
|
'ponte','poolendem','popelte','popikone','popokneten','porlings','porti','posen',
|
||||||
|
'posiertest','postärztin','postenden','postjobs','postmannes','postmärkte','postsache','postwaggon',
|
||||||
|
'prachatitz','prälat','praliné','prämatur','prämisse','preisindiz','preiskrieg','prellens',
|
||||||
|
'presbytern','pressgangs','pressiert','prickeltet','primark','privathaus','probelager','probendem',
|
||||||
|
'profiler','profiteam','projekte','prokurator','prosciutto','prüderer','prüfern','prüfstufen',
|
||||||
|
'prüfzwecke','prügel','prustenden','pseudograf','puerperium','pulsnitz','pults','pummel',
|
||||||
|
'pumpenhübe','pumpwagen','punkern','pushender','pushendes','puterrot','putzkasten','putzwolle',
|
||||||
|
'quäkern','quälereien','qualm','quappaale','quartierte','quarzkorn','quengele','quengelst',
|
||||||
|
'quengler','queräxten','querulant','rachsucht','raddrucks','radecke','radfelgen','radgröße',
|
||||||
|
'radianten','radiolyse','radkreuze','radpartien','radprofi','radstrecke','rahseglern','raketenbau',
|
||||||
|
'ralligere','randnummer','randsänger','randthemas','randwinkel','randziffer','ranggen','rangierte',
|
||||||
|
'ranwirft','rapsfeld','raschelnd','raschestes','rasenplatz','rasens','rasten','rastendes',
|
||||||
|
'rastklinke','ratiopharm','ratsbücher','räubernd','räubertest','raubmord','raubzwecke','rauchbier',
|
||||||
|
'rauchende','rauchs','rauchspeck','raumgehalt','raumgilde','raumgruppe','raumlösung','raumwinde',
|
||||||
|
'raunend','rauschband','rausfändet','rausgehört','rausholt','rauswagend','realzinsen','rechnest',
|
||||||
|
'rechtloser','redeangst','redeeifers','redehalle','rederecht','redlichere','reduktion','reeps',
|
||||||
|
'referenzen','reformist','regelbaren','regelfach','regelglied','regelhefte','regeltypen','regendicht',
|
||||||
|
'regionales','regress','reichsdorf','reiflichem','reifriesin','reinhards','reinigern','reinkniet',
|
||||||
|
'reinpasse','reinpumpt','reinstoffs','reinströmt','reiselänge','reisesegen','reitgehege','reitpfade',
|
||||||
|
'rekelt','rekorder','rekordzüge','relaiskern','rempelns','rems','renderst','renitentem',
|
||||||
|
'renktest','renneisen','rennfeuers','rennkombi','rennpappe','rennsieges','rentenhöhe','rententurm',
|
||||||
|
'rentierend','replikats','reprise','reputable','residualer','resistives','restloses','restrisiko',
|
||||||
|
'rettichen','reuegefühl','revolution','rezenter','rheinbahn','rheopexie','ribbelns','richtlampe',
|
||||||
|
'richtmaße','riesenbock','riesigere','ringfasan','ringnetze','ringseite','ringstroms','ringzone',
|
||||||
|
'risikohaft','ritzende','rochade','rockstücke','rodelndes','rodeltour','rohmasse','rohrfedern',
|
||||||
|
'rohrkatzen','rohrmolch','rollators','rollenkern','rollgutes','rollige','rollpulte','rollräder',
|
||||||
|
'romys','ronny','ronsdorf','rookie','rosalia','rosarotem','rosenherz','roséweinen',
|
||||||
|
'rostbraten','rostetest','rostzügel','rotbäckige','roteisens','rothäute','rothöfen','rotisseur',
|
||||||
|
'rotklee','rotnasig','rotsehe','rotviolett','rotwerks','routenwahl','rübchen','rubra',
|
||||||
|
'rückenbau','rückfusion','rückraumes','rückwänden','rudercamp','rüdersdorf','ruderten','ruffalles',
|
||||||
|
'rufhebeln','rufspiels','rugby','rugbyballs','ruhehaus','ruhelarven','ruhendem','rühmt',
|
||||||
|
'rumeierte','rumfummelt','rumhurt','rumliefen','rumpfnamen','rumpfrippe','rumprolle','rumrollen',
|
||||||
|
'rundschau','rundzellen','runzligem','saalestadt','saarburger','sabbat','sabbattags','säbelbeins',
|
||||||
|
'sachgüter','sachkredit','sachtiteln','sackgrober','sackgrobes','sadistisch','säender','safttagen',
|
||||||
|
'sägemühle','sägestaub','sähest','sahniges','sakral','säkulum','salbt','saloppem',
|
||||||
|
'salpeter','salpetrig','salzfass','salzgebers','salzhütten','salziges','salzlinien','salzsteppe',
|
||||||
|
'samtene','sandkämmen','sandpilze','sandyacht','sanftestem','sängerstar','sarrasani','säten',
|
||||||
|
'satertag','satiniere','satins','satirische','sattelnd','sattmache','satzbruch','satzfolge',
|
||||||
|
'satzpaare','satztempo','sauertopf','säuge','saugeil','saugfuß','saugpumpe','saugseiten',
|
||||||
|
'sauguter','säuleneibe','säulenpaar','säumender','säumens','saunabad','saunahotel','saunatages',
|
||||||
|
'säuselndem','schachfeld','schachts','schachzüge','schadest','schäfte','schaftende','schalllaut',
|
||||||
|
'schalters','schaltpult','scharrst','schatzburg','schaubach','scheide','scheinhanf','schengener',
|
||||||
|
'scheppernd','scheuern','schieferem','schiefern','schiefging','schiffstau','schillert','schimmre',
|
||||||
|
'schleifern','schleifton','schleifweg','schlendert','schliddern','schlingert','schmaleres','schmatzt',
|
||||||
|
'schmeckst','schmollens','schmore','schmückten','schmutzen','schnappten','schnaubtet','schneeeis',
|
||||||
|
'schneehexe','schnellen','schnepper','schnieften','schnittige','schnoberst','schocklage','schocktet',
|
||||||
|
'schömberg','schonen','schonend','schonische','schote','schrappt','schreiten','schubben',
|
||||||
|
'schuhtyps','schuhwerke','schulatlas','schülerulk','schulhofes','schuljahre','schulsäcke','schultipp',
|
||||||
|
'schulz','schulzeug','schummele','schüppe','schwänken','schwelgen','schwerion','schwingweg',
|
||||||
|
'schwollt','sechsring','sedierende','seeburg','seegrasart','seekrankem','seele','seelenleid',
|
||||||
|
'seeleuten','seemitte','seesack','seesysteme','seewagens','seewanne','segeberg','segelpin',
|
||||||
|
'segeltyps','segge','segneten','seidelbast','seiffert','sektlaune','selbsttode','selbstwahl',
|
||||||
|
'selenide','semper','senfgurke','senfpulver','senfsauce','sengenden','sensor','sensorbild',
|
||||||
|
'serenade','serienzüge','serösem','serothorax','sesamsalz','sexberater','sexpuppe','sextriebs',
|
||||||
|
'siamesisch','sibiriern','sicherst','siebenbach','sieberts','siebtet','sieg','siegelst',
|
||||||
|
'siegeltet','siegend','sieger','siegtores','siezen','silbenfuge','silberbart','silberguss',
|
||||||
|
'silbersarg','silberstab','silberwald','simmels','sinatras','singvogel','sinternd','sippenhaft',
|
||||||
|
'situative','sitzortes','sizilische','skandal','skarabäen','skateshop','skifahrer','skitechnik',
|
||||||
|
'skiwasser','skotom','slawisten','slowene','snobs','socken','soffen','solidem',
|
||||||
|
'sollmengen','soloproben','sondername','songtagen','sonnentanz','soße','soundcity','soupieren',
|
||||||
|
'spähenden','spaltbarer','spaltbares','spanlosen','sparbetrag','spareribs','spargold','sparpack',
|
||||||
|
'speedway','speist','spelunke','spenglern','sperma','sperrbügel','sperrgrad','sperrgriff',
|
||||||
|
'sperrkamm','sperrrecht','spessart','spielchens','spielgrund','spielhahn','spielräume','spieluhr',
|
||||||
|
'spielvideo','spinalnerv','spinn','spinndrüse','spinnend','spinnender','spinnkurve','spitals',
|
||||||
|
'splendides','spornräder','sporntest','sportarena','sportler','sportpause','sportseen','sportsitz',
|
||||||
|
'sportstück','spöttele','sprachzug','springsee','springseil','spritzöle','sprödesten','sprunglauf',
|
||||||
|
'spülte','staatshof','stadtakt','stadttyp','stahlbälle','stählern','stahlpflug','stahlspan',
|
||||||
|
'stalin','stammkopie','stampfend','standards','standest','standleier','standorgel','stanzte',
|
||||||
|
'starrendem','startcodon','starten','startgerät','starttaste','statistik','statteten','staubfäden',
|
||||||
|
'staubmenge','staubwerte','stauern','staulängen','stauseen','steckfeld','stehhöhe','stehkanten',
|
||||||
|
'steileres','steinigste','steinsäge','stellplatz','stellteils','sterndame','sternhöhen','sternwalze',
|
||||||
|
'steuerndes','stibitzend','stichelns','stiefele','stielachse','stiftweg','stilecht','stilform',
|
||||||
|
'stilistik','stillere','stils','stöberte','stofftiere','störanteil','störberufs','störkaviar',
|
||||||
|
'störköchin','strafkarte','strafsache','straften','strahltet','strandtage','strebertyp','strebfront',
|
||||||
|
'streifige','streifzugs','streitwert','strengem','streukanal','streunst','strich','strichelst',
|
||||||
|
'strichen','strichhöhe','strickzeug','strolch','stromkabel','stromnetz','strünken','stufenkeil',
|
||||||
|
'stuhlbein','stulpe','stülpe','stummster','stumpf','stupendes','stupsers','stürmer',
|
||||||
|
'sturstes','sturzbades','stutzigen','stützwand','stylishe','subdiakon','subkomitee','sublunar',
|
||||||
|
'subsumtiv','subsysteme','subtype','suchbaum','suchbooten','suchkegels','suchraums','suchworte',
|
||||||
|
'sudan','südgruppe','südostecke','südostzone','südsterne','südufers','südwinden','südwinds',
|
||||||
|
'süffisance','sulz','sulzbach','sülzens','surfende','symbole','synagogale','synergetik',
|
||||||
|
'syrisch','systemband','tafelberge','tafelgüter','tagereisen','tageskarte','tageskauf','tagungsort',
|
||||||
|
'taillen','taktart','taktteilen','taktzahlen','talebenen','talwärts','tamilische','tannenbaum',
|
||||||
|
'tannenholz','tanzbarer','tanzfächer','tanzkorps','tanzpaare','tanzsäbel','tanzsalon','tappendes',
|
||||||
|
'tapste','tarantella','tarifliste','tariflos','tarnfirma','tasmanier','tastencode','tatbericht',
|
||||||
|
'tatmittels','taubem','taubergung','taufethik','taufname','taufsekte','taumeligen','taumelte',
|
||||||
|
'taurine','tautomerie','teakholzes','teddys','teebücher','teehandel','teepott','teetrinken',
|
||||||
|
'teiches','teigigstes','teilerfolg','teilfeldes','teilhaus','teilhäuser','teilköpfe','teilmarkt',
|
||||||
|
'teilneubau','teilnummer','teilsaldo','teilweisem','telejets','telekabels','tenor','tenside',
|
||||||
|
'termini','terrorist','testbuches','testeid','testklick','testphasen','testregion','testsatzes',
|
||||||
|
'testwert','texanerin','texaners','textwerk','textwolfs','theismus','tiananmen','tickender',
|
||||||
|
'tiefbeet','tiefebbe','tiefst','tiefsten','tierarten','tierbau','tierklasse','tierleiche',
|
||||||
|
'tiermähne','tierrecht','tierreihen','tierspiele','tierstiles','timmendorf','timorese','tintenfass',
|
||||||
|
'tippendem','tischdecke','tischfüße','titelrolle','titelteil','todeskarte','todesnot','tolerablem',
|
||||||
|
'tolerante','tolerierte','tollerem','tonblock','tönen','tongutes','tonkabine','tonpfanne',
|
||||||
|
'tonrufen','tonsignale','tonspuren','tonteils','tontellern','tontiegels','tontöpfe','tonträgern',
|
||||||
|
'tontrauben','topfdeckel','topfebenem','topfgucker','topfrand','toppende','torferden','torfheide',
|
||||||
|
'torläufen','tornetzes','torpaaren','torten','tosend','totarbeite','totoblöcke','tougherem',
|
||||||
|
'tradiertem','traghimmel','tragholm','tragtüte','tragzeit','trainerjob','traktrix','transpiler',
|
||||||
|
'trantüten','trauma','traumbades','träume','traumlied','traumloses','traumpilze','traumtext',
|
||||||
|
'traumtore','treibgas','treidle','trend','trennens','trenntaste','treueiden','treuerem',
|
||||||
|
'treysa','triangel','trinkkur','trippelnd','triptycha','trittfüße','tröglitz','trommelte',
|
||||||
|
'trompetest','tröstendem','trotzigste','tuchelle','tukan','tulpenrock','türblech','türglocke',
|
||||||
|
'türklopfer','turkmene','turmdächer','turmgruppe','türmst','turmuhr','turmwegen','turnsport',
|
||||||
|
'tuschmaler','tuschtest','tuskulaner','twitterin','twitterten','typisierst','überbauter','übergroßes',
|
||||||
|
'überhastet','überlaut','überludet','übernähe','überragte','überredete','überstreut','übertrat',
|
||||||
|
'überwachst','uferrand','uffenheims','uhrarmband','uhrganges','ulla','umbrauste','umdachten',
|
||||||
|
'umfliegen','umhörendem','umhörtest','umkehrte','umklammert','umkodierte','umlage','umlandes',
|
||||||
|
'umlegung','umleiten','umleitet','umlernt','umnietende','umpackende','umsäumend','umsäumte',
|
||||||
|
'umschütte','umsehende','umspulens','umspülte','umstimme','umtauscht','umwälzung','umwandler',
|
||||||
|
'umwehten','umzugs','umzukommen','unbesehene','unbespielt','unbewährt','unbewegt','unbewegten',
|
||||||
|
'unbrutalen','undeutlich','unduldsame','unedlerer','unerlöst','ungeahnten','ungerächte','ungestalte',
|
||||||
|
'ungesüßt','unheilbare','unhold','uniformes','unita','units','unitymedia','university',
|
||||||
|
'unmutes','unpaarer','unstern','unterbinde','unterbügel','untergärig','untergebot','unterlegte',
|
||||||
|
'unterleibs','unterphase','unterpreis','unterschuh','untertritt','unüblichem','unverholzt','unvermählt',
|
||||||
|
'urämisch','urämisches','urbildlich','urchigem','urnengefäß','urschrift','ursprunges','urszenen',
|
||||||
|
'urteilsakt','urvölkern','urweib','usuell','utes','vademecum','vakanzzeit','vakuumform',
|
||||||
|
'valerius','variierst','vegetativ','vehement','vektoren','velgast','ventildüse','verabredet',
|
||||||
|
'verbauten','verbleib','verblühtet','verbots','verbracht','verbundes','verdingte','verdünntem',
|
||||||
|
'vereint','verenge','verfallen','verfaulend','verfehlst','verfeme','verfilmens','verfolgers',
|
||||||
|
'verformst','verfrühtem','vergammeln','vergieße','verglastet','vergucktet','verharrtet','verheilte',
|
||||||
|
'verleidete','verlöschen','vermahlen','vermerke','vermodre','vermottete','vernarbtet','vernetzbar',
|
||||||
|
'vernetztes','verpackung','verpestung','verpolter','verquickt','verratzter','verrohten','verrußen',
|
||||||
|
'versäumen','versautes','verschifft','verschoss','versilbern','versintert','versprüht','verstandes',
|
||||||
|
'versuchens','vertilgst','vertragend','vertranken','vertreibe','vertust','verübelter','verübeltes',
|
||||||
|
'verunziert','verwehtet','verwerft','verwestes','verwiesest','verwirktes','verwitwt','verwühltem',
|
||||||
|
'verwüstest','verzagtet','verzapfter','verzerrtem','verzinsens','verzollbar','vianden','videochips',
|
||||||
|
'viehhüter','vierarmige','vierstufig','vikariaten','villen','violen','virtuose','virustypen',
|
||||||
|
'vizesenior','vogelblüte','vogelbrot','vogelwart','vöhringens','volksstand','vollbades','vollblutes',
|
||||||
|
'volldeich','vollendung','vollgarage','vollkotze','volllast','volltext','vollwellen','vorahnens',
|
||||||
|
'voranalyse','vorankamst','vorausahne','vorderberg','vordruckes','voreilst','voreilten','vorfelder',
|
||||||
|
'vorfluters','vorführe','vorgängige','vorgelegt','vorglühend','vorkauende','vorkauf','vorkeimens',
|
||||||
|
'vorladend','vorlieft','vormann','vornimmt','vorprellt','vorpumpe','vorrangige','vorräumen',
|
||||||
|
'vorschwebt','vorsetzt','vorsinge','vorstehend','vortaunus','vorteige','vortriebs','vortrockne',
|
||||||
|
'vorwerft','vorzieht','vorzugaren','vranitzky','wacherem','wachgebiet','wachküsst','wachruft',
|
||||||
|
'wachsmann','wackligste','wagenbuchs','wagenburg','wagenpanne','wagnissen','wahldingen','wahlfreier',
|
||||||
|
'wahloption','wahlrunden','wahlschein','wahlstadt','wahlweise','wahlzeugen','waidmann','walachei',
|
||||||
|
'waldbau','waldigem','waldkiefer','waldoption','waldtal','walnussöle','walzberge','walzgolde',
|
||||||
|
'walzgut','walzjahr','walzlagern','walzrad','wandelnden','wanderclub','wandle','wappensäle',
|
||||||
|
'warben','ware','wärmtet','warnton','warsteins','wartenden','warthe','waschplatz',
|
||||||
|
'waschseife','watte','webweisers','weckendem','wegbegabst','wegblickt','wegbrechen','wegdösten',
|
||||||
|
'wegföhnend','weggänge','weghörende','wegklicken','wegleitest','weglobe','weglobend','weglösung',
|
||||||
|
'wegpacken','wegputzt','wegrasiert','wegräumens','wegreist','wegsteckt','wegstücken','wegtupft',
|
||||||
|
'wegwandere','wegwarf','wehrerker','wehrhügels','wehst','wehtust','weichkäses','weichlötet',
|
||||||
|
'weidenhahn','weidenklee','weidmannes','weinladen','weinsee','weinsiegel','weinte','weintipp',
|
||||||
|
'weisem','weiß','weissagern','weißliste','weitergebt','weitung','welkere','wellbleche',
|
||||||
|
'wellenkeil','wellerbau','wellrad','weltdamen','weltkenner','weltkernen','weltkunst','weltlage',
|
||||||
|
'weltlehrer','weltnormen','weltseins','weltweiten','wendbar','wendelgang','wendigeren','wendigste',
|
||||||
|
'wendisch','wenigem','werbefirma','werbehymne','werbestar','werdens','werfall','werkareal',
|
||||||
|
'werkbezug','werks','werksbau','werktest','wertfreie','wertstoffs','wertwort','wesertals',
|
||||||
|
'westend','westrampe','wettercode','wetterten','wettiner','wichshilfe','wiesenbach','wildasyl',
|
||||||
|
'wildem','wildforst','wildsäue','wildtieres','wildtulpen','windelkopf','windflöten','windgasse',
|
||||||
|
'windgeist','windstaus','windstrom','winkelform','wirkten','wirrender','wirten','wirtschaft',
|
||||||
|
'wischt','wissenstyp','witwenball','witwer','wohnkultur','wohnmoduls','wohnwesen','wölbtest',
|
||||||
|
'wolframs','workaholic','wortarme','wortfülle','wortkarges','worttreue','wulstartig','wunderwege',
|
||||||
|
'wundnähte','würgegriff','wurmförmig','wurmtet','wursthorn','würzburger','würzigere','wuseln',
|
||||||
|
'wüstenrose','wüstesten','wuterfüllt','wutproben','xenophon','yards','ypsilon','zaberner',
|
||||||
|
'zähigkeit','zahler','zahlung','zählungen','zahme','zahnarztes','zahnbettes','zahnfarben',
|
||||||
|
'zahngrund','zahnherd','zahnmark','zakopane','zapfenloch','zartblaue','zartgefühl','zäsiums',
|
||||||
|
'zaunbrett','zaza','zechte','zedong','zehnworte','zeitlosem','zeitsinn','zeitstufen',
|
||||||
|
'zeitstunde','zeittarifs','zellaltern','zeltartig','zensier','zentralbau','zerbeiß','zerbrächet',
|
||||||
|
'zerfetze','zerklopfst','zernagens','zerreib','zerrte','zersiedeln','zerstören','zerstreute',
|
||||||
|
'zerteilbar','zeuthen','zickigem','ziegeln','zielloches','zielorgans','ziemern','zier',
|
||||||
|
'ziererkern','zierlicher','ziermotive','zierstein','zierstücks','zigarillos','zilli','zilpzalpen',
|
||||||
|
'zimbeln','zimmerten','zimtwasser','zinkdächer','zinkoxide','zinksarg','zinnabbau','zinngefäße',
|
||||||
|
'zinngießen','zinntypen','zissoide','zither','ziviler','zivilpakt','zockereien','zölibatär',
|
||||||
|
'zollflagge','zöllige','zollteich','zolltor','zonendatei','zoophiler','zopfende','zornrote',
|
||||||
|
'zubilligt','zucchino','züchtigend','zuckerteil','zudeckten','zufächeln','zugantenne','zugballs',
|
||||||
|
'zugchefs','zugdrahts','zugesellt','zugestoßen','zugezogen','zugfest','zugregel','zugschirme',
|
||||||
|
'zugteilung','zugwurzel','zuhakens','zuklappen','zulasst','zunähend','zünddüse','zündgase',
|
||||||
|
'zündstufe','zunftlade','zungenbein','zurechnung','zureite','zurrbügeln','zurrbügels','zuschissen',
|
||||||
|
'zuschnitte','zusendens','zusieht','zustiegst','zustoßende','zustreben','zuteil','zuteilens',
|
||||||
|
'zutextete','zutrauen','zutritt','zuwanderst','zuziehung','zuzügliche','zuzunicken','zwanglosem',
|
||||||
|
'zwängten','zwecklosen','zweibach','zweibänder','zweifelst','zweitmiete','zwergklee','zwiefacher',
|
||||||
|
];
|
||||||
BIN
public/s/vcr.ttf
Normal file
63
public/sw.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const CACHE_NAME = 'w0bm-pwa-v8';
|
||||||
|
const ASSETS_TO_CACHE = [
|
||||||
|
'/',
|
||||||
|
'/s/css/f0ckm.css',
|
||||||
|
'/s/js/f0ckm.js',
|
||||||
|
'/s/img/favicon.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing v8...');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return Promise.allSettled(
|
||||||
|
ASSETS_TO_CACHE.map(url => cache.add(url).catch(err => console.warn('[SW] Failed to cache:', url, err)))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating v8...');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
if (cacheName !== CACHE_NAME) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cached = await caches.match(event.request);
|
||||||
|
return cached || fetch(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (ASSETS_TO_CACHE.includes(url.pathname)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((response) => {
|
||||||
|
return response || fetch(event.request).catch(() => new Response('Asset not found', { status: 404 }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
23
scripts/build-css.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const cssDir = path.join(process.cwd(), 'public/s/css');
|
||||||
|
const files = ['f0ckm.css', 'v0ck.css'];
|
||||||
|
const outputFile = path.join(cssDir, 'bundle.css');
|
||||||
|
|
||||||
|
let combined = '';
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const content = fs.readFileSync(path.join(cssDir, file), 'utf8');
|
||||||
|
combined += content + '\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple minification
|
||||||
|
const minified = combined
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove comments
|
||||||
|
.replace(/\s*([\{\}:;,])\s*/g, '$1') // Remove whitespace around selectors/properties
|
||||||
|
.replace(/\s+/g, ' ') // Collapse multiple whitespaces
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
fs.writeFileSync(outputFile, minified);
|
||||||
|
console.log(`Bundle created: ${outputFile} (${combined.length} -> ${minified.length} bytes)`);
|
||||||
63
scripts/cleanup_covers.mjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import cfg from "../src/inc/config.mjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("[BOOT] Starting coverart cleanup and migration...");
|
||||||
|
|
||||||
|
// 1. Get all audio items
|
||||||
|
const audioItems = await db`SELECT id, dest FROM items WHERE mime LIKE 'audio/%' AND is_deleted = false`;
|
||||||
|
console.log(`[INFO] Found ${audioItems.length} audio items to evaluate.`);
|
||||||
|
|
||||||
|
const PLACEHOLDER_SIZE = 649524; // Exact size of music.webp
|
||||||
|
const OLD_PLACEHOLDER_MAX = 5000; // Max size of old gray placeholder
|
||||||
|
|
||||||
|
let uniqueCount = 0;
|
||||||
|
let placeholderCount = 0;
|
||||||
|
let missingCount = 0;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const item of audioItems) {
|
||||||
|
process.stdout.write(`\r[${++count}/${audioItems.length}] Evaluating ${item.id}...`);
|
||||||
|
|
||||||
|
const caPath = path.join(cfg.paths.ca, `${item.id}.webp`);
|
||||||
|
let hasUniqueArt = false;
|
||||||
|
|
||||||
|
if (fs.existsSync(caPath)) {
|
||||||
|
const stats = fs.statSync(caPath);
|
||||||
|
if (stats.size === PLACEHOLDER_SIZE || stats.size < OLD_PLACEHOLDER_MAX) {
|
||||||
|
// It's a placeholder (new or old)
|
||||||
|
// Delete it and set flag to false
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(caPath);
|
||||||
|
placeholderCount++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n[ERROR] Failed to delete ${item.id} placeholder:`, err.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's likely a unique extracted cover
|
||||||
|
hasUniqueArt = true;
|
||||||
|
uniqueCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
missingCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await db`UPDATE items SET has_coverart = ${hasUniqueArt} WHERE id = ${item.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n[FINISH] Migration Complete.");
|
||||||
|
console.log(`[STATS] Total: ${audioItems.length}`);
|
||||||
|
console.log(`[STATS] Unique Covers Kept: ${uniqueCount}`);
|
||||||
|
console.log(`[STATS] Placeholders Deleted: ${placeholderCount}`);
|
||||||
|
console.log(`[STATS] Missing/Already Clean: ${missingCount}`);
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error("[FATAL]", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
53
scripts/recreate_covers.mjs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import cfg from "../src/inc/config.mjs";
|
||||||
|
import queue from "../src/inc/queue.mjs";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log("[BOOT] Starting coverart recreation script...");
|
||||||
|
const audioItems = await db`SELECT id, dest, mime FROM items WHERE mime LIKE 'audio/%' AND is_deleted = false`;
|
||||||
|
console.log(`[INFO] Found ${audioItems.length} audio items.`);
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const item of audioItems) {
|
||||||
|
process.stdout.write(`\r[${++count}/${audioItems.length}] Processing ${item.id}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caPath = path.join(cfg.paths.ca, `${item.id}.webp`);
|
||||||
|
const tPath = path.join(cfg.paths.t, `${item.id}.webp`);
|
||||||
|
|
||||||
|
let needsProcessing = !fs.existsSync(caPath);
|
||||||
|
if (!needsProcessing) {
|
||||||
|
const stats = fs.statSync(caPath);
|
||||||
|
if (stats.size < 5000) needsProcessing = true; // Detect old gray placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsProcessing) {
|
||||||
|
try {
|
||||||
|
// Try extraction first
|
||||||
|
await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
|
||||||
|
} catch (e) {
|
||||||
|
// If extraction fails, use the music.webp fallback
|
||||||
|
await fs.promises.copyFile('public/s/img/music.webp', caPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(tPath)) {
|
||||||
|
try {
|
||||||
|
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', tPath]);
|
||||||
|
} catch (err) { }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`\n[ERROR] ${item.id} processing failed:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n[FINISH] All audio items processed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
console.error("[FATAL]", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
87
scripts/recreate_thumbnail.mjs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Regenerate thumbnails and coverart for specific items.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node regen.mjs <itemid> - Regenerate a single item
|
||||||
|
* node regen.mjs <id1> <id2> ... - Regenerate multiple items
|
||||||
|
* node regen.mjs --all - Regenerate ALL items
|
||||||
|
* node regen.mjs --audio - Regenerate all audio items
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from "./src/inc/sql.mjs";
|
||||||
|
import queue from "./src/inc/queue.mjs";
|
||||||
|
import cfg from "./src/inc/config.mjs";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.log('Usage:');
|
||||||
|
console.log(' node regen.mjs <itemid> - Regenerate a single item');
|
||||||
|
console.log(' node regen.mjs <id1> <id2> ... - Regenerate multiple items');
|
||||||
|
console.log(' node regen.mjs --all - Regenerate ALL items');
|
||||||
|
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const regen = async (item) => {
|
||||||
|
const { id, dest, mime, src } = item;
|
||||||
|
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queue.genThumbnail(dest, mime, id, src || '', false);
|
||||||
|
|
||||||
|
if (mime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||||
|
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${id}`;
|
||||||
|
console.log(`[${id}] ✓ Cover art extracted and saved`);
|
||||||
|
} else if (mime.startsWith('audio/')) {
|
||||||
|
await db`UPDATE items SET has_coverart = FALSE WHERE id = ${id}`;
|
||||||
|
console.log(`[${id}] ✓ No cover art found, placeholder generated`);
|
||||||
|
} else {
|
||||||
|
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate blurred thumbnail if item has NSFW tag
|
||||||
|
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
|
||||||
|
if (nsfw.length > 0) {
|
||||||
|
await queue.genBlurredThumbnail(id, false);
|
||||||
|
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let items;
|
||||||
|
|
||||||
|
if (args.includes('--all')) {
|
||||||
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
|
||||||
|
console.log(`Regenerating ALL ${items.length} items...\n`);
|
||||||
|
} else if (args.includes('--audio')) {
|
||||||
|
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
|
||||||
|
console.log(`Regenerating ${items.length} audio items...\n`);
|
||||||
|
} else {
|
||||||
|
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
console.error('No valid item IDs provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
items = await db`SELECT id, dest, mime, src FROM items WHERE id IN ${db(ids)} ORDER BY id`;
|
||||||
|
const found = items.map(i => i.id);
|
||||||
|
const missing = ids.filter(id => !found.includes(id));
|
||||||
|
if (missing.length) console.warn(`Items not found: ${missing.join(', ')}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
await regen(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. ${items.length} items processed.`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
233
src/avatar_handler.mjs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import { execFile as _execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
|
// Multi-part parsing logic removed, using shared imports
|
||||||
|
|
||||||
|
// Helper for JSON response
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate UUID using the same method as video uploads
|
||||||
|
const genuuid = async () => {
|
||||||
|
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAvatarUpload = async (req, res) => {
|
||||||
|
console.log('[AVATAR HANDLER] Upload started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
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_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
console.log('[AVATAR HANDLER] Unauthorized - No valid session found');
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
console.log('[AVATAR HANDLER] Authorized:', req.session.user);
|
||||||
|
|
||||||
|
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked avatar upload for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
console.log('[AVATAR HANDLER] No boundary');
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AVATAR HANDLER] Collecting body...');
|
||||||
|
const body = await collectBody(req);
|
||||||
|
console.log('[AVATAR HANDLER] Body collected, size:', body.length);
|
||||||
|
|
||||||
|
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||||
|
const file = parts.file;
|
||||||
|
|
||||||
|
if (!file || !file.data) {
|
||||||
|
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB max)
|
||||||
|
const maxSize = 5 * 1024 * 1024;
|
||||||
|
if (file.data.length > maxSize) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed MIME types
|
||||||
|
const allowedMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate MIME type from content-type header
|
||||||
|
let mime = file.contentType.toLowerCase();
|
||||||
|
if (!allowedMimes.includes(mime)) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to tmp and verify with file magic
|
||||||
|
const uuid = await genuuid();
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp, `avatar_${uuid}_tmp`);
|
||||||
|
const finalFilename = `${uuid}.webp`;
|
||||||
|
const finalPath = path.join(cfg.paths.a, finalFilename);
|
||||||
|
|
||||||
|
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||||
|
await fs.mkdir(cfg.paths.a, { recursive: true });
|
||||||
|
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify MIME with file magic
|
||||||
|
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||||
|
const allowedActualMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current avatar_file to delete old one
|
||||||
|
const currentAvatar = (await db`
|
||||||
|
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.avatar_file;
|
||||||
|
|
||||||
|
// Convert to webp using ImageMagick with coalesce for GIF handling
|
||||||
|
try {
|
||||||
|
await execFile('magick', [tmpPath, '-coalesce', '-resize', '256x256^', '-gravity', 'center', '-extent', '256x256', '-quality', '50', finalPath]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AVATAR HANDLER] Magick error:', err);
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tmp file
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
|
||||||
|
// Delete old avatar file if exists (except default.png)
|
||||||
|
if (currentAvatar && currentAvatar !== 'default.png') {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentAvatar);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database — clear item-based avatar so custom file takes priority
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set avatar_file = ${finalFilename},
|
||||||
|
avatar = NULL
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
||||||
|
return sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
avatar_file: finalFilename,
|
||||||
|
msg: 'Avatar uploaded successfully'
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'BODY_TOO_LARGE') {
|
||||||
|
return sendJson(res, { success: false, msg: 'File too large (5 MB max for avatars)' }, 413);
|
||||||
|
}
|
||||||
|
console.error('[AVATAR HANDLER ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, msg: lib.logError(err, 'Avatar Upload failed') }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleAvatarDelete = async (req, res) => {
|
||||||
|
console.log('[AVATAR HANDLER] Delete started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
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_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked avatar delete for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentAvatar = (await db`
|
||||||
|
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.avatar_file;
|
||||||
|
|
||||||
|
if (currentAvatar && currentAvatar !== 'default.png') {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentAvatar);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set avatar_file = null
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('[AVATAR HANDLER] Delete complete');
|
||||||
|
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AVATAR DELETE ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to remove avatar' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
110
src/emoji_upload_handler.mjs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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 { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEmojiUpload = async (req, res) => {
|
||||||
|
console.error('[BOOT] [EMOJI HANDLER] Started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0 || !user[0].admin) {
|
||||||
|
console.error('[BOOT] [EMOJI HANDLER] Unauthorized');
|
||||||
|
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked emoji upload for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundary = boundaryMatch[1].trim();
|
||||||
|
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||||
|
boundary = boundary.substring(1, boundary.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyBuffer = await collectBody(req);
|
||||||
|
const parts = parseMultipart(bodyBuffer, boundary);
|
||||||
|
|
||||||
|
const name = (parts.name || '').trim().toLowerCase();
|
||||||
|
let url = (parts.url || '').trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||||
|
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = parts.file;
|
||||||
|
if (file && file.data && file.data.length > 0) {
|
||||||
|
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||||
|
const ext = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||||
|
const filename = `${name}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||||
|
|
||||||
|
// Emojis go to public/s/emojis
|
||||||
|
const filePath = path.join(cfg.paths.emojis, filename);
|
||||||
|
console.error(`[BOOT] [EMOJI HANDLER] Writing file to: ${filePath} (Size: ${file.data.length})`);
|
||||||
|
await fs.writeFile(filePath, file.data);
|
||||||
|
|
||||||
|
// Verify write
|
||||||
|
const exists = (await fs.stat(filePath)).size > 0;
|
||||||
|
if (!exists) throw new Error("File write verification failed");
|
||||||
|
|
||||||
|
url = `/s/emojis/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return sendJson(res, { success: false, message: 'Either image URL or File is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmoji = await db`
|
||||||
|
INSERT INTO custom_emojis (name, url)
|
||||||
|
VALUES (${name}, ${url})
|
||||||
|
RETURNING id, name, url
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.error(`[BOOT] [EMOJI HANDLER] Success: ${name}`);
|
||||||
|
await db`NOTIFY emojis_updated, '{}'`;
|
||||||
|
return sendJson(res, { success: true, emoji: newEmoji[0] });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||||
|
}
|
||||||
|
console.error('[EMOJI HANDLER ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, message: err.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
296
src/hall_image_handler.mjs
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import audit from "./inc/audit.mjs";
|
||||||
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import { execFile as _execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
|
const HALL_IMG_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||||
|
const HALL_CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||||
|
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Direct DB session lookup — same pattern as emoji_upload_handler.mjs
|
||||||
|
const lookupSession = async (req) => {
|
||||||
|
if (!req.cookies || !req.cookies.session) return null;
|
||||||
|
const users = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator,
|
||||||
|
"user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
return users.length > 0 ? users[0] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHallCache = async (hallSlug) => {
|
||||||
|
const { createHash } = await import('crypto');
|
||||||
|
for (const mode of [0, 1, 2]) {
|
||||||
|
const hash = createHash('md5').update(hallSlug + '_' + mode).digest('hex');
|
||||||
|
await fs.unlink(path.join(HALL_CACHE_DIR, hash + '.webp')).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/v2/admin/halls/:slug/image — upload a custom image for a hall
|
||||||
|
export const handleHallImageUpload = async (req, res) => {
|
||||||
|
console.error('[BOOT] [HALL IMG] Upload started');
|
||||||
|
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) {
|
||||||
|
console.error('[BOOT] [HALL IMG] Unauthorized');
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hallSlug = req.params && req.params.slug;
|
||||||
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||||
|
|
||||||
|
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
||||||
|
if (!hall.length) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||||
|
if (!boundaryMatch) return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
|
||||||
|
let boundary = boundaryMatch[1].trim();
|
||||||
|
if (boundary.startsWith('"') && boundary.endsWith('"')) boundary = boundary.slice(1, -1);
|
||||||
|
|
||||||
|
const body = await collectBody(req);
|
||||||
|
const parts = parseMultipart(body, boundary);
|
||||||
|
const file = parts.file;
|
||||||
|
|
||||||
|
if (!file || !file.data) return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
|
if (file.data.length > 10 * 1024 * 1024) return sendJson(res, { success: false, msg: 'File too large (10 MB max)' }, 400);
|
||||||
|
|
||||||
|
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||||
|
const fileMime = (file.contentType || '').toLowerCase().trim();
|
||||||
|
if (!allowedMimes.includes(fileMime))
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid file type: ' + fileMime }, 400);
|
||||||
|
|
||||||
|
await fs.mkdir(HALL_IMG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp || '/tmp', 'hall_img_' + hallSlug + '_tmp');
|
||||||
|
const finalFilename = hallSlug + '.webp';
|
||||||
|
const finalPath = path.join(HALL_IMG_DIR, finalFilename);
|
||||||
|
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify with file magic
|
||||||
|
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||||
|
if (!['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(actualMime.trim())) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid file type: ' + actualMime.trim() }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize to 600x300 webp
|
||||||
|
// -coalesce is required for animated GIFs: it composites delta frames into full frames
|
||||||
|
// before resizing, preventing heavy artifacts. Output is animated WebP for GIFs.
|
||||||
|
try {
|
||||||
|
await execFile('magick', [tmpPath, '-coalesce', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', '-quality', '85', finalPath]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HALL IMG] Magick error:', err.stderr || err.message);
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||||
|
}
|
||||||
|
await fs.unlink(tmpPath).catch(() => {});
|
||||||
|
|
||||||
|
await db`UPDATE halls SET custom_image = ${finalFilename} WHERE slug = ${hallSlug}`;
|
||||||
|
await clearHallCache(hallSlug);
|
||||||
|
|
||||||
|
console.error('[BOOT] [HALL IMG] Uploaded custom image for:', hallSlug);
|
||||||
|
return sendJson(res, { success: true, msg: 'Image uploaded', file: finalFilename });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HALL IMG] Upload error:', err);
|
||||||
|
return sendJson(res, { success: false, msg: err.message || 'Upload failed' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/v2/admin/halls/:slug/image — remove custom image
|
||||||
|
export const handleHallImageDelete = async (req, res) => {
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hallSlug = req.params && req.params.slug;
|
||||||
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||||
|
|
||||||
|
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
||||||
|
if (!hall.length) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
||||||
|
|
||||||
|
// Always delete the canonical {slug}.webp from disk regardless of DB value
|
||||||
|
const filePath = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
console.error('[HALL IMG] Deleted file:', filePath);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== 'ENOENT') {
|
||||||
|
console.error('[HALL IMG] Failed to delete file:', filePath, e.message);
|
||||||
|
}
|
||||||
|
// ENOENT = already gone, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete whatever the DB says (in case it differs)
|
||||||
|
if (hall[0].custom_image && hall[0].custom_image !== hallSlug + '.webp') {
|
||||||
|
await fs.unlink(path.join(HALL_IMG_DIR, hall[0].custom_image)).catch((e) => {
|
||||||
|
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to delete legacy file:', e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`UPDATE halls SET custom_image = NULL WHERE slug = ${hallSlug}`;
|
||||||
|
await clearHallCache(hallSlug);
|
||||||
|
|
||||||
|
return sendJson(res, { success: true, msg: 'Custom image removed' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// DELETE /api/v2/admin/halls/:slug — delete a hall entirely
|
||||||
|
export const handleHallDelete = async (req, res) => {
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hallSlug = req.params && req.params.slug;
|
||||||
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||||
|
|
||||||
|
await db`DELETE FROM halls_assign WHERE hall_id = (SELECT id FROM halls WHERE slug = ${hallSlug})`.catch(() => {});
|
||||||
|
await db`DELETE FROM halls WHERE slug = ${hallSlug}`;
|
||||||
|
|
||||||
|
await fs.unlink(path.join(HALL_IMG_DIR, hallSlug + '.webp')).catch(() => {});
|
||||||
|
await clearHallCache(hallSlug);
|
||||||
|
|
||||||
|
console.error('[BOOT] [HALL] Deleted hall:', hallSlug);
|
||||||
|
return sendJson(res, { success: true, msg: 'Hall deleted' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// PATCH /api/v2/admin/halls/:slug — update name/description/slug
|
||||||
|
export const handleHallUpdate = async (req, res) => {
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hallSlug = req.params && req.params.slug;
|
||||||
|
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing slug' }, 400);
|
||||||
|
|
||||||
|
// For PATCH requests, parse body manually since this is a bypass handler
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = await collectBody(req);
|
||||||
|
body = JSON.parse(raw.toString('utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
body = req.post || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, description } = body;
|
||||||
|
const rawRating = body.rating;
|
||||||
|
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : null;
|
||||||
|
const rawSlug = body.slug;
|
||||||
|
|
||||||
|
// Handle slug rename
|
||||||
|
if (rawSlug !== undefined) {
|
||||||
|
const newSlug = rawSlug.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (!newSlug) return sendJson(res, { success: false, msg: 'Slug cannot be empty' }, 400);
|
||||||
|
|
||||||
|
if (newSlug !== hallSlug) {
|
||||||
|
// Check for conflict with another hall
|
||||||
|
const conflict = await db`SELECT id FROM halls WHERE slug = ${newSlug} LIMIT 1`;
|
||||||
|
if (conflict.length > 0) {
|
||||||
|
return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename custom image file on disk
|
||||||
|
const oldFile = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
||||||
|
const newFile = path.join(HALL_IMG_DIR, newSlug + '.webp');
|
||||||
|
try {
|
||||||
|
await fs.rename(oldFile, newFile);
|
||||||
|
console.error('[HALL IMG] Renamed image:', hallSlug + '.webp', '->', newSlug + '.webp');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to rename image:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update slug in DB and fix custom_image filename if set
|
||||||
|
await db`
|
||||||
|
UPDATE halls
|
||||||
|
SET slug = ${newSlug},
|
||||||
|
custom_image = CASE WHEN custom_image IS NOT NULL THEN ${newSlug + '.webp'} ELSE NULL END
|
||||||
|
WHERE slug = ${hallSlug}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await clearHallCache(hallSlug);
|
||||||
|
await clearHallCache(newSlug);
|
||||||
|
|
||||||
|
// Update name/description/rating under the new slug
|
||||||
|
if (name !== undefined && name.trim()) {
|
||||||
|
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${newSlug}`;
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
await db`UPDATE halls SET description = ${description} WHERE slug = ${newSlug}`;
|
||||||
|
}
|
||||||
|
if (rating) {
|
||||||
|
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${newSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.log(session.id, 'rename_hall', 'hall', newSlug, { old_slug: hallSlug, new_slug: newSlug, name: name?.trim(), description });
|
||||||
|
return sendJson(res, { success: true, msg: 'Hall updated', newSlug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No slug change — just update name/description/rating
|
||||||
|
if (name !== undefined && name.trim()) {
|
||||||
|
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${hallSlug}`;
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
await db`UPDATE halls SET description = ${description} WHERE slug = ${hallSlug}`;
|
||||||
|
}
|
||||||
|
if (rating) {
|
||||||
|
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${hallSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await audit.log(session.id, 'update_hall', 'hall', hallSlug, { name: name?.trim(), description });
|
||||||
|
return sendJson(res, { success: true, msg: 'Hall updated' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/v2/admin/halls — create a new hall
|
||||||
|
export const handleHallCreate = async (req, res) => {
|
||||||
|
const session = await lookupSession(req);
|
||||||
|
if (!session || (!session.admin && !session.is_moderator)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
const raw = await collectBody(req);
|
||||||
|
body = JSON.parse(raw.toString('utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (body.name || '').trim();
|
||||||
|
const slug = (body.slug || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
const rawRating = body.rating;
|
||||||
|
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : 'sfw';
|
||||||
|
|
||||||
|
if (!name) return sendJson(res, { success: false, msg: 'Name is required' }, 400);
|
||||||
|
if (!slug) return sendJson(res, { success: false, msg: 'Could not derive a valid slug from the name' }, 400);
|
||||||
|
|
||||||
|
// Check for duplicate slug
|
||||||
|
const existing = await db`SELECT id FROM halls WHERE slug = ${slug} LIMIT 1`;
|
||||||
|
if (existing.length > 0) return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
||||||
|
|
||||||
|
await db`INSERT INTO halls (name, slug, description, rating) VALUES (${name}, ${slug}, '', ${rating})`;
|
||||||
|
|
||||||
|
await audit.log(session.id, 'create_hall', 'hall', slug, { name, slug });
|
||||||
|
console.error('[HALL] Created hall:', slug, '-', name);
|
||||||
|
return sendJson(res, { success: true, msg: 'Hall created', slug });
|
||||||
|
};
|
||||||
22
src/inc/admin.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import config from "./config.mjs";
|
||||||
|
|
||||||
|
export const getLevel = user => {
|
||||||
|
let ret = {
|
||||||
|
level: 0,
|
||||||
|
verified: false
|
||||||
|
};
|
||||||
|
if (typeof user !== "object")
|
||||||
|
return "user has to be an object!";
|
||||||
|
if (!user.prefix)
|
||||||
|
return ret;
|
||||||
|
|
||||||
|
let admin;
|
||||||
|
if(admin = config.admins.filter(e => e.prefix === user.prefix)[0]) {
|
||||||
|
ret = {
|
||||||
|
level: admin.level,
|
||||||
|
verified: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
23
src/inc/audit.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import db from "./sql.mjs";
|
||||||
|
|
||||||
|
export default new class {
|
||||||
|
/**
|
||||||
|
* Log an action to the audit log.
|
||||||
|
* @param {number} userId - The ID of the user performing the action.
|
||||||
|
* @param {string} action - Short description of the action (e.g. 'delete_item').
|
||||||
|
* @param {string} targetType - The type of target (e.g. 'item', 'comment', 'user').
|
||||||
|
* @param {string} targetId - The ID of the target.
|
||||||
|
* @param {object} details - Additional JSON details.
|
||||||
|
*/
|
||||||
|
async log(userId, action, targetType, targetId, details = {}) {
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
INSERT INTO audit_log (user_id, action, target_type, target_id, details)
|
||||||
|
VALUES (${userId}, ${action}, ${targetType}, ${targetId}, ${db.json(details)})
|
||||||
|
`;
|
||||||
|
console.log(`[AUDIT] User ${userId} performed ${action} on ${targetType}:${targetId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AUDIT] Failed to write log:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
32
src/inc/autotagger.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import fetch from 'flumm-fetch';
|
||||||
|
import cfg from './config.mjs';
|
||||||
|
|
||||||
|
export default new class autotagger {
|
||||||
|
async isNSFW(filename, filesize) {
|
||||||
|
let opts = {
|
||||||
|
method: 'POST',
|
||||||
|
};
|
||||||
|
let apiurl;
|
||||||
|
if(filesize < 4194304) {
|
||||||
|
apiurl = cfg.apis.nsfw1.url;
|
||||||
|
opts.headers = cfg.apis.nsfw1.headers;
|
||||||
|
opts.body = JSON.stringify({
|
||||||
|
DataRepresentation: "URL",
|
||||||
|
Value: `${cfg.main.url.full}/b/${filename}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
apiurl = cfg.apis.nsfw2.url;
|
||||||
|
opts.headers = cfg.apis.nsfw2.headers;
|
||||||
|
opts.body = JSON.stringify({
|
||||||
|
url: `${cfg.main.url.full}/b/${filename}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const res = await (await fetch(apiurl, opts)).json();
|
||||||
|
|
||||||
|
if(filesize < 4194304)
|
||||||
|
return res.IsImageAdultClassified || res.RacyClassificationScore > 0.6;
|
||||||
|
else
|
||||||
|
return res.unsafe;
|
||||||
|
};
|
||||||
|
};
|
||||||
53
src/inc/config.mjs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import _config from "../../config.json" with { type: "json" };
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
let config = JSON.parse(JSON.stringify(_config));
|
||||||
|
|
||||||
|
// Environment variable overrides for database connection
|
||||||
|
if (process.env.DB_HOST) config.sql.host = process.env.DB_HOST;
|
||||||
|
if (process.env.DB_USER) config.sql.user = process.env.DB_USER;
|
||||||
|
if (process.env.DB_PASS) config.sql.password = process.env.DB_PASS;
|
||||||
|
if (process.env.DB_NAME) config.sql.database = process.env.DB_NAME;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
config.main.development = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = path.resolve();
|
||||||
|
const storage = process.env.STORAGE_DIR;
|
||||||
|
|
||||||
|
const resolvePath = (defaultRel) => {
|
||||||
|
const local = path.resolve(path.join(base, defaultRel));
|
||||||
|
if (storage) {
|
||||||
|
const absStorage = path.resolve(storage);
|
||||||
|
if (defaultRel.startsWith('public/')) {
|
||||||
|
const sub = defaultRel.replace('public/', '');
|
||||||
|
if (sub === 's/emojis' || sub === 's/koepfe') {
|
||||||
|
const storagePath = path.join(absStorage, sub.split('/').pop());
|
||||||
|
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
return path.resolve(path.join(absStorage, sub));
|
||||||
|
}
|
||||||
|
return path.resolve(path.join(absStorage, defaultRel));
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
};
|
||||||
|
|
||||||
|
config.paths = {
|
||||||
|
a: resolvePath('public/a'),
|
||||||
|
b: resolvePath('public/b'),
|
||||||
|
t: resolvePath('public/t'),
|
||||||
|
ca: resolvePath('public/ca'),
|
||||||
|
s: path.join(base, 'public/s'),
|
||||||
|
emojis: resolvePath('public/s/emojis'),
|
||||||
|
koepfe: resolvePath('public/s/koepfe'),
|
||||||
|
memes: resolvePath('public/memes'),
|
||||||
|
pending: resolvePath('pending'),
|
||||||
|
deleted: resolvePath('deleted'),
|
||||||
|
logs: resolvePath('logs'),
|
||||||
|
tmp: resolvePath('tmp')
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
316
src/inc/events/callback_query.mjs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import logger from "../log.mjs";
|
||||||
|
import { getLevel } from "../../inc/admin.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import cfg from "../config.mjs";
|
||||||
|
import db from "../sql.mjs";
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const tagkeyboard = id => {
|
||||||
|
const tags = [{
|
||||||
|
tag: 'music',
|
||||||
|
id: 9124
|
||||||
|
}, {
|
||||||
|
tag: 'german',
|
||||||
|
id: 9161
|
||||||
|
}, {
|
||||||
|
tag: 'cat',
|
||||||
|
id: 559
|
||||||
|
}, {
|
||||||
|
tag: 'doggo',
|
||||||
|
id: 10932
|
||||||
|
}];
|
||||||
|
return Promise.all(tags.map(async t => ({ text: `${await lib.hasTag(id, t.id) ? '✓ ' : ''}${t.tag}`, callback_data: `b_settag_${t.id}:${id}` })));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async bot => {
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: "callback_query",
|
||||||
|
listener: "callback_query",
|
||||||
|
f: async e => {
|
||||||
|
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`);
|
||||||
|
|
||||||
|
let [cmd, id] = e.opt.data.split(':');
|
||||||
|
let f0ck;
|
||||||
|
id = +id;
|
||||||
|
|
||||||
|
if (cmd.startsWith('b_settag_')) {
|
||||||
|
const tagid = +cmd.replace('b_settag_', '');
|
||||||
|
|
||||||
|
if (!(await lib.getTags(id)).filter(tag => tag.id == tagid).length) {
|
||||||
|
// insert
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${db({
|
||||||
|
item_id: id,
|
||||||
|
tag_id: tagid,
|
||||||
|
user_id: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// delete
|
||||||
|
await db`
|
||||||
|
delete from "tags_assign"
|
||||||
|
where tag_id = ${tagid}
|
||||||
|
and item_id = ${id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboard = await tagkeyboard(id);
|
||||||
|
|
||||||
|
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
...keyboard
|
||||||
|
], [
|
||||||
|
{ text: 'back', callback_data: `b_back:${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "b_tags":
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const keyboard = await tagkeyboard(id);
|
||||||
|
|
||||||
|
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
...keyboard
|
||||||
|
], [
|
||||||
|
{ text: 'back', callback_data: `b_back:${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "b_back":
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||||
|
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||||
|
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||||
|
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||||
|
], [
|
||||||
|
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "b_sfw":
|
||||||
|
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!await lib.hasTag(id, 1)) {
|
||||||
|
// insert
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${db({
|
||||||
|
item_id: id,
|
||||||
|
tag_id: 1, // sfw
|
||||||
|
user_id: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
if (await lib.hasTag(id, 2)) {
|
||||||
|
await db`
|
||||||
|
delete from "tags_assign"
|
||||||
|
where tag_id = 2
|
||||||
|
and item_id = ${id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// delete
|
||||||
|
await db`
|
||||||
|
delete from "tags_assign"
|
||||||
|
where tag_id = 1
|
||||||
|
and item_id = ${id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||||
|
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||||
|
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||||
|
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||||
|
], [
|
||||||
|
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
case "b_nsfw":
|
||||||
|
if (!id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!await lib.hasTag(id, 2)) {
|
||||||
|
// insert
|
||||||
|
await db`
|
||||||
|
insert into "tags_assign" ${db({
|
||||||
|
item_id: id,
|
||||||
|
tag_id: 2, // nsfw
|
||||||
|
user_id: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
if (await lib.hasTag(id, 1)) {
|
||||||
|
await db`
|
||||||
|
delete from "tags_assign"
|
||||||
|
where tag_id = 1
|
||||||
|
and item_id = ${id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// delete
|
||||||
|
await db`
|
||||||
|
delete from "tags_assign"
|
||||||
|
where tag_id = 2
|
||||||
|
and item_id = ${id}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||||
|
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||||
|
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||||
|
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||||
|
], [
|
||||||
|
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "b_delete":
|
||||||
|
if (id <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.user = {
|
||||||
|
prefix: `${e.raw.reply_to_message.from.username}!${e.raw.reply_to_message.from.id}@${e.network}`,
|
||||||
|
nick: e.raw.reply_to_message.from.first_name,
|
||||||
|
username: e.raw.reply_to_message.from.username,
|
||||||
|
account: e.raw.reply_to_message.from.id.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
f0ck = await db`
|
||||||
|
select dest, mime, username, userchannel, usernetwork
|
||||||
|
from "items"
|
||||||
|
where
|
||||||
|
id = ${id} and
|
||||||
|
active = 'true'
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
const level = getLevel(e.user).level;
|
||||||
|
|
||||||
|
if (f0ck.length === 0) {
|
||||||
|
return await e.reply(`f0ck ${id}: f0ck not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
||||||
|
f0ck[0].userchannel !== e.channel ||
|
||||||
|
f0ck[0].usernetwork !== e.network) &&
|
||||||
|
level < 100
|
||||||
|
) {
|
||||||
|
return await e.reply(`f0ck ${id}: insufficient permissions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) {
|
||||||
|
return await e.reply(`f0ck ${id}: too late lol`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`update "items" set active = 'false', is_deleted = true where id = ${id}`;
|
||||||
|
|
||||||
|
await fs.copyFile(path.join(cfg.paths.b, f0ck[0].dest), path.join(cfg.paths.deleted, 'b', f0ck[0].dest)).catch(_ => { });
|
||||||
|
await fs.copyFile(path.join(cfg.paths.t, `${id}.webp`), path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.b, f0ck[0].dest)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(_ => { });
|
||||||
|
|
||||||
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
|
await fs.copyFile(path.join(cfg.paths.ca, `${id}.webp`), path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(_ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||||
|
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||||
|
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||||
|
{ text: 'recover', callback_data: `b_recover:${id}` }
|
||||||
|
], [
|
||||||
|
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "b_recover":
|
||||||
|
if (id <= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
e.user = {
|
||||||
|
prefix: `${e.raw.reply_to_message.from.username}!${e.raw.reply_to_message.from.id}@${e.network}`,
|
||||||
|
nick: e.raw.reply_to_message.from.first_name,
|
||||||
|
username: e.raw.reply_to_message.from.username,
|
||||||
|
account: e.raw.reply_to_message.from.id.toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
f0ck = await db`
|
||||||
|
select dest, mime
|
||||||
|
from "items"
|
||||||
|
where
|
||||||
|
id = ${id} and
|
||||||
|
active = 'false'
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (f0ck.length === 0) {
|
||||||
|
return await e.reply(`f0ck ${id}: f0ck not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.copyFile(path.join(cfg.paths.deleted, 'b', f0ck[0].dest), path.join(cfg.paths.b, f0ck[0].dest)).catch(_ => { });
|
||||||
|
await fs.copyFile(path.join(cfg.paths.deleted, 't', `${id}.webp`), path.join(cfg.paths.t, `${id}.webp`)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 'b', f0ck[0].dest)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(_ => { });
|
||||||
|
|
||||||
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
|
await fs.copyFile(path.join(cfg.paths.deleted, 'ca', `${id}.webp`), path.join(cfg.paths.ca, `${id}.webp`)).catch(_ => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(_ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`update "items" set active = 'true' where id = ${id}`;
|
||||||
|
|
||||||
|
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||||
|
reply_markup: JSON.stringify({
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||||
|
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||||
|
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||||
|
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||||
|
], [
|
||||||
|
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||||
|
]]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await e.reply('lol');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
};
|
||||||
25
src/inc/events/ctcp.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import logger from "../log.mjs";
|
||||||
|
|
||||||
|
const versions = [
|
||||||
|
"AmIRC.1 (8 Bit) for Commodore Amiga 500",
|
||||||
|
"HexChat 0.72 [x86] / Windows 95c [500MHz]"
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async bot => {
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: "version",
|
||||||
|
listener: "ctcp:version",
|
||||||
|
f: e => {
|
||||||
|
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ctcp:version ${e.message}`);
|
||||||
|
e.write(`notice ${e.user.nick} :\u0001VERSION ${versions[~~(Math.random() * versions.length)]}\u0001`);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
name: "ping",
|
||||||
|
listener: "ctcp:ping",
|
||||||
|
f: e => {
|
||||||
|
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ctcp:ping ${e.message}`);
|
||||||
|
e.write(`notice ${e.user.nick} :${e.message}`);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
};
|
||||||
12
src/inc/events/error.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import logger from "../log.mjs";
|
||||||
|
|
||||||
|
export default async bot => {
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: "error",
|
||||||
|
listener: "error",
|
||||||
|
f: e => {
|
||||||
|
logger.error(e);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
};
|
||||||
18
src/inc/events/info.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import logger from "../log.mjs";
|
||||||
|
|
||||||
|
export default async bot => {
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: "info",
|
||||||
|
listener: "info",
|
||||||
|
f: e => {
|
||||||
|
logger.debug(e);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
name: "debug",
|
||||||
|
listener: "debug",
|
||||||
|
f: e => {
|
||||||
|
logger.debug(e);
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
};
|
||||||
76
src/inc/events/message.mjs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import logger from "../log.mjs";
|
||||||
|
import { getLevel } from "../../inc/admin.mjs";
|
||||||
|
|
||||||
|
const parseArgs = msg => {
|
||||||
|
let args = msg.trim().split(" ")
|
||||||
|
, cmd = args.shift();
|
||||||
|
return {
|
||||||
|
cmd: cmd.replace(/^(\.|\/|\!)/, ""),
|
||||||
|
args: args
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async bot => {
|
||||||
|
|
||||||
|
return [{
|
||||||
|
name: "message",
|
||||||
|
listener: "message",
|
||||||
|
f: e => {
|
||||||
|
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`);
|
||||||
|
|
||||||
|
// Sanitize Matrix messages to remove reply fallback
|
||||||
|
// Matrix reply fallback format:
|
||||||
|
// > <@user:example.com> message
|
||||||
|
// > continued quote
|
||||||
|
//
|
||||||
|
// Actual reply
|
||||||
|
if (e.type === 'matrix' && e.message) {
|
||||||
|
let lines = e.message.split('\n');
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length && (lines[i].startsWith('>') || lines[i].trim() === '')) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
// Only strip if we found reply blocks, otherwise keep original
|
||||||
|
if (i > 0) {
|
||||||
|
e.message = lines.slice(i).join('\n').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trigger;
|
||||||
|
|
||||||
|
if (e.media) {
|
||||||
|
trigger = [...bot._trigger.entries()].filter(t => t[1].name === "parser");
|
||||||
|
if (!e.message)
|
||||||
|
e.message = "";
|
||||||
|
} else {
|
||||||
|
console.log(`[MESSAGE DEBUG] Processing message: '${e.message}' from ${e.user.nick} (${e.type}). Prefix: ${e.user.prefix}`);
|
||||||
|
trigger = [...bot._trigger.entries()].filter(t => {
|
||||||
|
const matchesCall = t[1].call.exec(e.message);
|
||||||
|
const includesClient = t[1].clients.includes(e.type);
|
||||||
|
const isActive = t[1].active;
|
||||||
|
const userLevel = getLevel(e.user).level;
|
||||||
|
const hasLevel = t[1].level <= userLevel;
|
||||||
|
|
||||||
|
if (matchesCall) {
|
||||||
|
console.log(`[MESSAGE DEBUG] Matched trigger '${t[1].name}': ClientMatch=${includesClient}, Active=${isActive}, LevelMatch=${hasLevel} (UserLevel=${userLevel} vs Req=${t[1].level})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchesCall && includesClient && isActive && hasLevel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.forEach(async t => {
|
||||||
|
try {
|
||||||
|
await t[1].f({ ...e, ...parseArgs(e.message) });
|
||||||
|
console.log(`triggered > ${t[0]}`);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
await e.reply(`${t[0]}: An error occured.`);
|
||||||
|
logger.error(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${err.toString ? err : JSON.stringify(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
};
|
||||||
13
src/inc/halls_cache.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import db from "./sql.mjs";
|
||||||
|
|
||||||
|
let hallsCache = [];
|
||||||
|
|
||||||
|
export const updateHallsCache = async () => {
|
||||||
|
try {
|
||||||
|
hallsCache = await db`SELECT id, name, slug, rating FROM halls ORDER BY name ASC`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[HALLS CACHE] Failed to update:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHalls = () => hallsCache;
|
||||||
57
src/inc/i18n.mjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, resolve } from 'path';
|
||||||
|
|
||||||
|
const localesDir = join(resolve(), 'src/inc/locales');
|
||||||
|
|
||||||
|
function loadLocale(lang) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(join(localesDir, `${lang}.json`), 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[i18n] Failed to load locale "${lang}":`, e.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a nested value from an object using dot-notation.
|
||||||
|
* e.g. get(obj, 'nav.login') => obj.nav.login
|
||||||
|
*/
|
||||||
|
function deepGet(obj, key) {
|
||||||
|
return key.split('.').reduce((o, k) => o?.[k], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an i18n instance for the given language.
|
||||||
|
* Falls back to English for any missing keys.
|
||||||
|
* No cache: always reads fresh from disk so locale changes
|
||||||
|
* take effect without restarting the server.
|
||||||
|
*
|
||||||
|
* @param {string} lang - language code, e.g. 'de' or 'en'
|
||||||
|
* @returns {{ t: Function, lang: string }}
|
||||||
|
*/
|
||||||
|
export function createI18n(lang = 'en') {
|
||||||
|
const primary = loadLocale(lang);
|
||||||
|
const fallback = lang !== 'en' ? loadLocale('en') : {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a dot-notation key.
|
||||||
|
* Returns the translated string, falling back to the English string,
|
||||||
|
* then to the raw key if nothing else matches.
|
||||||
|
* Supports variable interpolation using {key} syntax.
|
||||||
|
*
|
||||||
|
* @param {string} key - e.g. 'nav.login'
|
||||||
|
* @param {Object} [data] - optional variables to interpolate
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function t(key, data = {}) {
|
||||||
|
let str = deepGet(primary, key) ?? deepGet(fallback, key) ?? key;
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
Object.keys(data).forEach(k => {
|
||||||
|
const escapedK = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
str = str.replace(new RegExp(`\\{${escapedK}\\}`, 'g'), data[k]);
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { t, lang };
|
||||||
|
}
|
||||||
340
src/inc/lib.mjs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import util from "util";
|
||||||
|
import db from "./sql.mjs";
|
||||||
|
|
||||||
|
import cfg from "./config.mjs";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const scrypt = util.promisify(crypto.scrypt);
|
||||||
|
|
||||||
|
const epochs = [
|
||||||
|
["year", 31536000],
|
||||||
|
["month", 2592000],
|
||||||
|
["day", 86400],
|
||||||
|
["hour", 3600],
|
||||||
|
["minute", 60],
|
||||||
|
["second", 1]
|
||||||
|
];
|
||||||
|
const getDuration = timeAgoInSeconds => {
|
||||||
|
for (let [name, seconds] of epochs) {
|
||||||
|
const interval = ~~(timeAgoInSeconds / seconds);
|
||||||
|
if (interval >= 1) return {
|
||||||
|
interval: interval,
|
||||||
|
epoch: name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default new class {
|
||||||
|
escapeHTML(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.toString()
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||||
|
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||||
|
};
|
||||||
|
calcSpeed(b, s) {
|
||||||
|
return (Math.round((b * 8 / s / 1e6) * 1e4) / 1e4);
|
||||||
|
};
|
||||||
|
timeAgo(date) {
|
||||||
|
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) {
|
||||||
|
return crypto.createHash('md5').update(str).digest("hex");
|
||||||
|
};
|
||||||
|
sha256(str) {
|
||||||
|
return crypto.createHash('sha256').update(str).digest("hex");
|
||||||
|
};
|
||||||
|
getMode(mode) {
|
||||||
|
let tmp;
|
||||||
|
mode = Number(mode);
|
||||||
|
switch (mode) {
|
||||||
|
case 1: // nsfw
|
||||||
|
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||||
|
break;
|
||||||
|
case 2: // untagged
|
||||||
|
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||||
|
break;
|
||||||
|
case 3: // all
|
||||||
|
tmp = "1 = 1";
|
||||||
|
break;
|
||||||
|
case 4: // nsfl
|
||||||
|
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||||
|
break;
|
||||||
|
default: // sfw
|
||||||
|
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return tmp;
|
||||||
|
};
|
||||||
|
createID() {
|
||||||
|
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
|
||||||
|
};
|
||||||
|
generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
};
|
||||||
|
genLink(env) {
|
||||||
|
const link = [];
|
||||||
|
if (env.tag) link.push("tag", env.tag);
|
||||||
|
if (env.hall) link.push("h", env.hall);
|
||||||
|
if (env.user) link.push("user", env.user, env.type ?? 'uploads');
|
||||||
|
|
||||||
|
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||||
|
if (!tmp.endsWith('/'))
|
||||||
|
tmp = tmp + '/';
|
||||||
|
if (!tmp.startsWith('/'))
|
||||||
|
tmp = '/' + tmp;
|
||||||
|
|
||||||
|
// Build suffix with query params
|
||||||
|
let suffix = env.strict ? '?strict=1' : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
main: tmp,
|
||||||
|
path: env.path ? env.path : '',
|
||||||
|
suffix: suffix
|
||||||
|
};
|
||||||
|
};
|
||||||
|
parseTag(tag) {
|
||||||
|
if (!tag)
|
||||||
|
return null;
|
||||||
|
return decodeURIComponent(tag);
|
||||||
|
}
|
||||||
|
slugify(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape ILIKE wildcard characters in user-supplied strings
|
||||||
|
escapeLike(str) {
|
||||||
|
if (!str) return str;
|
||||||
|
return str.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// async funcs
|
||||||
|
async countf0cks() {
|
||||||
|
const tagged = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||||
|
`)[0].total;
|
||||||
|
const untagged = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||||
|
`)[0].total;
|
||||||
|
const sfw = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||||
|
`)[0].total;
|
||||||
|
const nsfw = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||||
|
`)[0].total;
|
||||||
|
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||||
|
`)[0].total : 0;
|
||||||
|
const deleted = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where active = false and is_deleted = true
|
||||||
|
`)[0].total;
|
||||||
|
const pending = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where active = false and is_deleted = false
|
||||||
|
`)[0].total;
|
||||||
|
const lastf0ck = +(await db`
|
||||||
|
select max(id) as id
|
||||||
|
from "items"
|
||||||
|
`)[0].id;
|
||||||
|
return {
|
||||||
|
tagged,
|
||||||
|
untagged,
|
||||||
|
total: tagged + untagged,
|
||||||
|
deleted,
|
||||||
|
pending,
|
||||||
|
untracked: lastf0ck - (tagged + untagged + deleted + pending),
|
||||||
|
sfw,
|
||||||
|
nsfw,
|
||||||
|
nsfl: cfg.enable_nsfl ? nsfl : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
async hash(str) {
|
||||||
|
const salt = crypto.randomBytes(16).toString("hex");
|
||||||
|
const derivedKey = await scrypt(str, salt, 64);
|
||||||
|
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
||||||
|
};
|
||||||
|
async verify(str, hash) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
async getTags(itemid) {
|
||||||
|
const tags = await db`
|
||||||
|
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||||
|
from "tags_assign"
|
||||||
|
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||||
|
left join "user" on "user".id = "tags_assign".user_id
|
||||||
|
left join user_options uo on uo.user_id = "user".id
|
||||||
|
where "tags_assign".item_id = ${+itemid}
|
||||||
|
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||||
|
`;
|
||||||
|
for (let t = 0; t < tags.length; t++) {
|
||||||
|
tags[t].badge = this.getBadge(tags[t]);
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
getBadge(tagObj) {
|
||||||
|
if (tagObj.tag.startsWith(">"))
|
||||||
|
return "badge-greentext badge-light";
|
||||||
|
else if (tagObj.normalized === "ukraine")
|
||||||
|
return "badge-ukraine badge-light";
|
||||||
|
else if (/[а-яё]/.test(tagObj.normalized) || tagObj.normalized === "russia")
|
||||||
|
return "badge-russia badge-light";
|
||||||
|
else if (tagObj.normalized === "german")
|
||||||
|
return "badge-german badge-light";
|
||||||
|
else if (tagObj.normalized === "dutch")
|
||||||
|
return "badge-dutch badge-light";
|
||||||
|
else if (tagObj.normalized === "sfw")
|
||||||
|
return "badge-success";
|
||||||
|
else if (tagObj.normalized === "nsfw")
|
||||||
|
return "badge-danger";
|
||||||
|
else if (tagObj.normalized === "nsfl")
|
||||||
|
return cfg.enable_nsfl ? "badge-nsfl" : "badge-light";
|
||||||
|
else
|
||||||
|
return "badge-light";
|
||||||
|
};
|
||||||
|
async hasTag(itemid, tagid) {
|
||||||
|
const tag = (await db`
|
||||||
|
select *
|
||||||
|
from "tags_assign"
|
||||||
|
where
|
||||||
|
item_id = ${+itemid} and
|
||||||
|
tag_id = ${+tagid}
|
||||||
|
limit 1
|
||||||
|
`).length;
|
||||||
|
return !!tag;
|
||||||
|
};
|
||||||
|
// detectNSFW: removed — contained shell injection via exec() with unescaped `dest` parameter.
|
||||||
|
// If re-implemented, use execFile() with argument arrays and a dedicated Python script.
|
||||||
|
async getDefaultAvatar() {
|
||||||
|
return (await db`
|
||||||
|
select column_default as avatar
|
||||||
|
from "information_schema"."columns"
|
||||||
|
where
|
||||||
|
TABLE_SCHEMA='public' and
|
||||||
|
TABLE_NAME='user_options' and
|
||||||
|
COLUMN_NAME = 'avatar'
|
||||||
|
`)[0].avatar;
|
||||||
|
};
|
||||||
|
|
||||||
|
// meddlware admin
|
||||||
|
async auth(req, res, next) {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: "401 - Unauthorized"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// meddlware user
|
||||||
|
async userauth(req, res, next) {
|
||||||
|
if (!req.session) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: "401 - Unauthorized"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
async loggedin(req, res, next) {
|
||||||
|
if (!req.session) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: "401 - Unauthorized"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
async modAuth(req, res, next) {
|
||||||
|
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: "401 - Unauthorized"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
async adminAuth(req, res, next) {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({
|
||||||
|
code: 401,
|
||||||
|
body: "401 - Unauthorized"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
getCookieOptions(expires = null, httpOnly = true) {
|
||||||
|
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
||||||
|
let options = "Path=/; SameSite=Lax";
|
||||||
|
if (httpOnly) options += "; HttpOnly";
|
||||||
|
if (isSecure) options += "; Secure";
|
||||||
|
if (expires) {
|
||||||
|
if (typeof expires === 'number') {
|
||||||
|
options += `; Max-Age=${expires}`;
|
||||||
|
} else {
|
||||||
|
options += `; Expires=${expires}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.debug(`[COOKIE DEBUG] full=${cfg.main.url.full}, isSecure=${isSecure}, options=${options}`);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(...args) {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logError(err, context = "Internal Error") {
|
||||||
|
const errId = crypto.randomUUID();
|
||||||
|
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||||
|
return `Internal Error. Reference: ${errId}`;
|
||||||
|
}
|
||||||
|
};
|
||||||