init f0ckm

This commit is contained in:
2026-04-25 19:51:52 +02:00
commit b646107eb7
241 changed files with 70364 additions and 0 deletions

24
.dockerignore Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
*.mjs gitlab-language=javascript
*.hbs gitlab-language=handlebars

13
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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();

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

2428
migrations/f0ckm_schema.sql Normal file

File diff suppressed because it is too large Load Diff

418
package-lock.json generated Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
User-agent: Googlebot
User-agent: AdsBot-Google
Disallow: /
User-agent: *
Disallow: /

BIN
public/s/95.ttf Normal file

Binary file not shown.

BIN
public/s/OpenSans.ttf Normal file

Binary file not shown.

463
public/s/css/dm.css Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/s/fonts/Hack.woff Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/s/img/404.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

BIN
public/s/img/audio.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/s/img/favicon.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

24
public/s/img/iconset.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

BIN
public/s/img/music.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

BIN
public/s/img/swf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

17
public/s/img/v0ck.svg Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

679
public/s/js/danmaku.js Normal file
View 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;
})();

View 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

File diff suppressed because it is too large Load Diff

723
public/s/js/flash_yank.js Normal file
View 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;">&times;</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

File diff suppressed because it is too large Load Diff

48
public/s/js/koepfe.js Normal file
View 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

File diff suppressed because one or more lines are too long

405
public/s/js/meme-creator.js Normal file
View 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);
}
})();

View 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

File diff suppressed because it is too large Load Diff

93
public/s/js/sanitizer.js Normal file
View 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

File diff suppressed because it is too large Load Diff

1322
public/s/js/settings.js Normal file

File diff suppressed because it is too large Load Diff

View 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(/&gt;/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">&gt;${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">&gt;${rendered}</span>`;
}
// Per-line limit to prevent marked.parse recursion on single giant lines
if (line.length > 10000) return line;
if (!line.trim()) return '&nbsp;';
// 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 `![image](${fullUrl})`;
});
// 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} &raquo;</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'} &raquo;</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)
})();

View 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
View 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;
})();

View 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

File diff suppressed because it is too large Load Diff

219
public/s/js/user.js Normal file
View 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'); // &nbsp;
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);
}
});
})();

View 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(/&gt;/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">&gt;${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">&gt;${quoteContent}</span>`;
}
// Per-line limit
if (line.length > 10000) return line;
if (!line.trim()) return '&nbsp;';
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
View 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
View File

@@ -0,0 +1,526 @@
/**
* wordlist.js — German recovery phrase wordlist
*
* 4096 common German words, lowercase, 410 characters.
* Sourced from: github.com/MarvinJWendt/wordlist-german
* Filtered to: lowercase letters only (including umlauts), 410 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

Binary file not shown.

63
public/sw.js Normal file
View 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
View 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)`);

View 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);
});

View 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);
});

View 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
View 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);
}
};

View 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
View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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);
}
}];
};

View 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
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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}`;
}
};

Some files were not shown because too many files have changed in this diff Show More