init f0ckm
This commit is contained in:
448
public/s/js/admin.js
Normal file
448
public/s/js/admin.js
Normal file
@@ -0,0 +1,448 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
// Prefer data-username (raw DB username) over innerText (may be a display name)
|
||||
poster: usernameEl?.dataset?.username || usernameEl?.innerText?.trim() || null,
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if (method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for (const [key, val] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = (_tags, highlightTag = null) => {
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
||||
if (!tag.querySelector('#a_addtag') && !tag.querySelector('#a_toggle') && !tag.classList.contains('tag-ac-wrapper')) {
|
||||
tag.parentElement.removeChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "mr-2");
|
||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||
span.classList.add('new-tag-glow');
|
||||
}
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
delbutton.href = "javascript:void(0)";
|
||||
// Class for delegation
|
||||
delbutton.classList.add("admin-deltag", "removetag");
|
||||
|
||||
span.appendChild(a);
|
||||
span.appendChild(document.createTextNode('\u00A0'));
|
||||
span.appendChild(delbutton);
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
|
||||
let toggle = tagsContainer.querySelector(".show-tags-toggle");
|
||||
|
||||
if (realTags.length > 10) {
|
||||
if (!toggle) {
|
||||
toggle = document.createElement("a");
|
||||
toggle.href = "#";
|
||||
toggle.className = "show-tags-toggle";
|
||||
tagsContainer.appendChild(toggle);
|
||||
}
|
||||
const hiddenCount = realTags.length - 10;
|
||||
toggle.dataset.count = hiddenCount;
|
||||
|
||||
// Auto-expand when rendering new tags (e.g. after adding one) as requested
|
||||
tagsContainer.classList.add('tags-expanded');
|
||||
toggle.textContent = "show less";
|
||||
|
||||
} else if (toggle) {
|
||||
toggle.remove();
|
||||
tagsContainer.classList.remove('tags-expanded');
|
||||
}
|
||||
};
|
||||
|
||||
window.renderTags = renderTags;
|
||||
|
||||
const deleteEvent = async e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
let target = e.target;
|
||||
if (target.nodeType === 3) target = target.parentElement;
|
||||
|
||||
const badge = target.closest('.badge');
|
||||
if (!badge) return;
|
||||
const tagLink = badge.querySelector('a[href*="/tag/"], a:first-of-type');
|
||||
const tagname = tagLink ? tagLink.innerText.trim() : null;
|
||||
if (!tagname) return;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm((window.f0ckI18n && window.f0ckI18n.tag_delete_title) || 'Delete Tag', `${(window.f0ckI18n && window.f0ckI18n.tag_delete_confirm) || 'Are you sure you want to delete the tag'} <strong style="color:#d9534f">${tagname}</strong>?`, async (reason) => {
|
||||
// Send reason via query param for DELETE request
|
||||
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (reason ? "?reason=" + encodeURIComponent(reason) : ""), {
|
||||
method: 'DELETE',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.msg || "Error deleting tag");
|
||||
}
|
||||
renderTags(res.tags);
|
||||
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.tag_deleted_success) || 'Tag deleted', 2500, 'success');
|
||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||
};
|
||||
|
||||
const addtagClick = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
TagAutocomplete.open({
|
||||
postid,
|
||||
existingTags: tags,
|
||||
anchorEl: anchor,
|
||||
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteButtonEvent = async e => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, poster } = ctx;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const confirmTitle = i18n.item_delete_title || 'Delete Item';
|
||||
const confirmMsg = (i18n.item_delete_confirm || 'Are you sure you want to delete item {id} by {user}?')
|
||||
.replace('{id}', postid)
|
||||
.replace('{user}', poster || 'unknown');
|
||||
|
||||
ModAction.confirm(confirmTitle, confirmMsg, async (reason) => {
|
||||
// Flag immediately so the SSE delete_item handler skips navigation
|
||||
window._adminJustDeletedItem = postid;
|
||||
const res = await post("/api/v2/admin/deletepost", {
|
||||
postid: postid,
|
||||
reason: reason
|
||||
});
|
||||
if (!res.success) {
|
||||
window._adminJustDeletedItem = null;
|
||||
throw new Error(res.msg || "Error deleting item");
|
||||
}
|
||||
const mediaObj = document.querySelector('.media-object');
|
||||
if (mediaObj) {
|
||||
mediaObj.innerHTML = '<div style="padding: 100px; text-align: center; color: #d9534f;"><h1>Item Deleted</h1><p>The item has been successfully removed.</p></div>';
|
||||
}
|
||||
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.item_deleted_success) || 'Item deleted', 2500, 'success');
|
||||
// Clear flag after a short delay (SSE has surely arrived by then)
|
||||
setTimeout(() => { window._adminJustDeletedItem = null; }, 3000);
|
||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||
};
|
||||
|
||||
let tmptt = null;
|
||||
const editTagEvent = async e => {
|
||||
e.preventDefault();
|
||||
if (e.detail === 2) { // Double click
|
||||
clearTimeout(tmptt);
|
||||
const old = e.target;
|
||||
const parent = e.target.parentElement;
|
||||
const oldtag = e.target.innerText;
|
||||
|
||||
const textfield = document.createElement('input');
|
||||
textfield.value = e.target.innerText;
|
||||
textfield.size = 10;
|
||||
|
||||
parent.insertAdjacentElement('afterbegin', textfield);
|
||||
textfield.focus();
|
||||
parent.removeChild(e.target);
|
||||
// Hide delete button while editing
|
||||
const delBtn = parent.querySelector('a:last-child');
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
|
||||
textfield.addEventListener("keydown", async e => {
|
||||
if (e.key === 'Enter' || e.keyCode === 13) {
|
||||
parent.removeChild(textfield);
|
||||
let res = await fetch('/api/v2/tags/rename/' + encodeURIComponent(oldtag), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify({ newtag: textfield.value })
|
||||
});
|
||||
const status = res.status;
|
||||
res = await res.json();
|
||||
switch (status) {
|
||||
case 200:
|
||||
case 201:
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
old.href = `/tag/${res.tag}`;
|
||||
old.innerText = res.tag.trim();
|
||||
break;
|
||||
default:
|
||||
console.log(res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
parent.removeChild(textfield);
|
||||
parent.insertAdjacentElement('afterbegin', old);
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
tmptt = setTimeout(() => location.href = e.target.href, 250);
|
||||
return false;
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
document.addEventListener("click", e => {
|
||||
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
||||
|
||||
if (target.closest("a#a_addtag")) {
|
||||
addtagClick(e);
|
||||
} else if (target.closest("#a_delete")) {
|
||||
deleteButtonEvent(e);
|
||||
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
|
||||
editTagEvent(e);
|
||||
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
|
||||
deleteEvent(e);
|
||||
} else if (target.closest("#a_pin")) {
|
||||
pinButtonEvent(e);
|
||||
} else if (target.closest("#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
const pinButtonEvent = async e => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const pinBtn = document.querySelector('#a_pin');
|
||||
if (!pinBtn) return;
|
||||
|
||||
const isPinned = pinBtn.getAttribute('data-pinned') === 'true';
|
||||
const url = isPinned ? `/mod/unpin?id=${postid}` : `/mod/pin?id=${postid}`;
|
||||
|
||||
try {
|
||||
const res = await (await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
|
||||
if (res.success) {
|
||||
const newState = res.pinned;
|
||||
const title = newState ? 'Unpin from main' : 'Pin to main';
|
||||
const currentBtn = document.querySelector('#a_pin');
|
||||
if (currentBtn) {
|
||||
currentBtn.setAttribute('data-pinned', newState);
|
||||
currentBtn.setAttribute('title', title);
|
||||
currentBtn.classList.toggle('active', newState);
|
||||
}
|
||||
window.flashMessage(newState ? 'ITEM PINNED' : 'ITEM UNPINNED');
|
||||
} else {
|
||||
alert('Error: ' + res.msg);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Pin error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
|
||||
// 'f' and 'i' handled by f0ckm.js keybindings via programmatic click
|
||||
if (e.key === "x") deleteButtonEvent();
|
||||
else if (e.key === "g") pinButtonEvent();
|
||||
});
|
||||
|
||||
window.adminSetPassword = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
|
||||
if (!password) return;
|
||||
if (password.length < 20) return alert('Password must be at least 20 characters.');
|
||||
|
||||
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
|
||||
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
} else {
|
||||
alert(data.msg || 'Failed to set password');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminDeleteUser = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
document.getElementById(`user-row-${id}`)?.remove();
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete user');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminResetLoginAttempts = async (btn) => {
|
||||
const username = btn.dataset.username;
|
||||
if (!confirm(`Are you sure you want to reset login attempts for ${username}?`)) return;
|
||||
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
window.location.reload(); // Quickest way to refresh badges
|
||||
} else {
|
||||
alert(data.msg || 'Failed to reset attempts');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminBulkDeleteHalls = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete halls');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
2509
public/s/js/comments.js
Normal file
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
679
public/s/js/danmaku.js
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* danmaku.js — NicoNico/弾幕-style flying comments for v0ck
|
||||
*
|
||||
* Usage:
|
||||
* const d = new Danmaku(playerEl, videoEl);
|
||||
* d.load(commentsArray); // feed comments from API
|
||||
* d.fire('hello!', 'user', '#f0f'); // fire one immediately
|
||||
* d.toggle(); // toggle on/off
|
||||
* d.destroy(); // cleanup
|
||||
*/
|
||||
(function () {
|
||||
|
||||
const PILL_MIN_MS = 6000; // Fastest a pill can cross the screen
|
||||
const PILL_MAX_MS = 12000; // Slowest a pill can cross the screen
|
||||
const LANE_COUNT = 10; // Vertical lane slots
|
||||
const LOOKAHEAD_SEC = 0.25; // How far ahead of currentTime we look when scanning
|
||||
const MIN_RANDOM_SECS = 2; // Random timecode lower bound (avoid very start)
|
||||
const RANDOM_SPREAD = 0.85; // Use 85% of duration for random spread
|
||||
|
||||
/**
|
||||
* SyntheticClock — emulates a <video> element's time API for non-video items
|
||||
* (Flash/Ruffle). Ticks at 4 Hz so Danmaku's timeupdate handler fires normally.
|
||||
*/
|
||||
class SyntheticClock {
|
||||
constructor() {
|
||||
this._currentTime = 0;
|
||||
this._paused = false;
|
||||
this._listeners = { timeupdate: [], seeked: [] };
|
||||
this._timer = this._startTimer();
|
||||
}
|
||||
_startTimer() {
|
||||
return setInterval(() => {
|
||||
if (this._paused) return;
|
||||
this._currentTime += 0.25;
|
||||
this._listeners.timeupdate.forEach(fn => fn());
|
||||
}, 250);
|
||||
}
|
||||
get currentTime() { return this._currentTime; }
|
||||
get duration() { return Infinity; }
|
||||
get paused() { return this._paused; }
|
||||
pause() {
|
||||
// For Flash/Ruffle: never pause the clock — let pills and time advance freely.
|
||||
// Ruffle's is_playing is unreliable and would stall danmaku if respected.
|
||||
this._paused = true;
|
||||
}
|
||||
resume() {
|
||||
this._paused = false;
|
||||
}
|
||||
addEventListener(type, fn, opts) {
|
||||
if (this._listeners[type]) this._listeners[type].push(fn);
|
||||
}
|
||||
removeEventListener(type, fn) {
|
||||
if (this._listeners[type])
|
||||
this._listeners[type] = this._listeners[type].filter(f => f !== fn);
|
||||
}
|
||||
/** Reset the clock to zero (used for looping in Flash/Ruffle mode). */
|
||||
reset() {
|
||||
this._currentTime = 0;
|
||||
this._listeners.seeked.forEach(fn => fn());
|
||||
}
|
||||
destroy() { clearInterval(this._timer); }
|
||||
}
|
||||
|
||||
class Danmaku {
|
||||
/**
|
||||
* @param {HTMLElement} playerEl — the .v0ck wrapper element
|
||||
* @param {HTMLVideoElement|HTMLAudioElement} mediaEl — the <video> or <audio>
|
||||
*/
|
||||
constructor(playerEl, mediaEl) {
|
||||
this.player = playerEl;
|
||||
this.media = mediaEl;
|
||||
this._synthClock = (mediaEl instanceof SyntheticClock) ? mediaEl : null;
|
||||
this.overlay = null;
|
||||
this.items = [];
|
||||
this._lastTime = -1;
|
||||
this._paused = false;
|
||||
this._laneUntil = new Array(LANE_COUNT).fill(0);
|
||||
// Site-wide config default
|
||||
const configDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
|
||||
? !!window.f0ckSession.enable_danmaku
|
||||
: true;
|
||||
|
||||
// User preference
|
||||
const savedPref = localStorage.getItem('danmaku');
|
||||
|
||||
if (savedPref !== null) {
|
||||
// User has explicitly chosen ON or OFF in the past
|
||||
this._enabled = savedPref !== 'false';
|
||||
} else {
|
||||
// No user preference yet — use the site-wide factory default
|
||||
this._enabled = configDefault;
|
||||
}
|
||||
this._bound_onTime = this._onTimeUpdate.bind(this);
|
||||
this._bound_onSeek = this._onSeeked.bind(this);
|
||||
this._bound_onPause = this._onPause.bind(this);
|
||||
this._bound_onPlay = this._onPlay.bind(this);
|
||||
|
||||
// Own emoji cache — populated via CommentSystem, event, or independent fetch
|
||||
this._emojiCache = {};
|
||||
this._initEmojiCache();
|
||||
|
||||
this._createOverlay();
|
||||
this.media.addEventListener('timeupdate', this._bound_onTime, { passive: true });
|
||||
this.media.addEventListener('seeked', this._bound_onSeek, { passive: true });
|
||||
this.media.addEventListener('pause', this._bound_onPause, { passive: true });
|
||||
this.media.addEventListener('play', this._bound_onPlay, { passive: true });
|
||||
|
||||
// For Ruffle/SyntheticClock: no poller needed — clock runs freely
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the emoji cache from whichever source resolves first:
|
||||
* 1. CommentSystem.emojiCache already populated (fast path — browser was already on a page)
|
||||
* 2. f0ck:emojis_ready event (CommentSystem finishes loading after us)
|
||||
* 3. Independent fetch (Ruffle/Flash items where CommentSystem may never fire the event)
|
||||
*/
|
||||
_initEmojiCache() {
|
||||
// Fast path: CommentSystem already populated (AJAX nav / second page view)
|
||||
const tryCs = () => {
|
||||
const cs = (typeof CommentSystem !== 'undefined') ? CommentSystem.emojiCache : null;
|
||||
return (cs && Object.keys(cs).length > 0) ? cs : null;
|
||||
};
|
||||
|
||||
const cs0 = tryCs();
|
||||
if (cs0) { this._emojiCache = cs0; return; }
|
||||
|
||||
// Listen for CommentSystem's own event
|
||||
this._bound_onEmojis = (e) => {
|
||||
const map = e.detail || tryCs() || {};
|
||||
if (map && Object.keys(map).length > 0) {
|
||||
this._emojiCache = map;
|
||||
this._reRenderEmojis();
|
||||
}
|
||||
};
|
||||
window.addEventListener('f0ck:emojis_ready', this._bound_onEmojis);
|
||||
|
||||
// Aggressive retry: try every 500 ms for up to 30 attempts.
|
||||
// Each attempt checks CommentSystem first (free), then falls back to a fetch.
|
||||
let attempts = 0;
|
||||
let fetched = false;
|
||||
const retry = () => {
|
||||
if (this._emojiCache && Object.keys(this._emojiCache).length > 0) return; // already got them
|
||||
if (++attempts > 30) return;
|
||||
|
||||
// 1. CommentSystem populated by now?
|
||||
const cs = tryCs();
|
||||
if (cs) {
|
||||
this._emojiCache = cs;
|
||||
this._reRenderEmojis();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Kick off the HTTP fetch once; then just wait for it / the event
|
||||
if (!fetched) {
|
||||
fetched = true;
|
||||
fetch('/api/v2/emojis')
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`emoji fetch ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success || !Array.isArray(data.emojis)) return;
|
||||
const map = {};
|
||||
data.emojis.forEach(e => { map[e.name] = e.url; });
|
||||
if (Object.keys(map).length > 0) {
|
||||
this._emojiCache = map;
|
||||
this._reRenderEmojis();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[Danmaku] emoji fetch failed:', err.message);
|
||||
fetched = false; // allow retry
|
||||
});
|
||||
}
|
||||
|
||||
// Schedule next check
|
||||
if (!this._destroyed) setTimeout(retry, 500);
|
||||
};
|
||||
setTimeout(retry, 200); // first attempt after a short grace window
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load (or reload) comments. Comments with video_time=null get a random time.
|
||||
* @param {Array} comments — raw comment objects from /api/comments
|
||||
*/
|
||||
load(comments) {
|
||||
if (!Array.isArray(comments) || comments.length === 0) return;
|
||||
|
||||
const duration = this.media.duration;
|
||||
const hasDuration = isFinite(duration) && duration > 0;
|
||||
|
||||
// Build the prepared item list
|
||||
const mapped = comments
|
||||
.filter(c => !c.is_deleted && c.content)
|
||||
.map(c => ({
|
||||
text: this._prepareText(c.content),
|
||||
username: c.display_name || c.username || '?',
|
||||
color: c.username_color || null,
|
||||
raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null,
|
||||
fired: false,
|
||||
video_time: 0
|
||||
}));
|
||||
|
||||
if (this._synthClock) {
|
||||
// Flash/Ruffle mode: bypass the timeline entirely.
|
||||
// Store pool and start the random continuous loop.
|
||||
this._flashPool = mapped;
|
||||
this._startFlashLoop();
|
||||
return; // don't touch this.items / _lastTime
|
||||
}
|
||||
|
||||
// Normal video mode: use video_time, random spread for nulls
|
||||
this.items = mapped.map(item => {
|
||||
if (item.raw_time !== null) {
|
||||
item.video_time = item.raw_time;
|
||||
} else if (hasDuration) {
|
||||
const spread = duration * RANDOM_SPREAD;
|
||||
item.video_time = MIN_RANDOM_SECS + Math.random() * Math.max(0, spread - MIN_RANDOM_SECS);
|
||||
} else {
|
||||
item.video_time = MIN_RANDOM_SECS + Math.random() * 598;
|
||||
}
|
||||
return item;
|
||||
}).sort((a, b) => a.video_time - b.video_time);
|
||||
|
||||
this._resetFiredState(this.media.currentTime);
|
||||
this._lastTime = this.media.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random continuous loop for Flash/Ruffle.
|
||||
* Fires one comment every 2-5 s (random), reshuffles pool on exhaustion.
|
||||
*/
|
||||
_startFlashLoop() {
|
||||
// Cancel any previous loop
|
||||
if (this._flashTimer) clearTimeout(this._flashTimer);
|
||||
this._flashTimer = null;
|
||||
|
||||
if (!this._flashPool || this._flashPool.length === 0) return;
|
||||
|
||||
// Shuffle helper
|
||||
const shuffle = arr => {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
};
|
||||
|
||||
// Working queue — randomised copy of pool
|
||||
let queue = shuffle([...this._flashPool]);
|
||||
let idx = 0;
|
||||
|
||||
const tick = () => {
|
||||
if (this._destroyed || !this._enabled) return;
|
||||
|
||||
// Refill and reshuffle when queue exhausted
|
||||
if (idx >= queue.length) {
|
||||
queue = shuffle([...this._flashPool]);
|
||||
idx = 0;
|
||||
}
|
||||
|
||||
const item = queue[idx++];
|
||||
this._spawnPill(item.text, item.username, item.color);
|
||||
|
||||
// Random delay 2 – 5 seconds between pills
|
||||
const delay = 2000 + Math.random() * 3000;
|
||||
this._flashTimer = setTimeout(tick, delay);
|
||||
};
|
||||
|
||||
// Small initial delay so page finishes loading before first pill
|
||||
this._flashTimer = setTimeout(tick, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately fire a single comment pill (e.g. user's own new comment).
|
||||
*/
|
||||
fire(text, username, color) {
|
||||
if (!this._enabled) return;
|
||||
this._spawnPill(this._prepareText(text), username, color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new comment to the timeline so it loops back in future playback.
|
||||
* Also fires it immediately as a one-shot pill.
|
||||
* @param {Object} comment — raw comment object from API
|
||||
*/
|
||||
addItem(comment) {
|
||||
if (!comment || !comment.content) return;
|
||||
|
||||
const duration = this.media.duration;
|
||||
const hasDuration = isFinite(duration) && duration > 0;
|
||||
|
||||
let t = (comment.video_time != null) ? parseFloat(comment.video_time) : null;
|
||||
|
||||
if (t === null) {
|
||||
const now = this.media.currentTime || 0;
|
||||
if (hasDuration) {
|
||||
const remaining = duration - now;
|
||||
const spread = Math.max(remaining * 0.9, MIN_RANDOM_SECS);
|
||||
t = now + MIN_RANDOM_SECS + Math.random() * spread;
|
||||
if (t > duration) t = MIN_RANDOM_SECS + Math.random() * duration * RANDOM_SPREAD;
|
||||
} else {
|
||||
t = (this.media.currentTime || 0) + MIN_RANDOM_SECS + Math.random() * 300;
|
||||
}
|
||||
}
|
||||
|
||||
const item = {
|
||||
video_time: t,
|
||||
text: this._prepareText(comment.content),
|
||||
username: comment.display_name || comment.username || '?',
|
||||
color: comment.username_color || null,
|
||||
fired: true // mark as already fired — caller handles any immediate one-shot
|
||||
};
|
||||
|
||||
// Insert in sorted order
|
||||
const idx = this.items.findIndex(i => i.video_time > t);
|
||||
if (idx === -1) this.items.push(item);
|
||||
else this.items.splice(idx, 0, item);
|
||||
}
|
||||
|
||||
/** Toggle danmaku on/off. */
|
||||
toggle() {
|
||||
this._enabled = !this._enabled;
|
||||
localStorage.setItem('danmaku', this._enabled ? 'true' : 'false');
|
||||
this.overlay.style.display = this._enabled ? '' : 'none';
|
||||
|
||||
// Update the switch if it exists in the player
|
||||
const sw = this.player.querySelector('#toggledanmaku');
|
||||
if (sw) sw.classList.toggle('active', this._enabled);
|
||||
}
|
||||
|
||||
setEnabled(val) {
|
||||
if (this._enabled === !!val) return;
|
||||
this.toggle();
|
||||
}
|
||||
|
||||
isEnabled() { return this._enabled; }
|
||||
|
||||
destroy() {
|
||||
this._destroyed = true;
|
||||
this.media.removeEventListener('timeupdate', this._bound_onTime);
|
||||
this.media.removeEventListener('seeked', this._bound_onSeek);
|
||||
this.media.removeEventListener('pause', this._bound_onPause);
|
||||
this.media.removeEventListener('play', this._bound_onPlay);
|
||||
if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis);
|
||||
if (this._rufflePoller) clearInterval(this._rufflePoller);
|
||||
if (this._flashTimer) clearTimeout(this._flashTimer);
|
||||
if (this._synthClock) this._synthClock.destroy();
|
||||
if (this.overlay && this.overlay.parentNode) this.overlay.parentNode.removeChild(this.overlay);
|
||||
this.overlay = null;
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
_createOverlay() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'danmaku-overlay';
|
||||
if (!this._enabled) this.overlay.style.display = 'none';
|
||||
this.player.appendChild(this.overlay);
|
||||
}
|
||||
|
||||
_onPause() {
|
||||
this._paused = true;
|
||||
if (this._synthClock) this._synthClock.pause();
|
||||
// Do NOT pause pill animations — pills already in flight always complete.
|
||||
}
|
||||
|
||||
_onPlay() {
|
||||
this._paused = false;
|
||||
if (this._synthClock) this._synthClock.resume();
|
||||
// Nothing to do for in-flight pills — they were never paused.
|
||||
}
|
||||
|
||||
_checkRuffleState() {
|
||||
const rp = document.querySelector('ruffle-player, ruffle-object');
|
||||
if (!rp) return;
|
||||
// Ruffle exposes is_playing on the element
|
||||
const isPlaying = rp.is_playing !== undefined ? !!rp.is_playing : true;
|
||||
if (isPlaying && this._paused) this._onPlay();
|
||||
if (!isPlaying && !this._paused) this._onPause();
|
||||
}
|
||||
|
||||
_onTimeUpdate() {
|
||||
if (this._paused) return;
|
||||
const now = this.media.currentTime;
|
||||
if (!this._enabled || this.items.length === 0) { this._lastTime = now; return; }
|
||||
|
||||
const prev = this._lastTime;
|
||||
this._lastTime = now;
|
||||
|
||||
// Detect video loop (time jumped backwards) — reset so comments fire again
|
||||
if (now < prev - 0.5) {
|
||||
this._resetFiredState(now);
|
||||
return;
|
||||
}
|
||||
|
||||
const from = prev;
|
||||
const to = now + LOOKAHEAD_SEC;
|
||||
|
||||
for (const item of this.items) {
|
||||
if (item.fired) continue;
|
||||
if (item.video_time < from) { item.fired = true; continue; } // already passed
|
||||
if (item.video_time > to) break; // sorted, nothing further in range
|
||||
item.fired = true;
|
||||
this._spawnPill(item.text, item.username, item.color);
|
||||
}
|
||||
|
||||
// Loop for SyntheticClock (Flash/Ruffle): once all items have fired, restart
|
||||
if (this._synthClock && this.items.length > 0 && this.items.every(i => i.fired)) {
|
||||
this._loopSynth();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset all fired flags and the synthetic clock for looping. */
|
||||
_loopSynth() {
|
||||
this.items.forEach(i => { i.fired = false; });
|
||||
this._synthClock.reset(); // back to t=0
|
||||
this._lastTime = 0;
|
||||
}
|
||||
|
||||
_onSeeked() {
|
||||
this._resetFiredState(this.media.currentTime);
|
||||
this._lastTime = this.media.currentTime;
|
||||
}
|
||||
|
||||
_resetFiredState(currentTime) {
|
||||
for (const item of this.items) {
|
||||
item.fired = item.video_time < currentTime - LOOKAHEAD_SEC;
|
||||
}
|
||||
}
|
||||
|
||||
_pickLane() {
|
||||
const now = Date.now();
|
||||
// Find the lane that will be free soonest
|
||||
let best = 0;
|
||||
let bestFree = this._laneUntil[0];
|
||||
for (let i = 1; i < LANE_COUNT; i++) {
|
||||
if (this._laneUntil[i] < bestFree) {
|
||||
bestFree = this._laneUntil[i];
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
// Occupy the lane — use the max duration so slower pills don't get overwritten
|
||||
this._laneUntil[best] = now + PILL_MAX_MS;
|
||||
return best;
|
||||
}
|
||||
|
||||
_spawnPill(text, username, color) {
|
||||
if (!this.overlay || !this._enabled || this._paused) return;
|
||||
|
||||
const pill = document.createElement('div');
|
||||
pill.className = 'danmaku-pill';
|
||||
|
||||
// Lane assignment — distribute vertically to avoid full overlap
|
||||
const lane = this._pickLane();
|
||||
const laneH = 100 / LANE_COUNT;
|
||||
const topPct = lane * laneH + (laneH * 0.1); // slight inset
|
||||
pill.style.top = topPct + '%';
|
||||
|
||||
// Message content — store raw text for deferred emoji re-render
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'dpill-text';
|
||||
msg.dataset.rawText = text;
|
||||
|
||||
// Read the freshest emoji source available at spawn time
|
||||
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
|
||||
? this._emojiCache
|
||||
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
|
||||
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
|
||||
|
||||
msg.appendChild(this._renderContent(text));
|
||||
|
||||
// Track pill for deferred re-render if emojis weren't ready yet
|
||||
if (!this._emojiCache || Object.keys(this._emojiCache).length === 0) {
|
||||
if (!this._pendingPills) this._pendingPills = new Set();
|
||||
this._pendingPills.add(msg);
|
||||
}
|
||||
|
||||
pill.appendChild(msg);
|
||||
|
||||
// Insert paused so we can measure before animation fires
|
||||
this.overlay.appendChild(pill);
|
||||
|
||||
// Duration scales with text length so long comments get enough time to cross.
|
||||
// Formula: 5s base + 25ms per character, clamped to [6s, 45s].
|
||||
const charCount = text.length;
|
||||
const duration = Math.min(Math.max(5000 + charCount * 25, PILL_MIN_MS), 45_000);
|
||||
|
||||
// Use actual scroll (content) width — wider than offsetWidth for very long lines.
|
||||
// This ensures the animation pixel travel is enough for ALL content to exit left,
|
||||
// not just the max-width-capped pill box.
|
||||
const overlayW = this.overlay.offsetWidth || window.innerWidth || 1920;
|
||||
const contentW = pill.scrollWidth || pill.offsetWidth || 200;
|
||||
const startX = overlayW + contentW; // off-screen right
|
||||
const endX = -(contentW + 200); // fully off-screen left, overflow included
|
||||
|
||||
const anim = pill.animate(
|
||||
[
|
||||
{ transform: `translateX(${startX}px)` },
|
||||
{ transform: `translateX(${endX}px)` }
|
||||
],
|
||||
{ duration, easing: 'linear', fill: 'none' }
|
||||
);
|
||||
|
||||
// Remove pill once animation completes
|
||||
anim.addEventListener('finish', () => {
|
||||
if (pill.parentNode) pill.parentNode.removeChild(pill);
|
||||
}, { once: true });
|
||||
|
||||
// Failsafe in case the Animations API finish event doesn't fire
|
||||
pill._timeoutId = setTimeout(() => { if (pill.parentNode) pill.parentNode.removeChild(pill); }, duration + 1000);
|
||||
}
|
||||
|
||||
/** Re-render pending pills once emojis are available. */
|
||||
_reRenderEmojis() {
|
||||
if (!this.overlay) return;
|
||||
// Prefer direct element tracking (fast, no DOM query needed)
|
||||
const pending = this._pendingPills;
|
||||
if (pending && pending.size > 0) {
|
||||
pending.forEach(msg => {
|
||||
if (!msg.parentNode) { pending.delete(msg); return; } // already removed
|
||||
const raw = msg.dataset.rawText;
|
||||
if (!raw) return;
|
||||
msg.textContent = '';
|
||||
msg.appendChild(this._renderContent(raw));
|
||||
});
|
||||
pending.clear();
|
||||
}
|
||||
// Also sweep any pills that slipped through (belt-and-suspenders)
|
||||
this.overlay.querySelectorAll('.dpill-text[data-raw-text]').forEach(msg => {
|
||||
const raw = msg.dataset.rawText;
|
||||
if (!raw) return;
|
||||
// Only re-render if the content is still plain text (no img children)
|
||||
if (!msg.querySelector('img.dpill-emoji')) {
|
||||
msg.textContent = '';
|
||||
msg.appendChild(this._renderContent(raw));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DocumentFragment from raw comment text.
|
||||
* Handles [spoiler], [blur], :emoji:, and >greentext lines.
|
||||
*/
|
||||
_renderContent(rawText) {
|
||||
if (!rawText) return document.createDocumentFragment();
|
||||
|
||||
const prepared = this._prepareText(rawText);
|
||||
const frag = document.createDocumentFragment();
|
||||
const cache = this._emojiCache; // always use full cache in danmaku
|
||||
|
||||
// Process line by line — leading > lines become greentext
|
||||
// Filter empty lines to avoid ghost rows from trailing newlines
|
||||
const lines = prepared.split('\n').filter(l => l.trim() !== '');
|
||||
lines.forEach((line) => {
|
||||
const isQuote = /^>\s?/.test(line);
|
||||
const text = isQuote ? '> ' + line.replace(/^>\s?/, '') : line;
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = isQuote ? 'dpill-greentext' : 'dpill-line';
|
||||
this._renderInline(text, span, cache);
|
||||
frag.appendChild(span);
|
||||
});
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders inline content (spoiler/blur/emoji) into a parent node.
|
||||
* Prepends a space before the first text chunk for visual separation.
|
||||
*/
|
||||
_renderInline(text, parent, emojiCache) {
|
||||
// match[1]=spoiler, match[2]=blur, match[3]=emoji, match[4]=inline-img-url
|
||||
const combined = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[blur\]([\s\S]*?)\[\/blur\]|:([a-z0-9_+\-]+):|\x04([^\x05]+)\x05/gi;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = combined.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parent.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'dpill-spoiler';
|
||||
span.title = 'Click to reveal spoiler';
|
||||
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
|
||||
this._renderInline(match[1], span, emojiCache); // recursive so emojis inside spoilers work
|
||||
parent.appendChild(span);
|
||||
} else if (match[2] !== undefined) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'dpill-blur';
|
||||
span.title = 'Click to reveal';
|
||||
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
|
||||
this._renderInline(match[2], span, emojiCache); // recursive so emojis inside blur work
|
||||
parent.appendChild(span);
|
||||
} else if (match[3]) {
|
||||
const code = match[3];
|
||||
const url = emojiCache[code];
|
||||
if (url) {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = `:${code}:`;
|
||||
img.className = 'dpill-emoji';
|
||||
parent.appendChild(img);
|
||||
} else {
|
||||
parent.appendChild(document.createTextNode(match[0]));
|
||||
}
|
||||
} else if (match[4]) {
|
||||
const img = document.createElement('img');
|
||||
img.src = match[4];
|
||||
img.className = 'dpill-img';
|
||||
parent.appendChild(img);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parent.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Strips markdown and character limits; preserves > and newlines for _renderContent. */
|
||||
_prepareText(text) {
|
||||
if (!text) return '';
|
||||
|
||||
// Protect emoji codes from the bold/italic underscore regex.
|
||||
// e.g. `:dance_fart: :dance_fart:` would have its underscores eaten
|
||||
// when the regex pairs the _ from the first code with the _ in the second.
|
||||
const emojiTokens = [];
|
||||
let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
|
||||
emojiTokens.push(match);
|
||||
return `\x02${emojiTokens.length - 1}\x03`; // private-use delimiters
|
||||
});
|
||||
|
||||
// Tokenize image URLs with \x04URL\x05 so they survive all regexes and reach _renderInline
|
||||
// Only embed images from the allowed-images allowlist (window.f0ckAllowedImages) or same site
|
||||
const allowedHosts = Array.isArray(window.f0ckAllowedImages) ? window.f0ckAllowedImages : [];
|
||||
const siteHost = window.location.hostname;
|
||||
const isAllowedImg = (url) => {
|
||||
try {
|
||||
const h = new URL(url).hostname;
|
||||
return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a));
|
||||
} catch { return false; }
|
||||
};
|
||||
const imgTokenUrls = [];
|
||||
protected_ = protected_
|
||||
// Stop at protocol boundaries so concatenated URLs aren't merged into one broken src.
|
||||
.replace(/https?:\/\/(?:(?!https?:\/\/)\S)+\.(?:png|jpg|jpeg|gif|webp|svg|avif)(\?(?:(?!https?:\/\/)\S)*)?/gi, (url) => {
|
||||
if (!isAllowedImg(url)) return url; // disallowed image URLs stay as plain text
|
||||
imgTokenUrls.push(url);
|
||||
return `\x04${imgTokenUrls.length - 1}\x05`; // numeric index placeholder
|
||||
})
|
||||
.replace(/```[\s\S]*?```/g, '[code]')
|
||||
.replace(/`[^`]+`/g, match => match.slice(1, -1))
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '[img]')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||
// Normalize \r\n → \n but keep line breaks for greentext
|
||||
.replace(/\r\n?/g, '\n')
|
||||
// Collapse 3+ blank lines to 2
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
// Restore emoji codes, then image URL tokens
|
||||
return protected_
|
||||
.replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '')
|
||||
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? `\x04${imgTokenUrls[+i]}\x05` : '');
|
||||
}
|
||||
}
|
||||
|
||||
window.Danmaku = Danmaku;
|
||||
window.SyntheticClock = SyntheticClock;
|
||||
|
||||
})();
|
||||
194
public/s/js/f0ck_upload_init.js
Normal file
194
public/s/js/f0ck_upload_init.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Global Drag and Drop Initialization - ROBUST VERSION
|
||||
*/
|
||||
(() => {
|
||||
const dropOverlay = document.getElementById('drop-overlay');
|
||||
const dragModal = document.getElementById('upload-drag-modal');
|
||||
const dragModalClose = document.getElementById('drag-modal-close');
|
||||
const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null;
|
||||
|
||||
const showModal = () => {
|
||||
if (!dragModal) return;
|
||||
dragModal.classList.add('show');
|
||||
// Reset scroll position so it always starts at the top
|
||||
dragModal.scrollTop = 0;
|
||||
const modalContent = dragModal.querySelector('.modal-content');
|
||||
if (modalContent) modalContent.scrollTop = 0;
|
||||
const modalBody = dragModal.querySelector('.modal-body');
|
||||
if (modalBody) modalBody.scrollTop = 0;
|
||||
};
|
||||
|
||||
// Navbar upload link — always attached so it works even if drag-drop can't init.
|
||||
// Opens the modal when available; falls back to /upload navigation otherwise.
|
||||
const navUploadLink = document.getElementById('nav-upload-link');
|
||||
if (navUploadLink) {
|
||||
navUploadLink.addEventListener('click', (e) => {
|
||||
if (!dragModal) return; // no modal → fall back to href navigation
|
||||
e.preventDefault();
|
||||
showModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (!dropOverlay || !dragModal || !dragForm) {
|
||||
console.warn('[f0ck] Quick Upload Modal or Form missing, global drag-drop disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
let dragCounter = 0;
|
||||
let uploader = null;
|
||||
|
||||
// Use the consolidated initUploadForm from upload.js
|
||||
if (window.initUploadForm) {
|
||||
uploader = window.initUploadForm(dragForm);
|
||||
} else {
|
||||
console.error('[f0ck] window.initUploadForm is missing! upload.js not loaded?');
|
||||
return;
|
||||
}
|
||||
|
||||
// Global Drag Events
|
||||
window.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.location.pathname === '/upload') return;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
dragCounter++;
|
||||
dropOverlay.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.location.pathname === '/upload') return;
|
||||
});
|
||||
|
||||
window.addEventListener('dragleave', (e) => {
|
||||
if (window.location.pathname === '/upload') return;
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
dropOverlay.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('drop', (e) => {
|
||||
if (window.location.pathname === '/upload') return;
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropOverlay.classList.remove('active');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
if (uploader && uploader.handleFile) {
|
||||
const ok = uploader.handleFile(files[0]);
|
||||
if (ok !== false) {
|
||||
showModal();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Modal Close
|
||||
dragModalClose.onclick = () => {
|
||||
dragModal.classList.remove('show');
|
||||
if (uploader && uploader.reset) {
|
||||
uploader.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Close on ESC
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && dragModal.classList.contains('show')) {
|
||||
dragModalClose.onclick();
|
||||
}
|
||||
});
|
||||
|
||||
// Open on 'u' shortcut (not on /upload, not when typing)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'u' && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
const tag = document.activeElement?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || document.activeElement?.isContentEditable) return;
|
||||
if (window.location.pathname === '/upload') return;
|
||||
if (dragModal.classList.contains('show')) return;
|
||||
|
||||
e.preventDefault();
|
||||
showModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Global Paste Event for Clipboard Images/Videos/URLs
|
||||
window.addEventListener('paste', (e) => {
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
const isTyping = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || activeTag === 'SELECT' || document.activeElement?.isContentEditable;
|
||||
|
||||
const isModalOpen = dragModal.classList.contains('show');
|
||||
const isUploadPage = window.location.pathname === '/upload';
|
||||
|
||||
// Items loop for files
|
||||
const items = e.clipboardData?.items;
|
||||
let file = null;
|
||||
if (items) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file' && (item.type.startsWith('image/') || item.type.startsWith('video/'))) {
|
||||
file = item.getAsFile();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file) {
|
||||
// Find target uploader
|
||||
let targetUploader = null;
|
||||
if (isModalOpen) {
|
||||
targetUploader = uploader;
|
||||
} else if (isUploadPage) {
|
||||
const pageForm = document.querySelector('.pagewrapper .upload-form') || document.querySelector('#main .upload-form');
|
||||
targetUploader = (pageForm && pageForm._f0ckUploader) ? pageForm._f0ckUploader : uploader;
|
||||
} else {
|
||||
targetUploader = uploader;
|
||||
}
|
||||
|
||||
if (targetUploader && targetUploader.handleFile) {
|
||||
if (isUploadPage || isModalOpen) {
|
||||
targetUploader.handleFile(file);
|
||||
e.preventDefault();
|
||||
} else if (!isTyping) {
|
||||
e.preventDefault();
|
||||
showModal();
|
||||
targetUploader.handleFile(file);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle URL paste (only if NOT typing)
|
||||
const text = e.clipboardData.getData('text')?.trim();
|
||||
if (text && (text.startsWith('http://') || text.startsWith('https://') || text.includes('youtube.com/') || text.includes('youtu.be/'))) {
|
||||
if (!isTyping) {
|
||||
e.preventDefault();
|
||||
|
||||
// Context-aware target selection
|
||||
let targetContainer = dragModal;
|
||||
if (!isModalOpen && isUploadPage) {
|
||||
targetContainer = document.querySelector('.pagewrapper .upload-form') ||
|
||||
document.querySelector('#main .upload-form') ||
|
||||
dragModal;
|
||||
}
|
||||
|
||||
if (targetContainer === dragModal && !isModalOpen && !isUploadPage) {
|
||||
showModal();
|
||||
}
|
||||
|
||||
// Switch to URL tab
|
||||
const urlTab = targetContainer.querySelector('.upload-mode-tab[data-mode="url"]');
|
||||
if (urlTab) urlTab.click();
|
||||
|
||||
// Fill input
|
||||
const urlInput = targetContainer.querySelector('#url-upload-input');
|
||||
if (urlInput) {
|
||||
urlInput.value = text;
|
||||
urlInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
8200
public/s/js/f0ckm.js
Normal file
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
723
public/s/js/flash_yank.js
Normal file
@@ -0,0 +1,723 @@
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'w0bmFlashFilterSettings_v1';
|
||||
const DEFAULT_SETTINGS = {
|
||||
yank: 60, // 0..100 master "yank"
|
||||
enabled: false,
|
||||
advanced: false, // if true, use individual params instead of yank mapping
|
||||
enableResolution: true, // per-axis degradation toggles (advanced only)
|
||||
enableFps: true,
|
||||
enablePalette: true,
|
||||
internalWidth: 320,
|
||||
fps: 12,
|
||||
paletteLevels: 4
|
||||
};
|
||||
|
||||
let settings = loadSettings();
|
||||
let currentConfig = computeConfig();
|
||||
let chanLUT = buildChannelLUT(currentConfig.paletteLevels);
|
||||
|
||||
let currentController = null;
|
||||
let hotkeyAttached = false;
|
||||
let ui = null; // { panel, slider, yankValue, info, toggle }
|
||||
const isMobile = /Mobi/i.test(navigator.userAgent);
|
||||
|
||||
function isItemPage() {
|
||||
const path = window.location.pathname;
|
||||
// Strictly match item pages (e.g., /123, /user/name/123) and exclude grids/specials
|
||||
const isItem = (path.match(/^\/\d+/) || path.split('/').some(s => /^\d+$/.test(s))) && !path.match(/\/p\//);
|
||||
const isForbidden = path === '/upload' || path.startsWith('/admin') || path.startsWith('/mod');
|
||||
return isItem && !isForbidden;
|
||||
}
|
||||
|
||||
// ---------- Settings / Config ----------
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const val = localStorage.getItem(STORAGE_KEY);
|
||||
if (val) {
|
||||
const obj = JSON.parse(val);
|
||||
return Object.assign({}, DEFAULT_SETTINGS, obj);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings from localStorage', e);
|
||||
}
|
||||
return Object.assign({}, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings to localStorage', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Yank 0..100 => config
|
||||
// Higher yank = harsher: lower res, lower FPS, fewer colors
|
||||
function computeConfigFromYank(yank) {
|
||||
const t = Math.min(1, Math.max(0, yank / 100));
|
||||
// Match the ranges used by the advanced sliders:
|
||||
// Resolution: 160..800, FPS: 8..30, Palette levels: 2..12
|
||||
const widthMax = 800, widthMin = 160;
|
||||
const fpsMax = 30, fpsMin = 8;
|
||||
const levelsMax = 12, levelsMin = 2; // per channel
|
||||
|
||||
const width = Math.round(widthMax - (widthMax - widthMin) * t);
|
||||
const height = Math.round(width * 9 / 16);
|
||||
const fps = Math.round(fpsMax - (fpsMax - fpsMin) * t);
|
||||
const paletteLevels = Math.round(levelsMax - (levelsMax - levelsMin) * t);
|
||||
|
||||
return {
|
||||
internalWidth: width,
|
||||
internalHeight: null, // Will be calculated dynamically based on aspect ratio
|
||||
fps,
|
||||
paletteLevels
|
||||
};
|
||||
}
|
||||
|
||||
// Update advanced parameters from current yank value
|
||||
function updateAdvancedFromYank() {
|
||||
const cfg = computeConfigFromYank(settings.yank);
|
||||
settings.internalWidth = cfg.internalWidth;
|
||||
settings.fps = cfg.fps;
|
||||
settings.paletteLevels = cfg.paletteLevels;
|
||||
}
|
||||
|
||||
// Derive an approximate yank from current advanced sliders (0..100)
|
||||
function updateYankFromAdvanced() {
|
||||
const widthMax = 800, widthMin = 160;
|
||||
const fpsMax = 30, fpsMin = 8;
|
||||
const levelsMax = 12, levelsMin = 2;
|
||||
|
||||
const tWidth = (widthMax - settings.internalWidth) / (widthMax - widthMin);
|
||||
const tFps = (fpsMax - settings.fps) / (fpsMax - fpsMin);
|
||||
const tPalette = (levelsMax - settings.paletteLevels) / (levelsMax - levelsMin);
|
||||
|
||||
let t = (tWidth + tFps + tPalette) / 3;
|
||||
t = Math.min(1, Math.max(0, t));
|
||||
settings.yank = Math.round(t * 100);
|
||||
}
|
||||
|
||||
// Combine yank mapping with optional advanced overrides
|
||||
function computeConfig() {
|
||||
if (settings.advanced) {
|
||||
const w = clamp(settings.internalWidth || DEFAULT_SETTINGS.internalWidth, 160, 800);
|
||||
const fps = clamp(settings.fps || DEFAULT_SETTINGS.fps, 8, 30);
|
||||
const pl = clamp(settings.paletteLevels || DEFAULT_SETTINGS.paletteLevels, 2, 12);
|
||||
settings.internalWidth = w;
|
||||
settings.fps = fps;
|
||||
settings.paletteLevels = pl;
|
||||
return {
|
||||
internalWidth: w,
|
||||
internalHeight: null, // Calculated dynamically
|
||||
fps,
|
||||
paletteLevels: pl
|
||||
};
|
||||
}
|
||||
|
||||
const cfg = computeConfigFromYank(settings.yank);
|
||||
// keep advanced values in sync so toggling advanced inherits current feel
|
||||
settings.internalWidth = cfg.internalWidth;
|
||||
settings.fps = cfg.fps;
|
||||
settings.paletteLevels = cfg.paletteLevels;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
v = Number(v);
|
||||
if (isNaN(v)) return min;
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function applySettingsToRuntime() {
|
||||
currentConfig = computeConfig();
|
||||
chanLUT = buildChannelLUT(currentConfig.paletteLevels);
|
||||
if (currentController && currentController.onConfigChanged) {
|
||||
currentController.onConfigChanged();
|
||||
}
|
||||
updateUIFromSettings();
|
||||
}
|
||||
|
||||
// ---------- Palette / Image processing ----------
|
||||
|
||||
function buildChannelLUT(levels) {
|
||||
const lut = new Uint8Array(256);
|
||||
if (levels <= 1) {
|
||||
lut.fill(0);
|
||||
return lut;
|
||||
}
|
||||
const step = 255 / (levels - 1);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const idx = Math.round(i / step);
|
||||
lut[i] = Math.round(idx * step);
|
||||
}
|
||||
return lut;
|
||||
}
|
||||
|
||||
function applyPalette(imageData, lut) {
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = lut[data[i]];
|
||||
data[i + 1] = lut[data[i + 1]];
|
||||
data[i + 2] = lut[data[i + 2]];
|
||||
// alpha unchanged
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Video controller ----------
|
||||
|
||||
function createCanvasForVideo(video) {
|
||||
// Ensure parent is relative for absolute positioning of canvas
|
||||
const style = window.getComputedStyle(video.parentNode);
|
||||
if (style.position === 'static') {
|
||||
video.parentNode.style.position = 'relative';
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.zIndex = '1'; // Lower z-index so controls can sit on top
|
||||
canvas.style.pointerEvents = 'none'; // Let clicks pass through to controls
|
||||
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.backgroundColor = 'transparent';
|
||||
canvas.style.imageRendering = 'pixelated';
|
||||
canvas.style.objectFit = 'contain';
|
||||
video.parentNode.insertBefore(canvas, video.nextSibling);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function makeController(video) {
|
||||
const canvas = createCanvasForVideo(video);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
let enabled = false;
|
||||
let intervalId = null;
|
||||
|
||||
function applyConfigToCanvas() {
|
||||
const rect = video.getBoundingClientRect();
|
||||
const displayWidth = rect.width || video.clientWidth || video.width || 640;
|
||||
const displayHeight = rect.height || video.clientHeight || video.height || 360;
|
||||
|
||||
let internalWidth = currentConfig.internalWidth;
|
||||
let internalHeight;
|
||||
|
||||
// Calculate aspect ratio
|
||||
const videoWidth = video.videoWidth || 640;
|
||||
const videoHeight = video.videoHeight || 360;
|
||||
const aspectRatio = videoHeight / videoWidth;
|
||||
|
||||
// If advanced resolution scaling is disabled, match display size for best quality
|
||||
if (settings.advanced && !settings.enableResolution) {
|
||||
internalWidth = Math.round(displayWidth);
|
||||
internalHeight = Math.round(displayHeight);
|
||||
} else {
|
||||
// Calculate height based on fixed width and aspect ratio
|
||||
internalHeight = Math.round(internalWidth * aspectRatio);
|
||||
}
|
||||
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
// The canvas fills the container (which is sized by the hidden video)
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
canvas.style.maxWidth = '';
|
||||
canvas.style.aspectRatio = '';
|
||||
}
|
||||
|
||||
applyConfigToCanvas();
|
||||
|
||||
function drawFrame() {
|
||||
if (!enabled || video.paused || video.ended) return;
|
||||
try {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
|
||||
// If advanced palette reduction is disabled, skip quantization
|
||||
if (!(settings.advanced && !settings.enablePalette)) {
|
||||
const frame = ctx.getImageData(0, 0, w, h);
|
||||
applyPalette(frame, chanLUT);
|
||||
ctx.putImageData(frame, 0, 0);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function startLoop() {
|
||||
if (intervalId || !enabled) return;
|
||||
const effectiveFps = (settings.advanced && !settings.enableFps) ? 60 : currentConfig.fps;
|
||||
intervalId = setInterval(drawFrame, 1000 / effectiveFps);
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function enable() {
|
||||
if (enabled) return;
|
||||
enabled = true;
|
||||
canvas.style.display = 'block';
|
||||
video.style.visibility = 'hidden'; // Keep layout space!
|
||||
if (!video.paused && !video.ended) startLoop();
|
||||
}
|
||||
|
||||
function disable() {
|
||||
if (!enabled) return;
|
||||
enabled = false;
|
||||
canvas.style.display = 'none';
|
||||
video.style.visibility = '';
|
||||
stopLoop();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (enabled) disable();
|
||||
else enable();
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function onConfigChanged() {
|
||||
const wasEnabled = enabled;
|
||||
stopLoop();
|
||||
applyConfigToCanvas();
|
||||
if (wasEnabled) {
|
||||
startLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disable();
|
||||
canvas.remove();
|
||||
video.removeEventListener('play', startLoop);
|
||||
video.removeEventListener('pause', stopLoop);
|
||||
video.removeEventListener('ended', stopLoop);
|
||||
}
|
||||
|
||||
video.addEventListener('play', startLoop);
|
||||
video.addEventListener('pause', stopLoop);
|
||||
video.addEventListener('ended', stopLoop);
|
||||
video.addEventListener('loadedmetadata', applyConfigToCanvas); // Recalculate when metadata allows
|
||||
|
||||
return { enable, disable, toggle, isEnabled, destroy, onConfigChanged };
|
||||
}
|
||||
|
||||
function setupVideo(video) {
|
||||
if (!video) return;
|
||||
|
||||
if (!isItemPage()) {
|
||||
if (ui) ui.wrapper.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Prioritize the main item player (id="my-video" or class "viewer")
|
||||
const isPrimary = video.id === 'my-video' || video.classList.contains('viewer') || video.classList.contains('v0ck_video');
|
||||
|
||||
if (video.dataset.flashFilterAttached === '1') {
|
||||
// If already attached, ensure its currentController is restored if it's the primary one
|
||||
if (isPrimary && !currentController) {
|
||||
currentController = video.__flashFilterController;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
video.dataset.flashFilterAttached = '1';
|
||||
const controller = makeController(video);
|
||||
video.__flashFilterController = controller;
|
||||
|
||||
// If this is the primary video, or we don't have one yet, set as current
|
||||
if (isPrimary || !currentController) {
|
||||
currentController = controller;
|
||||
}
|
||||
|
||||
// Always sync with global setting on creation
|
||||
if (settings.enabled) {
|
||||
controller.enable();
|
||||
} else {
|
||||
controller.disable();
|
||||
}
|
||||
|
||||
// Small delay to ensure v0ck has injected its controls
|
||||
setTimeout(() => {
|
||||
if (ui) updateUIFromSettings();
|
||||
}, 50);
|
||||
if (ui && isPrimary) {
|
||||
ui.wrapper.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function scanExistingVideos() {
|
||||
document.querySelectorAll('video').forEach(setupVideo);
|
||||
}
|
||||
|
||||
function startObserver() {
|
||||
if (!document.body) return;
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
m.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (node.tagName === 'VIDEO') {
|
||||
setupVideo(node);
|
||||
} else if (node.querySelectorAll) {
|
||||
node.querySelectorAll('video').forEach(setupVideo);
|
||||
}
|
||||
});
|
||||
|
||||
m.removedNodes.forEach((node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
|
||||
const vids = [];
|
||||
if (node.tagName === 'VIDEO') {
|
||||
vids.push(node);
|
||||
}
|
||||
if (node.querySelectorAll) {
|
||||
node.querySelectorAll('video').forEach(v => vids.push(v));
|
||||
}
|
||||
|
||||
vids.forEach((v) => {
|
||||
const c = v.__flashFilterController;
|
||||
if (c) {
|
||||
c.destroy();
|
||||
if (currentController === c) {
|
||||
currentController = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ui && document.querySelectorAll('video').length === 0) {
|
||||
ui.wrapper.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// ---------- UI Panel ----------
|
||||
|
||||
function createOptionsUI() {
|
||||
if (ui || !document.body) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.position = 'fixed';
|
||||
wrapper.style.bottom = '10px';
|
||||
wrapper.style.right = '10px';
|
||||
wrapper.style.zIndex = '99999';
|
||||
wrapper.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||
wrapper.style.fontSize = '12px';
|
||||
wrapper.style.color = '#fff';
|
||||
wrapper.style.display = 'none'; // Initially hidden
|
||||
wrapper.id = 'flash-yank-ui';
|
||||
|
||||
const floatingBadge = document.createElement('div');
|
||||
floatingBadge.id = 'w0bm-floating-swf';
|
||||
floatingBadge.textContent = 'SWF';
|
||||
floatingBadge.style.background = 'rgba(0,0,0,0.9)';
|
||||
floatingBadge.style.padding = '2px 8px';
|
||||
floatingBadge.style.borderRadius = '3px';
|
||||
floatingBadge.style.cursor = 'pointer';
|
||||
floatingBadge.style.fontWeight = 'bold';
|
||||
floatingBadge.style.letterSpacing = '0.08em';
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.style.position = 'fixed';
|
||||
panel.style.right = '10px';
|
||||
panel.style.bottom = '22px';
|
||||
panel.style.background = 'rgba(0,0,0,0.9)';
|
||||
panel.style.color = '#fff';
|
||||
panel.style.padding = '6px 10px';
|
||||
panel.style.fontSize = '12px';
|
||||
panel.style.borderRadius = '4px';
|
||||
panel.style.maxWidth = '320px';
|
||||
panel.style.minWidth = '240px';
|
||||
panel.style.maxHeight = 'calc(100vh - 40px)'; // Prevent exceeding screen height
|
||||
panel.style.overflowY = 'auto'; // Make scrollable
|
||||
panel.style.boxSizing = 'border-box';
|
||||
panel.style.boxShadow = '0 0 6px rgba(0,0,0,0.7)';
|
||||
panel.style.display = 'none';
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div id="w0bm-title" style="font-weight:bold; cursor:pointer;">Flash Yank</div>
|
||||
<div id="w0bm-close" style="cursor:pointer; font-size:16px; padding: 0 4px;">×</div>
|
||||
</div>
|
||||
|
||||
<label style="font-size:11px; display:block; margin-bottom:2px;">
|
||||
Yank: <span id="w0bm-yank-value"></span>
|
||||
</label>
|
||||
<input id="w0bm-yank-slider" type="range" min="0" max="100" step="1" style="width:100%;">
|
||||
|
||||
<div id="w0bm-yank-info" style="font-size:10px; margin-top:8px; opacity: 0.8;"></div>
|
||||
`;
|
||||
|
||||
wrapper.appendChild(panel);
|
||||
wrapper.appendChild(floatingBadge);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
let panelVisible = false;
|
||||
const HOVER_MARGIN = 50; // px around panel before closing
|
||||
|
||||
let activePlayer = null;
|
||||
|
||||
function showPanel(trigger) {
|
||||
if (panelVisible) return;
|
||||
panelVisible = true;
|
||||
panel.style.display = 'block';
|
||||
|
||||
activePlayer = trigger.closest('.v0ck');
|
||||
if (activePlayer) activePlayer.classList.add('v0ck_swf_active');
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
if (!panelVisible) return;
|
||||
panelVisible = false;
|
||||
panel.style.display = 'none';
|
||||
|
||||
if (activePlayer) {
|
||||
activePlayer.classList.remove('v0ck_swf_active');
|
||||
activePlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBadgeHover(target) {
|
||||
if (target.id === 'toggleswf' || target === floatingBadge) {
|
||||
// Anchor to trigger element (shared logic for mobile/desktop)
|
||||
const rect = target.getBoundingClientRect();
|
||||
panel.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
|
||||
panel.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
panel.style.transform = 'none';
|
||||
panel.style.maxWidth = isMobile ? '90vw' : '320px';
|
||||
|
||||
showPanel(target);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', (e) => {
|
||||
const target = e.target.closest('#toggleswf') || (e.target === floatingBadge ? floatingBadge : null);
|
||||
if (target) {
|
||||
handleBadgeHover(target);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!panelVisible || isMobile) return; // Don't hide by mousemove on mobile
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const left = rect.left - HOVER_MARGIN;
|
||||
const right = rect.right + HOVER_MARGIN;
|
||||
const top = rect.top - HOVER_MARGIN;
|
||||
const bottom = rect.bottom + HOVER_MARGIN;
|
||||
|
||||
if (e.clientX < left || e.clientX > right || e.clientY < top || e.clientY > bottom) {
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
const title = panel.querySelector('#w0bm-title');
|
||||
const slider = panel.querySelector('#w0bm-yank-slider');
|
||||
const yankValue = panel.querySelector('#w0bm-yank-value');
|
||||
const info = panel.querySelector('#w0bm-yank-info');
|
||||
|
||||
ui = {
|
||||
wrapper,
|
||||
floatingBadge,
|
||||
panel,
|
||||
title,
|
||||
slider,
|
||||
yankValue,
|
||||
info
|
||||
};
|
||||
|
||||
slider.addEventListener('input', (e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
settings.yank = isNaN(val) ? 0 : Math.min(100, Math.max(0, val));
|
||||
// Yank drives the underlying advanced parameters
|
||||
updateAdvancedFromYank();
|
||||
saveSettings();
|
||||
applySettingsToRuntime();
|
||||
});
|
||||
|
||||
function toggleEnabledFromUI(targetController) {
|
||||
settings.enabled = !settings.enabled;
|
||||
saveSettings();
|
||||
|
||||
// If a specific controller was the target (e.g. clicked inside a player), use it.
|
||||
// Otherwise use the global currentController (main player).
|
||||
const controller = targetController || currentController;
|
||||
if (controller) {
|
||||
if (settings.enabled) controller.enable();
|
||||
else controller.disable();
|
||||
}
|
||||
|
||||
// For global consistency, if settings.enabled changed, we might want to toggle ALL?
|
||||
// But per user request, we focus on the item player.
|
||||
// If there's another video that isn't the currentController, it won't toggle here,
|
||||
// but the hotkey and UI rely on currentController.
|
||||
|
||||
updateUIFromSettings();
|
||||
}
|
||||
|
||||
// Click SWF badge or title to toggle filter enabled/disabled
|
||||
document.addEventListener('click', (e) => {
|
||||
const swfBtn = e.target.closest('#toggleswf');
|
||||
const isBadge = swfBtn || e.target === floatingBadge;
|
||||
const isTitle = e.target === title;
|
||||
if (isBadge || isTitle) {
|
||||
// If clicked a button inside a player, try to get THAT player's controller
|
||||
let targetCtrl = null;
|
||||
if (swfBtn) {
|
||||
const player = swfBtn.closest('.v0ck');
|
||||
const vid = player ? player.querySelector('video') : null;
|
||||
if (vid && vid.__flashFilterController) {
|
||||
targetCtrl = vid.__flashFilterController;
|
||||
}
|
||||
}
|
||||
toggleEnabledFromUI(targetCtrl);
|
||||
// On mobile, explicitly show panel on click/tap
|
||||
if (isMobile) {
|
||||
handleBadgeHover(swfBtn || floatingBadge);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = panel.querySelector('#w0bm-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
// Close Flash Yank panel when the main settings menu is closed
|
||||
document.addEventListener('v0ck_settings_closed', () => {
|
||||
hidePanel();
|
||||
});
|
||||
|
||||
updateUIFromSettings();
|
||||
}
|
||||
|
||||
function updateUIFromSettings() {
|
||||
if (!ui) return;
|
||||
|
||||
const isItem = isItemPage();
|
||||
const hasVideos = document.querySelectorAll('video').length > 0;
|
||||
|
||||
if (!isItem || !hasVideos) {
|
||||
ui.wrapper.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
ui.slider.value = settings.yank;
|
||||
ui.yankValue.textContent = settings.yank + '%';
|
||||
|
||||
const approxColors = Math.pow(currentConfig.paletteLevels, 3);
|
||||
ui.info.textContent = currentConfig.internalWidth + 'px width, ' +
|
||||
currentConfig.fps + 'fps, ~' + approxColors + ' colors';
|
||||
|
||||
// Enable/disable controls based on enabled state
|
||||
const isEnabled = !!settings.enabled;
|
||||
ui.slider.disabled = !isEnabled;
|
||||
|
||||
// Visual state: strike-through when disabled
|
||||
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
|
||||
|
||||
// Handle floating badge visibility
|
||||
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
|
||||
|
||||
// Style both (if they exist)
|
||||
[ui.floatingBadge, ...swfButtons].forEach(b => {
|
||||
if (!b) return;
|
||||
b.style.textDecoration = isEnabled ? 'none' : 'line-through';
|
||||
b.style.opacity = isEnabled ? '1' : '0.6';
|
||||
if (b.classList.contains('v0ck_menu_item')) {
|
||||
b.style.color = isEnabled ? 'var(--accent, #9f0)' : '#fff';
|
||||
b.style.fontWeight = isEnabled ? 'bold' : 'normal';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Hotkey ----------
|
||||
|
||||
function attachHotkey() {
|
||||
if (hotkeyAttached) return;
|
||||
hotkeyAttached = true;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !isItemPage()) return;
|
||||
if (e.key.toLowerCase() !== 's') return;
|
||||
|
||||
// Ignore when typing in an input, textarea, or contenteditable
|
||||
const tag = document.activeElement?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable) return;
|
||||
|
||||
if (!currentController) return;
|
||||
|
||||
settings.enabled = !settings.enabled;
|
||||
if (settings.enabled) currentController.enable();
|
||||
else currentController.disable();
|
||||
|
||||
saveSettings();
|
||||
updateUIFromSettings();
|
||||
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Bootstrapping ----------
|
||||
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', fn, { once: true });
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
// firefox mobile check
|
||||
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
|
||||
if (isFirefoxMobile) {
|
||||
console.log("Firefox Mobile detected, disabling Flash Yank script.");
|
||||
return;
|
||||
}
|
||||
|
||||
applySettingsToRuntime();
|
||||
createOptionsUI();
|
||||
scanExistingVideos();
|
||||
startObserver();
|
||||
attachHotkey();
|
||||
|
||||
// Path change handling for AJAX transitions
|
||||
const handlePathChange = () => {
|
||||
// Reset currentController on path change to force re-discovery
|
||||
currentController = null;
|
||||
if (!isItemPage()) {
|
||||
if (ui) ui.wrapper.style.display = 'none';
|
||||
} else {
|
||||
// Scan after a short delay to allow DOM/v0ck to settle
|
||||
setTimeout(() => {
|
||||
scanExistingVideos();
|
||||
updateUIFromSettings();
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePathChange);
|
||||
document.addEventListener('f0ck:contentLoaded', handlePathChange);
|
||||
});
|
||||
})();
|
||||
1271
public/s/js/globalchat.js
Normal file
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
48
public/s/js/koepfe.js
Normal file
@@ -0,0 +1,48 @@
|
||||
(function() {
|
||||
const koepfe = window.f0ckKoepfe;
|
||||
if (!koepfe || !koepfe.length) return;
|
||||
|
||||
let current = null;
|
||||
|
||||
const pick = () => {
|
||||
if (koepfe.length === 1) return koepfe[0];
|
||||
let next;
|
||||
do { next = koepfe[~~(Math.random() * koepfe.length)]; } while (next === current && koepfe.length > 1);
|
||||
return next;
|
||||
};
|
||||
|
||||
if (document.getElementById('koepfe-img')) return;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.id = 'koepfe-img';
|
||||
img.alt = '';
|
||||
img.draggable = false;
|
||||
|
||||
const show = (src) => {
|
||||
current = src;
|
||||
img.classList.remove('visible');
|
||||
img.onload = () => img.classList.add('visible');
|
||||
img.src = src;
|
||||
};
|
||||
|
||||
|
||||
document.body.prepend(img);
|
||||
show(pick());
|
||||
|
||||
// Hide the image immediately when a new AJAX load starts to prevent it
|
||||
// from being revealed while the page content is empty/churning.
|
||||
window.addEventListener('pjax:start', () => {
|
||||
img.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Change on AJAX navigation (next/prev/random/page change) but NOT infinite scroll
|
||||
document.addEventListener('f0ck:contentLoaded', (e) => {
|
||||
if (e.detail && e.detail.isInfinite) return;
|
||||
|
||||
// Small delay to ensure the content layer is fully painted on top
|
||||
// before we swap the background image, preventing 'pop-in' flickers.
|
||||
setTimeout(() => {
|
||||
show(pick());
|
||||
}, 150);
|
||||
});
|
||||
})();
|
||||
69
public/s/js/marked.min.js
vendored
Normal file
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
405
public/s/js/meme-creator.js
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Meme Creator Logic
|
||||
* DYNAMIC MULTIPLE LAYERS
|
||||
*/
|
||||
|
||||
(() => {
|
||||
const canvas = document.getElementById('memeCanvas');
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const layersContainer = document.getElementById('textLayersContainer');
|
||||
const addTextBtn = document.getElementById('addText');
|
||||
|
||||
const uploadBtn = document.getElementById('uploadMeme');
|
||||
|
||||
// Core state
|
||||
let textLayers = [];
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
let draggingLayer = null;
|
||||
let hoveredLayer = null;
|
||||
let img = new Image();
|
||||
|
||||
const memeFont = 'Impact, Charcoal, sans-serif';
|
||||
|
||||
// Image Setup
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
canvas.width = img.width || 800;
|
||||
canvas.height = img.height || 600;
|
||||
|
||||
const defaultSize = 40;
|
||||
|
||||
// Initial layers
|
||||
textLayers = [
|
||||
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||
];
|
||||
|
||||
renderInputs();
|
||||
draw();
|
||||
};
|
||||
img.src = window.memeTemplate.url;
|
||||
|
||||
// Ensure font is loaded before first draw
|
||||
if (document.fonts) {
|
||||
document.fonts.ready.then(() => {
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function renderInputs() {
|
||||
layersContainer.innerHTML = '';
|
||||
textLayers.forEach((layer, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-group layer-input-group';
|
||||
div.style.marginBottom = '20px';
|
||||
div.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<label style="margin-bottom: 0; font-weight: bold;">${(window.f0ckI18n?.meme?.text_layer) || 'Text Layer'} ${index + 1}</label>
|
||||
<button class="remove-layer" data-id="${layer.id}" style="background: transparent; border: none; color: #ff4444; cursor: pointer; padding: 0 5px;">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<textarea data-id="${layer.id}" placeholder="${(window.f0ckI18n?.meme?.enter_text) || 'Enter text...'}" rows="2" style="width: 100%; margin-bottom: 8px;">${layer.text}</textarea>
|
||||
|
||||
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
||||
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textarea = div.querySelector('textarea');
|
||||
textarea.addEventListener('input', (e) => {
|
||||
layer.text = e.target.value;
|
||||
draw();
|
||||
});
|
||||
|
||||
const fsInput = div.querySelector('.layer-fs-input');
|
||||
const fsVal = div.querySelector('.layer-fs-val');
|
||||
fsInput.addEventListener('input', (e) => {
|
||||
layer.fontSize = parseInt(e.target.value);
|
||||
fsVal.textContent = layer.fontSize;
|
||||
draw();
|
||||
});
|
||||
|
||||
const removeBtn = div.querySelector('.remove-layer');
|
||||
removeBtn.addEventListener('click', () => {
|
||||
textLayers = textLayers.filter(l => l.id !== layer.id);
|
||||
renderInputs();
|
||||
draw();
|
||||
});
|
||||
|
||||
layersContainer.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
addTextBtn.addEventListener('click', () => {
|
||||
textLayers.push({
|
||||
id: Date.now(),
|
||||
text: 'NEW TEXT',
|
||||
x: canvas.width / 2,
|
||||
y: canvas.height / 2,
|
||||
fontSize: 40
|
||||
});
|
||||
renderInputs();
|
||||
draw();
|
||||
});
|
||||
|
||||
function draw() {
|
||||
if (!img.complete) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.miterLimit = 2; // Prevent sharp spikes in characters like 'A'
|
||||
|
||||
const globalFontSize = 40;
|
||||
|
||||
// Render each layer
|
||||
textLayers.forEach((layer) => {
|
||||
if (!layer.text) return;
|
||||
|
||||
const fontSize = layer.fontSize || 40;
|
||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||
|
||||
let displayStr = layer.text.toUpperCase();
|
||||
const lines = displayStr.split('\n');
|
||||
const h = lines.length * fontSize * 1.1;
|
||||
const w = canvas.width * 0.9;
|
||||
|
||||
// Box for the dragged/hovered layer (top-most layer gets preference)
|
||||
if (hoveredLayer === layer || draggingLayer === layer) {
|
||||
ctx.save();
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeStyle = '#9f0';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(layer.x - w / 2, layer.y - 10, w, h + 20);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const yOffset = i * (fontSize * 1.1);
|
||||
const renderX = Math.round(layer.x);
|
||||
const renderY = Math.round(layer.y + yOffset);
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
||||
ctx.strokeText(line, renderX, renderY);
|
||||
ctx.fillText(line, renderX, renderY);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Options hooks removed as they are now hardcoded and the inputs are gone
|
||||
|
||||
const getEventPos = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * (canvas.width / rect.width),
|
||||
y: (clientY - rect.top) * (canvas.height / rect.height)
|
||||
};
|
||||
};
|
||||
|
||||
const isInsideText = (pt, layer) => {
|
||||
if (!layer.text) return false;
|
||||
const fontSize = layer.fontSize || 40;
|
||||
const lines = layer.text.split('\n');
|
||||
const w = canvas.width * 0.95;
|
||||
const h = lines.length * fontSize * 1.2;
|
||||
|
||||
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
||||
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
||||
};
|
||||
|
||||
// POINTER EVENTS
|
||||
const onStart = (e) => {
|
||||
const pt = getEventPos(e);
|
||||
|
||||
// Find layer (start from top-most, reverse of render order)
|
||||
draggingLayer = [...textLayers].reverse().find(layer => isInsideText(pt, layer)) || null;
|
||||
|
||||
if (draggingLayer) {
|
||||
dragOffset = { x: pt.x - draggingLayer.x, y: pt.y - draggingLayer.y };
|
||||
if (e.pointerId) canvas.setPointerCapture(e.pointerId);
|
||||
canvas.style.cursor = 'grabbing';
|
||||
draw();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onMove = (e) => {
|
||||
const pt = getEventPos(e);
|
||||
|
||||
if (draggingLayer) {
|
||||
draggingLayer.x = pt.x - dragOffset.x;
|
||||
draggingLayer.y = pt.y - dragOffset.y;
|
||||
draw();
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Hover logic
|
||||
const currentHover = [...textLayers].reverse().find(layer => isInsideText(pt, layer)) || null;
|
||||
if (currentHover !== hoveredLayer) {
|
||||
hoveredLayer = currentHover;
|
||||
canvas.style.cursor = hoveredLayer ? 'grab' : 'crosshair';
|
||||
draw();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (draggingLayer) {
|
||||
if (e.pointerId) canvas.releasePointerCapture(e.pointerId);
|
||||
draggingLayer = null;
|
||||
canvas.style.cursor = 'grab';
|
||||
draw();
|
||||
}
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerdown', onStart);
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onEnd);
|
||||
canvas.addEventListener('pointercancel', onEnd);
|
||||
canvas.addEventListener('mousedown', onStart);
|
||||
// Upload
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
||||
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
||||
|
||||
const isOrakelVon10 = subCategory === 'von10';
|
||||
const isOrakelUser = subCategory === 'user';
|
||||
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
|
||||
|
||||
let uploadCanvas = canvas;
|
||||
|
||||
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
|
||||
// Create an off-screen canvas to apply the orakel answer silently
|
||||
uploadCanvas = document.createElement('canvas');
|
||||
uploadCanvas.width = canvas.width;
|
||||
uploadCanvas.height = canvas.height;
|
||||
const uCtx = uploadCanvas.getContext('2d');
|
||||
|
||||
// Copy current canvas state
|
||||
uCtx.drawImage(canvas, 0, 0);
|
||||
|
||||
let result = '';
|
||||
if (isOrakelNormal) {
|
||||
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
||||
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
||||
} else if (isOrakelUser) {
|
||||
try {
|
||||
const res = await fetch('/api/v2/orakel/user');
|
||||
const data = await res.json();
|
||||
result = (data.success && data.username) ? `${data.display_name || data.username}|||ID: ${data.id}` : 'Anonymous';
|
||||
} catch (e) {
|
||||
result = 'Anonymous';
|
||||
}
|
||||
} else if (isOrakelVon10) {
|
||||
result = Math.floor(Math.random() * 11).toString();
|
||||
}
|
||||
|
||||
// Draw Orakel result on the hidden canvas
|
||||
uCtx.save();
|
||||
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||
uCtx.textAlign = 'center';
|
||||
uCtx.textBaseline = 'middle';
|
||||
|
||||
if (isOrakelNormal) {
|
||||
uCtx.shadowBlur = 20;
|
||||
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
||||
} else if (isOrakelVon10) {
|
||||
uCtx.shadowBlur = 0; // No shadow as requested
|
||||
} else {
|
||||
// No shadow for the User Orakel
|
||||
uCtx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
uCtx.fillStyle = '#fff';
|
||||
uCtx.strokeStyle = '#000';
|
||||
uCtx.lineWidth = 10;
|
||||
uCtx.miterLimit = 2;
|
||||
|
||||
// Adjust position for user Orakel (reverting to +10 offset)
|
||||
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
||||
|
||||
if (isOrakelVon10) {
|
||||
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
||||
}
|
||||
|
||||
const xPos = Math.round(uploadCanvas.width / 2);
|
||||
|
||||
// Auto-fit font size for user orakel — shrink until text fits within image width
|
||||
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
||||
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
||||
|
||||
if (isOrakelUser) {
|
||||
const parts = result.split('|||');
|
||||
const namePart = parts[0];
|
||||
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
||||
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
||||
|
||||
// Even tighter threshold for User Orakel (approx 25% total padding)
|
||||
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
||||
|
||||
let currentFontSize = 74;
|
||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||
|
||||
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||
currentFontSize -= 2;
|
||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||
}
|
||||
|
||||
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||
|
||||
if (combinedFits) {
|
||||
// Single line — potentially shrunk for long names
|
||||
uCtx.fillText(combinedText, xPos, yPos);
|
||||
} else {
|
||||
// Two lines — auto-fit just the name, ID below
|
||||
let nameFontSize = 74;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||
nameFontSize -= 2;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
}
|
||||
|
||||
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||
const lineGap = Math.round(nameFontSize * 0.65);
|
||||
const nameY = Math.round(yPos - lineGap / 2);
|
||||
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
uCtx.fillText(namePart, xPos, nameY);
|
||||
|
||||
if (idPart) {
|
||||
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||
uCtx.fillText(idPart, xPos, idY);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal / von10 — single line as before
|
||||
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||
uCtx.strokeText(result, xPos, yPos);
|
||||
uCtx.fillText(result, xPos, yPos);
|
||||
}
|
||||
uCtx.restore();
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> ' + (window.f0ckI18n?.uploading || 'Uploading...');
|
||||
|
||||
try {
|
||||
const blob = await new Promise(resolve => uploadCanvas.toBlob(resolve, 'image/jpeg', 0.95));
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, `meme-${Date.now()}.jpg`);
|
||||
const defaultTags = document.getElementById('tags').value || 'meme';
|
||||
const autoTag = window.memeTemplate ? window.memeTemplate.name : '';
|
||||
const tags = `${defaultTags}, ${autoTag}`;
|
||||
|
||||
formData.append('rating', 'sfw');
|
||||
formData.append('tags', tags);
|
||||
formData.append('csrf_token', window.csrf_token);
|
||||
|
||||
const res = await fetch('/api/v2/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'X-CSRF-Token': window.csrf_token, 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
const dest = result.redirect || '/meme';
|
||||
if (window.loadItemAjax) {
|
||||
window.loadItemAjax(dest);
|
||||
} else if (window.loadPageAjax) {
|
||||
window.loadPageAjax(dest);
|
||||
} else {
|
||||
window.location.href = dest;
|
||||
}
|
||||
}
|
||||
else {
|
||||
window.flashMessage('Error: ' + result.msg, 3000, 'error');
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.innerHTML = `<i class="fa fa-upload"></i> ${(window.f0ckI18n?.meme?.upload_btn) || 'Upload Meme'}`;
|
||||
}
|
||||
} catch (err) {
|
||||
window.flashMessage('Upload failed', 3000, 'error');
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial draw
|
||||
setTimeout(draw, 300);
|
||||
}
|
||||
})();
|
||||
|
||||
199
public/s/js/mention_autocomplete.js
Normal file
199
public/s/js/mention_autocomplete.js
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* MentionAutocomplete — Real-time user mention suggestions for textareas
|
||||
* Detects "@" and fetches matching users from the backend API.
|
||||
*/
|
||||
window.MentionAutocomplete = (() => {
|
||||
let activeDropdown = null;
|
||||
let selectedIndex = -1;
|
||||
let suggestions = [];
|
||||
let currentInput = null;
|
||||
let mentionStart = -1;
|
||||
let query = '';
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
let debounceTimer = null;
|
||||
|
||||
function destroy() {
|
||||
if (activeDropdown) {
|
||||
activeDropdown.remove();
|
||||
activeDropdown = null;
|
||||
}
|
||||
selectedIndex = -1;
|
||||
suggestions = [];
|
||||
mentionStart = -1;
|
||||
query = '';
|
||||
}
|
||||
|
||||
function positionDropdown(input) {
|
||||
if (!activeDropdown) return;
|
||||
const rect = input.getBoundingClientRect();
|
||||
activeDropdown.style.left = `${rect.left}px`;
|
||||
activeDropdown.style.width = `${rect.width}px`;
|
||||
// layout-modern: input is near the top of the sidebar → open downward
|
||||
if (document.body.classList.contains('layout-modern')) {
|
||||
activeDropdown.style.top = `${rect.bottom}px`;
|
||||
activeDropdown.style.bottom = 'auto';
|
||||
} else {
|
||||
activeDropdown.style.bottom = `${window.innerHeight - rect.top + 5}px`;
|
||||
activeDropdown.style.top = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(q) {
|
||||
try {
|
||||
const res = await fetch(`/api/v2/users/suggest?q=${encodeURIComponent(q)}`);
|
||||
const data = await res.json();
|
||||
return data.suggestions || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderDropdown() {
|
||||
if (!activeDropdown) {
|
||||
activeDropdown = document.createElement('div');
|
||||
activeDropdown.className = 'mention-suggestions';
|
||||
document.body.appendChild(activeDropdown);
|
||||
}
|
||||
|
||||
activeDropdown.innerHTML = '';
|
||||
if (suggestions.length === 0) {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
suggestions.forEach((user, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'mention-suggestion-item';
|
||||
if (i === selectedIndex) item.classList.add('active');
|
||||
|
||||
const avatarSrc = user.avatar_file ? `/a/${user.avatar_file}` : (user.avatar ? `/t/${user.avatar}.webp` : '/a/default.png');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = avatarSrc;
|
||||
img.onerror = () => { img.src = '/a/default.png'; };
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'mention-name';
|
||||
nameSpan.textContent = user.user;
|
||||
|
||||
item.appendChild(img);
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
if (user.display_name) {
|
||||
const displaySpan = document.createElement('span');
|
||||
displaySpan.className = 'mention-display';
|
||||
displaySpan.textContent = user.display_name;
|
||||
item.appendChild(displaySpan);
|
||||
}
|
||||
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
insertMention(user.user);
|
||||
});
|
||||
|
||||
activeDropdown.appendChild(item);
|
||||
});
|
||||
|
||||
// Ensure the active item is visible in the scrollable container
|
||||
const activeItem = activeDropdown.querySelector('.mention-suggestion-item.active');
|
||||
if (activeItem) {
|
||||
activeItem.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
}
|
||||
|
||||
positionDropdown(currentInput);
|
||||
}
|
||||
|
||||
let skipNextInput = false;
|
||||
|
||||
function insertMention(username) {
|
||||
if (!currentInput) return;
|
||||
const text = currentInput.value;
|
||||
const before = text.substring(0, mentionStart);
|
||||
const after = text.substring(currentInput.selectionStart);
|
||||
const insert = username.includes(' ') ? `[@${username}]` : `@${username}`;
|
||||
currentInput.value = before + insert + after;
|
||||
const newPos = before.length + insert.length;
|
||||
currentInput.setSelectionRange(newPos, newPos);
|
||||
currentInput.focus();
|
||||
destroy();
|
||||
|
||||
// Trigger generic input/change events for other modules (like auto-resize or emoji autocomplete)
|
||||
skipNextInput = true;
|
||||
currentInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
currentInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!activeDropdown) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex + 1) % suggestions.length;
|
||||
renderDropdown();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedIndex = (selectedIndex - 1 + suggestions.length) % suggestions.length;
|
||||
renderDropdown();
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
insertMention(suggestions[selectedIndex].user);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e) {
|
||||
if (skipNextInput) {
|
||||
skipNextInput = false;
|
||||
return;
|
||||
}
|
||||
const input = e.target;
|
||||
const pos = input.selectionStart;
|
||||
const text = input.value.substring(0, pos);
|
||||
|
||||
// Detect @ followed by alphanum, _, -, . (standard username chars)
|
||||
const match = text.match(/@([a-zA-Z0-9_\-\.]{0,})$/);
|
||||
|
||||
if (match) {
|
||||
currentInput = input;
|
||||
mentionStart = pos - match[0].length;
|
||||
query = match[1];
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
suggestions = await fetchUsers(query);
|
||||
if (suggestions.length > 0) {
|
||||
selectedIndex = 0;
|
||||
renderDropdown();
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
destroy();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Attach mention autocomplete to a textarea
|
||||
* @param {HTMLTextAreaElement} textarea
|
||||
*/
|
||||
attach(textarea) {
|
||||
if (!textarea || textarea._mentionsAttached) return;
|
||||
textarea._mentionsAttached = true;
|
||||
textarea.addEventListener('input', handleInput);
|
||||
textarea.addEventListener('keydown', handleKeyDown);
|
||||
textarea.addEventListener('blur', () => {
|
||||
setTimeout(destroy, 200);
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
1705
public/s/js/messages.js
Normal file
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
93
public/s/js/sanitizer.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Simple Whitelist-based HTML Sanitizer
|
||||
* Protects against XSS by stripping disallowed tags and attributes.
|
||||
*/
|
||||
class Sanitizer {
|
||||
static ALLOWED_TAGS = ['span', 'img', 'a', 'br', 'b', 'i', 'strong', 'em', 'blockquote', 'pre', 'code', 'div', 'p', 'hr', 'ul', 'ol', 'li', 'textarea', 'button', 'input', 'label', 'select', 'option', 'svg', 'polyline', 'path', 'line', 'rect', 'circle', 'g', 'defs', 'symbol', 'use', 'polygon', 'ellipse', 'lineargradient', 'radialgradient', 'stop', 'clippath', 'mask', 'iframe', 'video', 'audio'];
|
||||
static ALLOWED_ATTRS = ['class', 'style', 'src', 'href', 'alt', 'title', 'target', 'width', 'height', 'placeholder', 'readonly', 'disabled', 'value', 'name', 'id', 'type', 'data-parent', 'data-id', 'data-username', 'xmlns', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'points', 'x1', 'y1', 'x2', 'y2', 'd', 'transform', 'rx', 'ry', 'x', 'y', 'offset', 'stop-color', 'stop-opacity', 'fill-rule', 'clip-rule', 'cx', 'cy', 'r', 'fill-opacity', 'stroke-opacity', 'preserveaspectratio', 'vector-effect', 'pointer-events', 'allowfullscreen', 'frameborder', 'allow', 'referrerpolicy', 'rel', 'controls', 'loop', 'muted', 'playsinline', 'preload', 'tooltip', 'flow'];
|
||||
static DISALLOWED_URL_SCHEMES = ['javascript:', 'data:', 'vbscript:'];
|
||||
|
||||
/**
|
||||
* Clean an HTML string
|
||||
* @param {string} html
|
||||
* @returns {string} Sanitized HTML string
|
||||
*/
|
||||
static clean(html) {
|
||||
if (!html) return '';
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
this.sanitizeNode(template.content);
|
||||
return template.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iteratively sanitize DOM nodes (prevents stack overflow)
|
||||
* @param {Node} root
|
||||
*/
|
||||
static sanitizeNode(root) {
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
const nodes = Array.from(current.childNodes);
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
if (!this.ALLOWED_TAGS.includes(tagName)) {
|
||||
// If tag is not allowed, replace it with its text content
|
||||
const text = document.createTextNode(node.textContent);
|
||||
node.parentNode.replaceChild(text, node);
|
||||
} else {
|
||||
// Sanitize attributes
|
||||
const attrs = Array.from(node.attributes);
|
||||
for (const attr of attrs) {
|
||||
const attrName = attr.name.toLowerCase();
|
||||
|
||||
// Check if attribute is on whitelist or is a data- attribute
|
||||
if (!this.ALLOWED_ATTRS.includes(attrName) && !attrName.startsWith('data-')) {
|
||||
node.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for URLs
|
||||
if (attrName === 'href' || attrName === 'src') {
|
||||
const val = attr.value.trim().toLowerCase();
|
||||
if (this.DISALLOWED_URL_SCHEMES.some(scheme => val.startsWith(scheme))) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Iframes: only allow YouTube embed URLs
|
||||
if (attrName === 'src' && tagName === 'iframe') {
|
||||
if (!val.startsWith('https://www.youtube.com/embed/')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for style (extremely restrictive)
|
||||
if (attrName === 'style') {
|
||||
// Only allow specific safe CSS properties
|
||||
const safeStyles = ['color', 'background', 'background-color', 'background-image', 'font-weight', 'font-style', 'text-decoration', 'vertical-align', 'height', 'width', 'display', 'fill', 'stroke', 'stroke-width', 'opacity', 'cursor', 'border', 'border-radius', 'padding', 'padding-left', 'padding-right', 'padding-top', 'padding-bottom', 'margin', 'margin-left', 'margin-right', 'margin-top', 'margin-bottom', 'position', 'top', 'left', 'right', 'bottom', 'z-index', 'flex', 'flex-direction', 'justify-content', 'align-items', 'gap'];
|
||||
const styleParts = attr.value.split(';').filter(p => p.trim().length > 0);
|
||||
const cleanStyles = styleParts.filter(part => {
|
||||
const prop = part.split(':')[0].trim().toLowerCase();
|
||||
return safeStyles.includes(prop);
|
||||
});
|
||||
if (cleanStyles.length > 0) {
|
||||
node.setAttribute(attr.name, cleanStyles.join('; '));
|
||||
} else {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push to stack for iterative processing of children
|
||||
stack.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global export
|
||||
window.Sanitizer = Sanitizer;
|
||||
2568
public/s/js/scroller.js
Normal file
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
1322
public/s/js/settings.js
Normal file
File diff suppressed because it is too large
Load Diff
662
public/s/js/sidebar-activity.js
Normal file
662
public/s/js/sidebar-activity.js
Normal file
@@ -0,0 +1,662 @@
|
||||
(function() {
|
||||
let customEmojis = {};
|
||||
let loading = false;
|
||||
let loadingMore = false;
|
||||
let currentPage = 1;
|
||||
let hasMore = true;
|
||||
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
|
||||
// Shared cache for activity across AJAX loads
|
||||
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
|
||||
|
||||
const loadEmojis = async () => {
|
||||
if (Object.keys(customEmojis).length > 0) return;
|
||||
try {
|
||||
const res = await fetch('/api/v2/emojis');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
data.emojis.forEach(e => {
|
||||
customEmojis[e.name] = e.url;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Activity: Failed to load emojis", e);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEmoji = (match, name) => {
|
||||
if (customEmojis[name]) {
|
||||
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
|
||||
}
|
||||
return match;
|
||||
};
|
||||
|
||||
const escapeHtml = (unsafe) => {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const renderCommentContent = (content) => {
|
||||
if (!content) return '';
|
||||
|
||||
// Anti-recursion / Performance safeguard for extremely long comments
|
||||
if (content.length > 50000) {
|
||||
console.warn('Sidebar Activity: Comment too long, skipping markdown');
|
||||
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0; padding: 0; background: none; border: none; font-size: inherit; color: inherit;">${escapeHtml(content)}</pre>`;
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
return escapeHtml(content)
|
||||
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract and protect code blocks (```...```) before escaping
|
||||
const codeBlocks = [];
|
||||
let processed = content.replace(/```([\s\S]*?)```/g, (match) => {
|
||||
const placeholder = `BLOCKPORTALX${codeBlocks.length}X`;
|
||||
codeBlocks.push(marked.parse(match));
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
let escaped = escapeHtml(processed)
|
||||
.replace(/>/g, ">"); // Restore > for markdown markers
|
||||
|
||||
// Handle Image Embeds (Client-side)
|
||||
const siteOrigin = window.location.origin;
|
||||
const escapedSiteUrl = siteOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allowedHosts = [escapedSiteUrl];
|
||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||
window.f0ckAllowedImages.forEach(h => {
|
||||
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
||||
});
|
||||
}
|
||||
const hostsRegexPart = allowedHosts.join('|');
|
||||
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
|
||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||
const imageRegex = new RegExp(`(?<![\\(\\[])((?:https?:\\/\\/)?(?:${hostsRegexPart})(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.blockquote = function (quote) {
|
||||
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||
text = text.replace(/<p>|<\/p>/g, '');
|
||||
return text.split('\n').map(line => {
|
||||
if (!line.trim()) return '';
|
||||
return `<span class="greentext">>${line}</span>`;
|
||||
}).join('\n');
|
||||
};
|
||||
renderer.paragraph = function (text) {
|
||||
return (typeof text === 'string') ? text : (text.text || '');
|
||||
};
|
||||
|
||||
renderer.link = function (href, title, text) {
|
||||
if (typeof href === 'object' && href !== null) {
|
||||
title = href.title; text = href.text || text; href = href.href;
|
||||
}
|
||||
if (!href) return text || '';
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
const isExternal = href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//');
|
||||
let isSameSite = false;
|
||||
|
||||
// Marked greedy autolink fix for spoiler brackets appended to URLs
|
||||
let extraSuffix = '';
|
||||
const lowerHref = href.toLowerCase();
|
||||
if (lowerHref.endsWith('%5b/spoiler%5d')) {
|
||||
href = href.substring(0, href.length - 14);
|
||||
text = text.replace(/\[\/spoiler\]/ig, '');
|
||||
extraSuffix = '[/spoiler]';
|
||||
} else if (lowerHref.endsWith('[/spoiler]')) {
|
||||
href = href.substring(0, href.length - 10);
|
||||
text = text.replace(/\[\/spoiler\]/ig, '');
|
||||
extraSuffix = '[/spoiler]';
|
||||
}
|
||||
|
||||
if (href.startsWith(siteOrigin) || (href.startsWith('/') && !href.startsWith('//'))) {
|
||||
isSameSite = true;
|
||||
} else {
|
||||
try {
|
||||
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
|
||||
const urlObj = new URL(urlToParse, siteOrigin);
|
||||
isSameSite = (urlObj.hostname === window.location.hostname);
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
let displayText = text;
|
||||
if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) {
|
||||
try {
|
||||
const urlToParse = href.startsWith('//') ? window.location.protocol + href : href;
|
||||
const url = new URL(urlToParse.startsWith('http') ? urlToParse : siteOrigin + (urlToParse.startsWith('/') ? '' : '/') + urlToParse);
|
||||
displayText = url.pathname + url.search + url.hash;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const isMention = href.startsWith('/user/') && text.startsWith('@');
|
||||
if (isExternal && !isSameSite) {
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${displayText}<i class="fa-solid fa-arrow-up-right-from-square external-link-icon"></i></a>${extraSuffix}`;
|
||||
}
|
||||
return `<a href="${href}"${titleAttr}${isMention ? ' class="mention"' : ''}>${displayText}</a>${extraSuffix}`;
|
||||
};
|
||||
renderer.image = function (href, title, text) {
|
||||
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||
const alt = text || '';
|
||||
const ttl = title ? ` title="${title}"` : '';
|
||||
return `<img class="sidebar-comment-img" src="${src}" alt="${alt}"${ttl} loading="lazy">`;
|
||||
};
|
||||
|
||||
// Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>')) {
|
||||
// Manual greentext handling — apply emoji if the user preference allows it
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const rendered = quoteEmojis
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit to prevent marked.parse recursion on single giant lines
|
||||
if (line.length > 10000) return line;
|
||||
|
||||
if (!line.trim()) return ' ';
|
||||
|
||||
// Perform replacements on the single line
|
||||
let processedLine = line;
|
||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
|
||||
const user = g1 || g2;
|
||||
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||
});
|
||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||
fullUrl = '//' + url;
|
||||
}
|
||||
return ``;
|
||||
});
|
||||
|
||||
// Use marked for each line individually
|
||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||
|
||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
|
||||
}
|
||||
|
||||
return rendered;
|
||||
});
|
||||
|
||||
let md = renderedLines.join('\n');
|
||||
|
||||
// YouTube label replacement: show icon + labeled link
|
||||
md = md.replace(
|
||||
/<a\s[^>]*href="https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?(?:[^"]*&(?:amp;)?)?v=|youtu\.be\/)([a-zA-Z0-9_\-]{11})[^"]*"[^>]*>([\s\S]*?)<\/a>/gi,
|
||||
(match) => {
|
||||
const hrefMatch = match.match(/href="([^"]+)"/i);
|
||||
const href = hrefMatch ? hrefMatch[1] : '#';
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-brands fa-youtube"></i></a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Build regex for allowed media hosters (video/audio)
|
||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const mediaHosts = [escapedSiteHost];
|
||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||
window.f0ckAllowedImages.forEach(h => {
|
||||
const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
mediaHosts.push(`(?:[a-z0-9-]+\\.)*${escaped}`);
|
||||
});
|
||||
}
|
||||
const mediaHostsPart = mediaHosts.join('|');
|
||||
const mediaDomainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${mediaHostsPart})|(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||
|
||||
// Video label replacement: instead of embedding, show a link
|
||||
const videoEmbedRegex = new RegExp(`<a\\s[^>]*href="(${mediaDomainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]+\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?))"[^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
|
||||
md = md.replace(videoEmbedRegex, (match, url) => {
|
||||
let isSameSite = false;
|
||||
try {
|
||||
const urlToParse = url.startsWith('//') ? window.location.protocol + url : url;
|
||||
const urlObj = new URL(urlToParse, siteOrigin);
|
||||
isSameSite = (urlObj.hostname === window.location.hostname);
|
||||
} catch(e) {
|
||||
isSameSite = url.startsWith(siteOrigin) || (url.startsWith('/') && !url.startsWith('//'));
|
||||
}
|
||||
const label = isSameSite ? 'Video Link' : 'External Video Link';
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="sidebar-video-link"><i class="fa-solid fa-film"></i> ${label} »</a>`;
|
||||
});
|
||||
|
||||
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
||||
let prevMd;
|
||||
let iterations = 0;
|
||||
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
|
||||
do {
|
||||
prevMd = md;
|
||||
md = md.replace(spoilerRegex, (match, content) => {
|
||||
return `<span class="spoiler">${content}</span>`;
|
||||
});
|
||||
iterations++;
|
||||
} while (md !== prevMd && iterations < 10);
|
||||
|
||||
// Handle blur [blur]text[/blur] (supports nesting)
|
||||
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
|
||||
iterations = 0;
|
||||
do {
|
||||
prevMd = md;
|
||||
md = md.replace(blurRegex, (match, content) => {
|
||||
return `<span class="blur-text">${content}</span>`;
|
||||
});
|
||||
iterations++;
|
||||
} while (md !== prevMd && iterations < 10);
|
||||
|
||||
// Restore protected code blocks
|
||||
md = md.replace(/BLOCKPORTALX(\d+)X/g, (match, index) => {
|
||||
return codeBlocks[index] || '';
|
||||
});
|
||||
|
||||
return md;
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
};
|
||||
|
||||
const SIDEBAR_MAX_CHARS = 200;
|
||||
const SIDEBAR_MAX_EMOJIS = 12;
|
||||
|
||||
const renderActivityItem = (c) => {
|
||||
const rawContent = c.content || c.body || '';
|
||||
const displayContent = renderCommentContent(rawContent);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
if (c.avatar_file) {
|
||||
avatarSrc = `/a/${c.avatar_file}`;
|
||||
} else if (c.avatar) {
|
||||
avatarSrc = `/t/${c.avatar}.webp`;
|
||||
}
|
||||
|
||||
const timeStr = c.created_at
|
||||
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
|
||||
: (c.timeago || 'just now');
|
||||
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
|
||||
|
||||
let itemPreview = '';
|
||||
if (c.item_id) {
|
||||
let mediaHtml = '';
|
||||
mediaHtml = `<img src="/t/${c.item_id}.webp" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" onerror="this.style.display='none'" />`;
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<a href="/${c.item_id}">${mediaHtml}</a>
|
||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="comment" id="sc${c.id}">
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
|
||||
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
|
||||
</a>
|
||||
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
|
||||
</div>
|
||||
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
|
||||
</div>
|
||||
<div class="comment-content" style="font-size: 0.85em; line-height: 1.3;"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
|
||||
${itemPreview}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const checkOverflow = () => {
|
||||
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
|
||||
const container = inner.parentElement;
|
||||
const btn = container.querySelector('.read-more-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// If expanded, always show "see less"
|
||||
if (container.classList.contains('expanded')) {
|
||||
btn.style.display = 'block';
|
||||
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
|
||||
return;
|
||||
}
|
||||
|
||||
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
|
||||
btn.style.display = 'block';
|
||||
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
|
||||
container.classList.add('has-overflow');
|
||||
} else {
|
||||
btn.style.display = 'none';
|
||||
container.classList.remove('has-overflow');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Event delegation — read-more expands, see-less collapses
|
||||
document.addEventListener('click', (e) => {
|
||||
// Read more / See less
|
||||
const readBtn = e.target.closest('.read-more-btn');
|
||||
if (readBtn) {
|
||||
const contentDiv = readBtn.closest('.comment-content');
|
||||
if (contentDiv) {
|
||||
contentDiv.classList.toggle('expanded');
|
||||
checkOverflow(); // Re-sync button text and visibility
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const renderFromCache = () => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
if (!container || window._sidebarActivityCache.length === 0) return false;
|
||||
|
||||
let html = '';
|
||||
window._sidebarActivityCache.forEach(c => {
|
||||
html += renderActivityItem(c);
|
||||
});
|
||||
|
||||
if (window.Sanitizer) {
|
||||
container.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
// Re-append IO sentinel so the scroll observer keeps working after re-renders
|
||||
if (ioSentinel) {
|
||||
container.appendChild(ioSentinel);
|
||||
}
|
||||
checkOverflow();
|
||||
return true;
|
||||
};
|
||||
|
||||
const SIDEBAR_PAGE_LIMIT = 50;
|
||||
|
||||
const loadActivity = async (silent = false) => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
if (!container || loading) return;
|
||||
|
||||
const hasCache = renderFromCache();
|
||||
if (!hasCache && !silent) {
|
||||
container.innerHTML = '<div class="loading">Loading activity...</div>';
|
||||
}
|
||||
|
||||
loading = true;
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
try {
|
||||
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||
const res = await fetch(`/activity?json=true&page=1&mode=${mode}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.comments && data.comments.length > 0) {
|
||||
window._sidebarActivityCache = data.comments.map(c => ({
|
||||
...c,
|
||||
body: c.content || c.body
|
||||
}));
|
||||
hasMore = data.hasMore === true;
|
||||
renderFromCache();
|
||||
// Also check after a delay to account for image/emoji loading shifts
|
||||
setTimeout(checkOverflow, 500);
|
||||
} else if (container.innerHTML.includes('loading')) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
|
||||
hasMore = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Activity: Failed to load activity", e);
|
||||
if (container.innerHTML.includes('loading')) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
|
||||
}
|
||||
hasMore = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMoreActivity = async () => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
if (!container || loading || loadingMore || !hasMore) return;
|
||||
|
||||
loadingMore = true;
|
||||
const nextPage = currentPage + 1;
|
||||
|
||||
// Show a subtle loading row at the bottom
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'sidebar-load-more-sentinel';
|
||||
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
|
||||
sentinel.textContent = 'Loading…';
|
||||
container.appendChild(sentinel);
|
||||
|
||||
try {
|
||||
const mode = typeof window.activeMode !== 'undefined' ? window.activeMode : '';
|
||||
const res = await fetch(`/activity?json=true&page=${nextPage}&mode=${mode}`, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// Remove sentinel before inserting real content
|
||||
const s = document.getElementById('sidebar-load-more-sentinel');
|
||||
if (s) s.remove();
|
||||
|
||||
if (data.success && data.comments && data.comments.length > 0) {
|
||||
currentPage = nextPage;
|
||||
hasMore = data.hasMore === true;
|
||||
|
||||
// Append only comments not already in the cache
|
||||
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
|
||||
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
|
||||
...c,
|
||||
body: c.content || c.body
|
||||
}));
|
||||
|
||||
window._sidebarActivityCache.push(...newComments);
|
||||
|
||||
// Append new items to DOM
|
||||
let html = '';
|
||||
newComments.forEach(c => { html += renderActivityItem(c); });
|
||||
if (html) {
|
||||
const temp = document.createElement('div');
|
||||
if (window.Sanitizer) {
|
||||
temp.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
temp.innerHTML = html;
|
||||
}
|
||||
while (temp.firstElementChild) {
|
||||
container.appendChild(temp.firstElementChild);
|
||||
}
|
||||
// Keep the IO sentinel at the very end so it triggers on the next scroll
|
||||
if (ioSentinel) container.appendChild(ioSentinel);
|
||||
checkOverflow();
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
// Show end-of-feed indicator
|
||||
const end = document.createElement('div');
|
||||
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
|
||||
end.textContent = '─ end of activity ─';
|
||||
container.appendChild(end);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Sidebar Activity: Failed to load more", e);
|
||||
const s = document.getElementById('sidebar-load-more-sentinel');
|
||||
if (s) s.remove();
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewActivity = (data) => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
|
||||
// 1. Deduplicate: check if this comment ID is already in the cache
|
||||
if (window._sidebarActivityCache.some(c => parseInt(c.id) === parseInt(data.id))) {
|
||||
console.log("Sidebar Activity: Duplicate comment ignored", data.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
|
||||
const newItem = {
|
||||
...data,
|
||||
body: data.body || data.content,
|
||||
timeago: (window.f0ckI18n && window.f0ckI18n.timeago_just_now) || 'just now'
|
||||
};
|
||||
window._sidebarActivityCache.unshift(newItem);
|
||||
|
||||
// Update DOM if visible
|
||||
if (container) {
|
||||
const html = renderActivityItem(newItem);
|
||||
const temp = document.createElement('div');
|
||||
if (window.Sanitizer) {
|
||||
temp.innerHTML = window.Sanitizer.clean(html);
|
||||
} else {
|
||||
temp.innerHTML = html;
|
||||
}
|
||||
const node = temp.firstElementChild;
|
||||
if (node) {
|
||||
node.classList.add('new-item-fade');
|
||||
container.prepend(node);
|
||||
checkOverflow();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
await loadEmojis();
|
||||
loadActivity();
|
||||
};
|
||||
|
||||
// Listen for live activity from f0ckm.js
|
||||
document.addEventListener('f0ck:activityReceived', (e) => {
|
||||
console.log("Sidebar Activity: Live update received", e.detail);
|
||||
handleNewActivity(e.detail);
|
||||
});
|
||||
|
||||
const handleLiveEdit = (data) => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
|
||||
// 1. Update cache
|
||||
if (window._sidebarActivityCache) {
|
||||
const comment = window._sidebarActivityCache.find(c => String(c.id) === String(data.comment_id));
|
||||
if (comment) {
|
||||
comment.content = data.content;
|
||||
comment.body = data.content;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update DOM if visible
|
||||
if (container) {
|
||||
const el = document.getElementById('sc' + data.comment_id);
|
||||
if (el) {
|
||||
const inner = el.querySelector('.comment-content-inner');
|
||||
if (inner) {
|
||||
inner.innerHTML = renderCommentContent(data.content);
|
||||
el.classList.remove('new-item-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('new-item-fade');
|
||||
checkOverflow();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('f0ck:comment_edited', (e) => {
|
||||
console.log("Sidebar Activity: Live edit received", e.detail);
|
||||
handleLiveEdit(e.detail);
|
||||
});
|
||||
|
||||
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||
|
||||
// Handle AJAX item loads
|
||||
document.addEventListener('f0ck:contentLoaded', () => {
|
||||
const currentMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
|
||||
const modeChanged = lastBoundMode !== null && lastBoundMode !== currentMode;
|
||||
lastBoundMode = currentMode;
|
||||
|
||||
console.log("Sidebar Activity: Page transition detected", modeChanged ? "(Mode changed)" : "");
|
||||
|
||||
if (modeChanged) {
|
||||
window._sidebarActivityCache = [];
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
loadActivity(false); // Force reload with loading state
|
||||
} else {
|
||||
// Immediately render from cache to avoid flicker
|
||||
renderFromCache();
|
||||
// Background sync
|
||||
loadActivity(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync sidebar and comments-list layout on initial page load (Legacy View Only)
|
||||
if (typeof syncSidebarAndComments === 'function') {
|
||||
syncSidebarAndComments();
|
||||
}
|
||||
|
||||
// Handle explicit mode changes (e.g. from item page where full transition doesn't occur)
|
||||
document.addEventListener('f0ck:modeChanged', (e) => {
|
||||
console.log("Sidebar Activity: Mode change detected", e.detail.mode);
|
||||
lastBoundMode = e.detail.mode;
|
||||
window._sidebarActivityCache = [];
|
||||
currentPage = 1;
|
||||
hasMore = true;
|
||||
loadActivity(false);
|
||||
});
|
||||
|
||||
// When the current user posts a comment, silently refresh sidebar to show it
|
||||
document.addEventListener('f0ck:commentPosted', () => {
|
||||
console.log("Sidebar Activity: Own comment posted, refreshing...");
|
||||
loadActivity(true);
|
||||
});
|
||||
|
||||
// Infinite scroll: load older comments when scrolling near the bottom
|
||||
const bindScrollListener = () => {
|
||||
const container = document.getElementById('sidebar-activity-container');
|
||||
if (!container) return;
|
||||
|
||||
// Use IntersectionObserver if available (performant), fallback to scroll event
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
// Create the sentinel once at module level so re-renders can re-append the same node
|
||||
if (!ioSentinel) {
|
||||
ioSentinel = document.createElement('div');
|
||||
ioSentinel.id = 'sidebar-io-sentinel';
|
||||
ioSentinel.style.height = '1px';
|
||||
container.appendChild(ioSentinel);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||
loadMoreActivity();
|
||||
}
|
||||
}, { root: container, rootMargin: '0px 0px 80px 0px', threshold: 0 });
|
||||
|
||||
observer.observe(ioSentinel);
|
||||
} else {
|
||||
container.addEventListener('scroll', () => {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 100;
|
||||
if (nearBottom) loadMoreActivity();
|
||||
}, { passive: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
const _origInit = init;
|
||||
const initWithScroll = async () => {
|
||||
await _origInit();
|
||||
bindScrollListener();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWithScroll);
|
||||
} else {
|
||||
initWithScroll();
|
||||
}
|
||||
|
||||
// Live updates are handled via SSE (f0ck:activityReceived event)
|
||||
})();
|
||||
283
public/s/js/tag_autocomplete.js
Normal file
283
public/s/js/tag_autocomplete.js
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* TagAutocomplete — Custom mobile-friendly tag autocomplete dropdown
|
||||
* Replaces native <datalist> which is unreliable on mobile Chrome.
|
||||
*
|
||||
* Usage:
|
||||
* TagAutocomplete.open({
|
||||
* postid: Number,
|
||||
* existingTags: String[],
|
||||
* anchorEl: Element, // the "add tag" link
|
||||
* onSubmit: async (tag) => { ... return { success, tags } },
|
||||
* renderTags: (tags) => void
|
||||
* });
|
||||
*/
|
||||
window.TagAutocomplete = (() => {
|
||||
let activeInstance = null;
|
||||
|
||||
const DEBOUNCE_MS = 300;
|
||||
const MIN_QUERY_LEN = 1;
|
||||
|
||||
function destroy() {
|
||||
if (!activeInstance) return;
|
||||
const { wrapper } = activeInstance;
|
||||
if (wrapper && wrapper.parentElement) {
|
||||
wrapper.parentElement.removeChild(wrapper);
|
||||
}
|
||||
activeInstance = null;
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
const { postid, existingTags, anchorEl, onSubmit, renderTags } = opts;
|
||||
|
||||
// If already open, just focus the existing input
|
||||
if (activeInstance && activeInstance.wrapper.parentElement) {
|
||||
activeInstance.input.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Build DOM --
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'badge badge-light ml-2 tag-ac-wrapper';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.style.display = 'inline';
|
||||
form.setAttribute('autocomplete', 'off');
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.size = '10';
|
||||
input.value = '';
|
||||
input.setAttribute('autocomplete', 'off');
|
||||
input.setAttribute('autocorrect', 'off');
|
||||
input.setAttribute('autocapitalize', 'off');
|
||||
input.setAttribute('spellcheck', 'false');
|
||||
input.className = 'tag-ac-input';
|
||||
input.placeholder = '';
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'tag-suggestions';
|
||||
dropdown.style.display = 'none';
|
||||
|
||||
form.appendChild(input);
|
||||
wrapper.appendChild(form);
|
||||
wrapper.appendChild(dropdown);
|
||||
|
||||
// Insert after the anchor itself to put it right next to it.
|
||||
anchorEl.insertAdjacentElement('afterend', wrapper);
|
||||
|
||||
input.focus();
|
||||
|
||||
activeInstance = { wrapper, input, dropdown };
|
||||
|
||||
// Flag to prevent focusout from destroying dropdown while touching it
|
||||
let dropdownTouching = false;
|
||||
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
||||
dropdown.addEventListener('touchend', () => {
|
||||
dropdownTouching = false;
|
||||
// Re-focus input so user can keep typing after scrolling
|
||||
input.focus();
|
||||
}, { passive: true });
|
||||
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
||||
|
||||
// -- Debounced suggest --
|
||||
let debounceTimer = null;
|
||||
let lastQuery = '';
|
||||
let highlightIndex = -1;
|
||||
|
||||
const updateHighlight = (items, newIndex) => {
|
||||
// Remove old highlight
|
||||
if (highlightIndex >= 0 && highlightIndex < items.length) {
|
||||
items[highlightIndex].classList.remove('active');
|
||||
}
|
||||
highlightIndex = newIndex;
|
||||
// Apply new highlight
|
||||
if (highlightIndex >= 0 && highlightIndex < items.length) {
|
||||
items[highlightIndex].classList.add('active');
|
||||
items[highlightIndex].scrollIntoView({ block: 'nearest' });
|
||||
// Update input to show highlighted tag
|
||||
input.value = items[highlightIndex].querySelector('.tag-suggestion-name').textContent;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSuggestions = async (q) => {
|
||||
try {
|
||||
const res = await fetch('/api/v2/tags/suggest?q=' + encodeURIComponent(q));
|
||||
const data = await res.json();
|
||||
return data.suggestions || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const renderDropdown = (suggestions) => {
|
||||
dropdown.innerHTML = '';
|
||||
highlightIndex = -1; // reset on new suggestions
|
||||
if (!suggestions.length) {
|
||||
dropdown.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
suggestions.forEach(entry => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tag-suggestion-item';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'tag-suggestion-name';
|
||||
name.textContent = entry.tag;
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'tag-suggestion-meta';
|
||||
const scoreStr = typeof entry.score === 'number' ? entry.score.toFixed(2) : '0.00';
|
||||
meta.textContent = `${entry.tagged || 0}× · ${scoreStr}`;
|
||||
|
||||
row.appendChild(name);
|
||||
row.appendChild(meta);
|
||||
|
||||
// Desktop: mousedown fires before focusout, preventing premature close
|
||||
row.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.value = entry.tag;
|
||||
dropdown.style.display = 'none';
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
// Mobile: distinguish tap from scroll using touch distance
|
||||
let touchStartY = 0;
|
||||
let touchStartX = 0;
|
||||
row.addEventListener('touchstart', (e) => {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}, { passive: true });
|
||||
row.addEventListener('touchend', (e) => {
|
||||
const dx = Math.abs(e.changedTouches[0].clientX - touchStartX);
|
||||
const dy = Math.abs(e.changedTouches[0].clientY - touchStartY);
|
||||
if (dx < 10 && dy < 10) {
|
||||
// Clean tap — select this suggestion
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.value = entry.tag;
|
||||
dropdown.style.display = 'none';
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.appendChild(row);
|
||||
});
|
||||
dropdown.style.display = '';
|
||||
};
|
||||
|
||||
// -- Events --
|
||||
const onInput = () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length < MIN_QUERY_LEN) {
|
||||
dropdown.style.display = 'none';
|
||||
dropdown.innerHTML = '';
|
||||
lastQuery = '';
|
||||
return;
|
||||
}
|
||||
if (q === lastQuery) return;
|
||||
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
debounceTimer = null;
|
||||
lastQuery = q;
|
||||
const suggestions = await fetchSuggestions(q);
|
||||
// Only render if input hasn't changed while we awaited
|
||||
if (input.value.trim() === q) {
|
||||
renderDropdown(suggestions);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
input.addEventListener('input', onInput);
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = dropdown.querySelectorAll('.tag-suggestion-item');
|
||||
if (!items.length || dropdown.style.display === 'none') return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = highlightIndex < items.length - 1 ? highlightIndex + 1 : 0;
|
||||
updateHighlight(items, next);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = highlightIndex > 0 ? highlightIndex - 1 : items.length - 1;
|
||||
updateHighlight(items, prev);
|
||||
} else if (e.key === 'Enter' && highlightIndex >= 0) {
|
||||
// Enter with a highlighted item — submit that tag
|
||||
e.preventDefault();
|
||||
dropdown.style.display = 'none';
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const tag = input.value.trim();
|
||||
if (!tag) return;
|
||||
|
||||
if (/^https?:\/\//i.test(tag)) {
|
||||
window.flashMessage('Post that in the comments', 3000, 'error');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingTags.includes(tag)) {
|
||||
window.flashMessage('Tag already exists', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await onSubmit(tag);
|
||||
if (!res.success) {
|
||||
window.flashMessage(res.msg || 'Error adding tag', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
renderTags(res.tags, tag);
|
||||
destroy();
|
||||
// Re-open for adding more tags
|
||||
open(opts);
|
||||
});
|
||||
|
||||
// Close when clicking/tapping outside
|
||||
const onDocClick = (e) => {
|
||||
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('touchstart', onDocClick);
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
// Delay attaching to avoid capturing the opening click
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('touchstart', onDocClick, { passive: true });
|
||||
}, 0);
|
||||
|
||||
// Click on the wrapper area should refocus the input
|
||||
wrapper.addEventListener('mousedown', (e) => {
|
||||
if (e.target !== input) {
|
||||
e.preventDefault(); // prevent blur
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('focusout', () => {
|
||||
// Delay to allow suggestion tap/scroll to complete first
|
||||
setTimeout(() => {
|
||||
if (dropdownTouching) return; // user is interacting with dropdown
|
||||
// Don't close if focus is still within the wrapper
|
||||
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
||||
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
||||
destroy();
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
return { open, destroy };
|
||||
})();
|
||||
64
public/s/js/theme.js
Normal file
64
public/s/js/theme.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const Cookie = {
|
||||
get: name => {
|
||||
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||
if (c) return decodeURIComponent(c);
|
||||
},
|
||||
set: (name, value, opts = {}) => {
|
||||
if (opts.days) {
|
||||
opts['max-age'] = opts.days * 60 * 60 * 24;
|
||||
delete opts.days;
|
||||
}
|
||||
opts.SameSite = 'Strict';
|
||||
opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, '');
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + opts;
|
||||
}
|
||||
};
|
||||
|
||||
(() => {
|
||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||
|
||||
const setTheme = (theme) => {
|
||||
if (!themes.includes(theme)) theme = defaultTheme;
|
||||
document.documentElement.setAttribute('theme', theme);
|
||||
Cookie.set('theme', theme, { path: '/', days: 360 });
|
||||
};
|
||||
|
||||
const cycleTheme = () => {
|
||||
const currentTheme = document.documentElement.getAttribute('theme') || Cookie.get('theme') || defaultTheme;
|
||||
let i = themes.indexOf(currentTheme);
|
||||
if (i === -1 || ++i >= themes.length) i = 0;
|
||||
setTheme(themes[i]);
|
||||
};
|
||||
|
||||
// Initial load — sync cookie → document attribute
|
||||
const acttheme = Cookie.get('theme') || defaultTheme;
|
||||
if (!themes.includes(acttheme) || acttheme !== document.documentElement.getAttribute('theme')) {
|
||||
setTheme(acttheme);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||
if (e.key === 't') {
|
||||
e.preventDefault();
|
||||
cycleTheme();
|
||||
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
|
||||
// Use scroller toast if available, otherwise site-wide flashMessage
|
||||
if (typeof window._scrollerThemeToast === 'function') {
|
||||
window._scrollerThemeToast(newTheme);
|
||||
} else if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(`Theme: ${newTheme}`, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.id === 'shortcut-theme' || e.target.closest('#shortcut-theme')) {
|
||||
cycleTheme();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose globally so other scripts (e.g. scroller.js) can call cycle/set
|
||||
window.f0ckCycleTheme = cycleTheme;
|
||||
window.f0ckSetTheme = setTheme;
|
||||
})();
|
||||
147
public/s/js/upload-common.js
Normal file
147
public/s/js/upload-common.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* common upload logic for f0ck
|
||||
* shared between /upload page and global drag-and-drop modal
|
||||
*/
|
||||
window.F0ckUpload = class {
|
||||
constructor(options) {
|
||||
this.form = options.form;
|
||||
this.config = options.config || {};
|
||||
this.onProgress = options.onProgress || (() => {});
|
||||
this.onComplete = options.onComplete || (() => {});
|
||||
this.onError = options.onError || (() => {});
|
||||
this.onStatusChange = options.onStatusChange || (() => {});
|
||||
|
||||
this.selectedFile = null;
|
||||
this.tags = [];
|
||||
this.minTags = options.minTags || 3;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.form) return;
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Tag input handling (if exists in this form instance)
|
||||
const tagInput = this.form.querySelector('.tag-input');
|
||||
if (tagInput) {
|
||||
tagInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.addTag(tagInput.value);
|
||||
tagInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return bytes.toFixed(2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
validateFile(file) {
|
||||
if (!file) return false;
|
||||
const allowedMimes = this.config.allowedMimes || [];
|
||||
if (allowedMimes.length > 0 && !allowedMimes.includes(file.type)) {
|
||||
return { error: `File type ${file.type} is not allowed.` };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
addTag(tagName) {
|
||||
tagName = tagName.trim();
|
||||
if (!tagName || this.tags.some(t => t.toLowerCase() === tagName.toLowerCase())) return;
|
||||
if (['sfw', 'nsfw', 'nsfl'].includes(tagName.toLowerCase())) return;
|
||||
|
||||
this.tags.push(tagName);
|
||||
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||
}
|
||||
|
||||
removeTag(tagName) {
|
||||
this.tags = this.tags.filter(t => t !== tagName);
|
||||
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||
}
|
||||
|
||||
clearTags() {
|
||||
this.tags = [];
|
||||
this.onStatusChange({ type: 'tags_updated', tags: this.tags });
|
||||
}
|
||||
|
||||
setFile(file) {
|
||||
const validation = this.validateFile(file);
|
||||
if (validation.error) {
|
||||
this.onError(validation.error);
|
||||
return false;
|
||||
}
|
||||
this.selectedFile = file;
|
||||
this.onStatusChange({ type: 'file_selected', file: file });
|
||||
return true;
|
||||
}
|
||||
|
||||
async upload() {
|
||||
if (!this.selectedFile) {
|
||||
this.onError('No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const rating = this.form.querySelector('input[name="rating"]:checked');
|
||||
if (!rating) {
|
||||
this.onError('Please select a rating');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tags.length < this.minTags) {
|
||||
this.onError(`At least ${this.minTags} tags are required`);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
formData.append('rating', rating.value);
|
||||
formData.append('tags', this.tags.join(','));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
this.onProgress(percent);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
this.onComplete(res);
|
||||
resolve(res);
|
||||
} else {
|
||||
this.onError(res.msg, res);
|
||||
reject(res);
|
||||
}
|
||||
} catch (err) {
|
||||
this.onError('Upload failed. Server returned invalid response.');
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = (err) => {
|
||||
this.onError('Network error occurred during upload.');
|
||||
reject(err);
|
||||
};
|
||||
|
||||
xhr.open('POST', '/api/v2/upload');
|
||||
xhr.setRequestHeader('X-CSRF-Token', window.f0ckSession?.csrf_token || '');
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
};
|
||||
1331
public/s/js/upload.js
Normal file
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
219
public/s/js/user.js
Normal file
@@ -0,0 +1,219 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
return {
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
const queryapi = async (url, data, method = 'GET') => {
|
||||
let req;
|
||||
if (method == 'POST') {
|
||||
req = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
else {
|
||||
let s = [];
|
||||
for (const [key, val] of Object.entries(data))
|
||||
s.push(encodeURIComponent(key) + "=" + encodeURIComponent(val));
|
||||
req = await fetch(url + '?' + s.join('&'));
|
||||
}
|
||||
return await req.json();
|
||||
};
|
||||
|
||||
const get = async (url, data) => queryapi(url, data, 'GET');
|
||||
const post = async (url, data) => queryapi(url, data, 'POST');
|
||||
|
||||
const renderTags = (_tags, highlightTag = null) => {
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
||||
if (!tag.querySelector('#a_addtag') && !tag.querySelector('#a_toggle') && !tag.classList.contains('tag-ac-wrapper')) {
|
||||
tag.parentElement.removeChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge", "mr-2");
|
||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||
span.classList.add('new-tag-glow');
|
||||
}
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
|
||||
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
|
||||
const space = document.createTextNode('\u00A0'); //
|
||||
span.appendChild(space);
|
||||
|
||||
const del = document.createElement("a");
|
||||
del.className = "removetag admin-deltag";
|
||||
del.href = "javascript:void(0)";
|
||||
del.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
span.insertAdjacentElement("beforeend", del);
|
||||
}
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
|
||||
let toggle = tagsContainer.querySelector(".show-tags-toggle");
|
||||
|
||||
if (realTags.length > 10) {
|
||||
if (!toggle) {
|
||||
toggle = document.createElement("a");
|
||||
toggle.href = "#";
|
||||
toggle.className = "show-tags-toggle";
|
||||
tagsContainer.appendChild(toggle);
|
||||
}
|
||||
const hiddenCount = realTags.length - 10;
|
||||
toggle.dataset.count = hiddenCount;
|
||||
|
||||
// Auto-expand when rendering new tags (e.g. after adding one) as requested
|
||||
tagsContainer.classList.add('tags-expanded');
|
||||
toggle.textContent = "show less";
|
||||
|
||||
} else if (toggle) {
|
||||
toggle.remove();
|
||||
tagsContainer.classList.remove('tags-expanded');
|
||||
}
|
||||
};
|
||||
|
||||
window.renderTags = renderTags;
|
||||
|
||||
const addtagClick = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
TagAutocomplete.open({
|
||||
postid,
|
||||
existingTags: tags,
|
||||
anchorEl: anchor,
|
||||
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
const isNsfw = res.tags.some(t => t.id == 2);
|
||||
const isUntagged = res.tags.length === 0;
|
||||
const toggleBtn = document.querySelector('button#a_toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-untagged', isUntagged);
|
||||
const labels = { true: 'NSFW', false: 'SFW' };
|
||||
toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
// e is the click event or undefined
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||
});
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
else {
|
||||
// lul
|
||||
}
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
document.addEventListener("click", e => {
|
||||
if (document.querySelector('script[src*="admin.js"]')) return;
|
||||
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
||||
if (target.closest("a#a_addtag")) {
|
||||
addtagClick(e);
|
||||
} else if (target.closest("#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
})();
|
||||
339
public/s/js/user_comments.js
Normal file
339
public/s/js/user_comments.js
Normal file
@@ -0,0 +1,339 @@
|
||||
class UserCommentSystem {
|
||||
constructor() {
|
||||
this.container = document.getElementById('user-comments-container');
|
||||
this.username = this.container ? this.container.dataset.user : null;
|
||||
this.page = 1;
|
||||
this.loading = false;
|
||||
this.finished = false;
|
||||
this.userColor = null;
|
||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||
|
||||
this.icons = {
|
||||
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`
|
||||
};
|
||||
|
||||
if (this.username) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
// Handle live updates for edited comments
|
||||
this.editListener = (e) => this.handleLiveEdit(e.detail);
|
||||
window.addEventListener('f0ck:comment_edited', this.editListener);
|
||||
}
|
||||
|
||||
handleLiveEdit(data) {
|
||||
if (!this.container) return;
|
||||
const el = document.getElementById('c' + data.comment_id);
|
||||
if (el && this.container.contains(el)) {
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content);
|
||||
el.classList.remove('new-item-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('new-item-fade');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.loadEmojis();
|
||||
this.loadMore();
|
||||
this.loadMore();
|
||||
this.bindEvents();
|
||||
this.startLiveTimestamps();
|
||||
}
|
||||
|
||||
async loadEmojis() {
|
||||
if (UserCommentSystem.emojiCache) {
|
||||
this.customEmojis = UserCommentSystem.emojiCache;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/v2/emojis');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.customEmojis = {};
|
||||
data.emojis.forEach(e => {
|
||||
this.customEmojis[e.name] = e.url;
|
||||
});
|
||||
UserCommentSystem.emojiCache = this.customEmojis;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load emojis", e);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (this.loading || this.finished) return;
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
|
||||
this.loadMore();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for mode changes
|
||||
document.addEventListener('f0ck:modeChange', (e) => {
|
||||
// Check if this instance is still active
|
||||
if (!document.body.contains(this.container)) return;
|
||||
|
||||
console.log('Mode changed, reloading comments...');
|
||||
this.container.innerHTML = '';
|
||||
this.page = 1;
|
||||
this.finished = false;
|
||||
this.loadMore();
|
||||
});
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || this.finished) return;
|
||||
this.loading = true;
|
||||
|
||||
const loader = document.createElement('div');
|
||||
loader.className = 'loader-placeholder';
|
||||
loader.innerText = 'Loading...';
|
||||
loader.style.textAlign = 'center';
|
||||
loader.style.padding = '10px';
|
||||
this.container.appendChild(loader);
|
||||
|
||||
try {
|
||||
const mode = window.activeMode || 'sfw';
|
||||
const res = await fetch('/user/' + encodeURIComponent(this.username) + '/comments?page=' + this.page + '&json=true&mode=' + mode);
|
||||
const json = await res.json();
|
||||
|
||||
loader.remove();
|
||||
|
||||
if (json.success && json.comments.length > 0) {
|
||||
if (json.user && json.user.username_color) {
|
||||
this.userColor = json.user.username_color;
|
||||
}
|
||||
json.comments.forEach(c => {
|
||||
console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
|
||||
const html = this.renderComment(c);
|
||||
this.container.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
this.page++;
|
||||
} else {
|
||||
this.finished = true;
|
||||
if (this.page === 1 && (!json.comments || json.comments.length === 0)) {
|
||||
this.container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No comments found.</div>';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.remove();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
renderCommentContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// Anti-recursion / Performance safeguard for extremely long comments
|
||||
if (content.length > 50000) {
|
||||
console.warn('UserComments: Comment too long, skipping markdown');
|
||||
return `<pre style="white-space: pre-wrap; font-family: inherit; margin: 0; padding: 0; background: none; border: none; font-size: inherit; color: inherit;">${this.escapeHtml(content)}</pre>`;
|
||||
}
|
||||
|
||||
if (typeof marked === 'undefined') {
|
||||
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Initial escaping using native method. Restore > for markdown markers.
|
||||
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||
|
||||
// 2. Mentions
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
||||
|
||||
const siteOrigin = window.location.origin;
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.blockquote = function (quote) {
|
||||
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||
text = text.replace(/<p>|<\/p>/g, '');
|
||||
return text.split('\n').map(line => {
|
||||
if (!line.trim()) return '';
|
||||
return `<span class="greentext">>${line}</span>`;
|
||||
}).join('\n');
|
||||
};
|
||||
renderer.paragraph = function (text) {
|
||||
return (typeof text === 'string') ? text : (text.text || '');
|
||||
};
|
||||
|
||||
renderer.link = function (href, title, text) {
|
||||
if (typeof href === 'object' && href !== null) {
|
||||
title = href.title; text = href.text || text; href = href.href;
|
||||
}
|
||||
if (!href) return text || '';
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
||||
const isSameSite = href.startsWith(siteOrigin);
|
||||
|
||||
let displayText = text;
|
||||
if (isSameSite && (text === href || text === href.replace(/^https?:\/\//, '') || text === href.replace(siteOrigin, ''))) {
|
||||
try {
|
||||
const url = new URL(href.startsWith('http') ? href : siteOrigin + (href.startsWith('/') ? '' : '/') + href);
|
||||
displayText = url.pathname + url.search + url.hash;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (isExternal && !isSameSite) {
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${displayText}</a>`;
|
||||
}
|
||||
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||
};
|
||||
|
||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>')) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
if (line.length > 10000) return line;
|
||||
if (!line.trim()) return ' ';
|
||||
|
||||
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||
|
||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||
rendered = rendered.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||
}
|
||||
|
||||
return rendered;
|
||||
});
|
||||
|
||||
let html = renderedLines.join('\n');
|
||||
|
||||
// Handle spoilers [spoiler]text[/spoiler] (supports nesting)
|
||||
let prevMd;
|
||||
let iterations = 0;
|
||||
const spoilerRegex = /\[spoiler\]((?:(?!\[spoiler\])[\s\S])*?)\[\/spoiler\]/gi;
|
||||
do {
|
||||
prevMd = html;
|
||||
html = html.replace(spoilerRegex, (match, content) => {
|
||||
return `<span class="spoiler">${content}</span>`;
|
||||
});
|
||||
iterations++;
|
||||
} while (html !== prevMd && iterations < 10);
|
||||
|
||||
// Handle blur [blur]text[/blur] (supports nesting)
|
||||
const blurRegex = /\[blur\]((?:(?!\[blur\])[\s\S])*?)\[\/blur\]/gi;
|
||||
iterations = 0;
|
||||
do {
|
||||
prevMd = html;
|
||||
html = html.replace(blurRegex, (match, content) => {
|
||||
return `<span class="blur-text">${content}</span>`;
|
||||
});
|
||||
iterations++;
|
||||
} while (html !== prevMd && iterations < 10);
|
||||
|
||||
return html;
|
||||
} catch (e) {
|
||||
console.error('UserCommentSystem Markdown Render Error:', e);
|
||||
return this.escapeHtml(content);
|
||||
}
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content);
|
||||
|
||||
// Replicating the structure of comments.js but adapting for the list view
|
||||
// We add a header indicating which item this comment belongs to
|
||||
|
||||
return `
|
||||
<div class="comment" id="c${c.id}">
|
||||
<div class="comment-avatar">
|
||||
<a href="/${c.item_id}">
|
||||
<img src="/t/${c.item_id}.webp" alt="">
|
||||
</a>
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
||||
</div>
|
||||
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
||||
</div>
|
||||
<div class="comment-content">${content}</div>
|
||||
<div class="comment-footer">
|
||||
<div class="comment-footer-right">
|
||||
<div class="comment-actions">
|
||||
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
// Update timestamps every 30 seconds
|
||||
setInterval(() => {
|
||||
const timestamps = this.container ? this.container.querySelectorAll('.comment-time.timeago') : [];
|
||||
timestamps.forEach(el => {
|
||||
const dateStr = el.getAttribute('tooltip');
|
||||
if (dateStr) {
|
||||
el.textContent = this.timeAgo(dateStr);
|
||||
}
|
||||
});
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
if (window.f0ckTimeAgo) return window.f0ckTimeAgo(date);
|
||||
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
const intervals = [
|
||||
{ label: 'year', seconds: 31536000 },
|
||||
{ label: 'month', seconds: 2592000 },
|
||||
{ label: 'day', seconds: 86400 },
|
||||
{ label: 'hour', seconds: 3600 },
|
||||
{ label: 'minute', seconds: 60 },
|
||||
{ label: 'second', seconds: 1 }
|
||||
];
|
||||
for (const interval of intervals) {
|
||||
const count = Math.floor(seconds / interval.seconds);
|
||||
if (count >= 1) {
|
||||
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
}
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
escapeHtml(unsafe) {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initializer for AJAX and standard load
|
||||
window.initUserComments = () => {
|
||||
// Prevent multiple instances if already running on this container
|
||||
if (document.getElementById('user-comments-container')) {
|
||||
new UserCommentSystem();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.initUserComments();
|
||||
});
|
||||
548
public/s/js/v0ck.js
Normal file
548
public/s/js/v0ck.js
Normal file
@@ -0,0 +1,548 @@
|
||||
(function() {
|
||||
const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
||||
<div class="v0ck_progress">
|
||||
<div class="v0ck_progress_buffered"></div>
|
||||
<div class="v0ck_progress_filled"></div>
|
||||
<div class="v0ck_seek_marker"></div>
|
||||
</div>
|
||||
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play">
|
||||
<svg style="width: 20px; height: 20px;">
|
||||
<use id="v0ck_svg_play" href="${svg}#play"></use>
|
||||
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v0ck_volume_group">
|
||||
<button class="v0ck_player_button v0ck_volume">
|
||||
<svg style="width: 20px; height: 20px;">
|
||||
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
|
||||
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" />
|
||||
</div>
|
||||
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button>
|
||||
<span style="flex: 30"></span>
|
||||
|
||||
<div class="v0ck_settings_container">
|
||||
<button class="v0ck_player_button v0ck_settings_btn" title="Settings">
|
||||
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v0ck_settings_menu v0ck_hidden">
|
||||
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button>
|
||||
<div class="v0ck_menu_item v0ck_bg_row">
|
||||
<span class="v0ck_switch_label">Background</span>
|
||||
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
|
||||
</div>
|
||||
<div class="v0ck_menu_item v0ck_bg_row">
|
||||
<span class="v0ck_switch_label">Autonext</span>
|
||||
<div id="toggleautoplay" class="v0ck_cool_switch" title="Toggle Autonext"></div>
|
||||
</div>
|
||||
<div class="v0ck_menu_item v0ck_bg_row">
|
||||
<span class="v0ck_switch_label">Danmaku</span>
|
||||
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
|
||||
</div>
|
||||
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen">
|
||||
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
||||
<div class="v0ck_overlay">
|
||||
<svg style="width: 60px; height: 60px;">
|
||||
<use href="${svg}#play"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v0ck_hud v0ck_hidden">
|
||||
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
|
||||
<div class="v0ck_hud_bar_container">
|
||||
<div class="v0ck_hud_bar"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const isMobile = /Mobi/i.test(navigator.userAgent) || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
|
||||
let mouseX = -1, mouseY = -1;
|
||||
const updateHoverStates = () => {
|
||||
// Block interaction if content warning is visible or on mobile
|
||||
const cwModal = document.getElementById('content-warning-modal');
|
||||
if ((cwModal && cwModal.style.display !== 'none') || isMobile) return;
|
||||
|
||||
document.querySelectorAll('.v0ck').forEach(p => {
|
||||
const rect = p.getBoundingClientRect();
|
||||
const isOver = mouseX >= rect.left && mouseX <= rect.right &&
|
||||
mouseY >= rect.top && mouseY <= rect.bottom;
|
||||
p.classList.toggle("v0ck_hover", isOver);
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', e => {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
updateHoverStates();
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener('scroll', updateHoverStates, { passive: true });
|
||||
window.addEventListener('wheel', updateHoverStates, { passive: true });
|
||||
|
||||
class v0ck {
|
||||
constructor(elem) {
|
||||
const tagName = elem.tagName.toLowerCase();
|
||||
if (["video", "audio"].includes(tagName)) {
|
||||
const parent = elem.parentElement;
|
||||
if (parent.querySelector('.v0ck_player_controls')) {
|
||||
console.log("[v0ck] Player controls already exist, skipping injection and init");
|
||||
return elem; // Return the video element as the constructor result
|
||||
} else {
|
||||
parent.classList.add("v0ck", "paused");
|
||||
elem.classList.add("v0ck_video", "viewer");
|
||||
|
||||
// Check if mouse is already inside the element synchronously to avoid transition flicker
|
||||
const rect = parent.getBoundingClientRect();
|
||||
if (mouseX >= rect.left && mouseX <= rect.right &&
|
||||
mouseY >= rect.top && mouseY <= rect.bottom) {
|
||||
parent.classList.add("v0ck_hover", "v0ck_no_transition");
|
||||
// Remove no-transition after a frame
|
||||
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
|
||||
}
|
||||
|
||||
if (!isMobile) {
|
||||
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
|
||||
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
|
||||
}
|
||||
|
||||
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
||||
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
||||
}
|
||||
|
||||
// Use absolute path for reliable asset loading
|
||||
const size = elem.getAttribute('data-size');
|
||||
elem.insertAdjacentHTML("afterend", tpl_player(`/s/img/v0ck.svg`, size));
|
||||
console.log("[v0ck] Player initialized for", tagName);
|
||||
}
|
||||
|
||||
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
|
||||
const player = document.querySelector('.v0ck');
|
||||
player.style.backgroundImage = `url('${elem.getAttribute('poster')}')`;
|
||||
}
|
||||
}
|
||||
else
|
||||
return console.error("nope");
|
||||
return this.init(elem);
|
||||
}
|
||||
|
||||
init(elem) {
|
||||
const player = document.querySelector('.v0ck');
|
||||
const video = elem;
|
||||
video.removeAttribute('controls');
|
||||
video.removeAttribute('autoplay');
|
||||
video.addEventListener('contextmenu', e => {
|
||||
if (isMobile) e.preventDefault(); // Block native download/options menu on mobile only
|
||||
});
|
||||
const progress = player.querySelector('.v0ck_progress');
|
||||
const progressBar = player.querySelector('.v0ck_progress_filled');
|
||||
const bufferBar = player.querySelector('.v0ck_progress_buffered');
|
||||
const seekMarker = player.querySelector('.v0ck_seek_marker');
|
||||
const loader = player.querySelector('.v0ck_loader');
|
||||
const toggle = player.querySelector('.v0ck_toggle');
|
||||
const skipButtons = player.querySelectorAll('.v0ck [data-skip]');
|
||||
const ranges = player.querySelectorAll('.v0ck input[type="range"]');
|
||||
const volumeSlider = player.querySelector('.v0ck input[type="range"][name="volume"]');
|
||||
const fullscreen = player.querySelector('.v0ck_fs_btn');
|
||||
const playtime = player.querySelector('.v0ck_playtime');
|
||||
const overlay = player.querySelector('.v0ck_overlay');
|
||||
const volumeButton = player.querySelector('.v0ck_volume');
|
||||
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
|
||||
|
||||
const defaultVolume = 0.5;
|
||||
let mousedown = false;
|
||||
let _volume;
|
||||
|
||||
function handleVolumeButton(vol) {
|
||||
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
|
||||
switch (true) {
|
||||
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
|
||||
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
|
||||
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
|
||||
}
|
||||
localStorage.setItem("volume", vol);
|
||||
}
|
||||
function togglePlay() {
|
||||
return video[video.paused ? 'play' : 'pause']();
|
||||
}
|
||||
function updatePlayIcon() {
|
||||
toggle.classList.toggle('playing');
|
||||
player.classList.toggle('paused');
|
||||
toggle.setAttribute('title', toggle.classList.contains('playing') ? 'Pause' : 'Play');
|
||||
[...toggle.querySelectorAll('use')].forEach(icon => icon.classList.toggle('v0ck_hidden'));
|
||||
}
|
||||
function toggleMute(e) {
|
||||
if (video.volume === 0)
|
||||
video.volume = volumeSlider.value = _volume === 0 ? defaultVolume : _volume;
|
||||
else {
|
||||
_volume = video.volume;
|
||||
video.volume = volumeSlider.value = 0;
|
||||
}
|
||||
handleVolumeButton(video.volume);
|
||||
}
|
||||
function skip() {
|
||||
video.currentTime += +this.dataset.skip;
|
||||
}
|
||||
function handleRangeUpdate() {
|
||||
video[this.name] = this.value;
|
||||
_volume = video.volume;
|
||||
handleVolumeButton(video.volume);
|
||||
}
|
||||
function formatTime(seconds) {
|
||||
const minutes = (~~(seconds / 60)).toString().padStart(2, "0");
|
||||
seconds = (~~(seconds % 60)).toString().padStart(2, "0");
|
||||
return minutes + ":" + seconds;
|
||||
}
|
||||
function handleProgress() {
|
||||
const percent = (video.currentTime / video.duration) * 100;
|
||||
progressBar.style.flexBasis = percent + '%';
|
||||
playtime.innerText = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
|
||||
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
const duration = video.duration;
|
||||
if (duration > 0) {
|
||||
bufferBar.style.width = (bufferedEnd / duration) * 100 + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
function scrub(e) {
|
||||
let x;
|
||||
if (e.type.startsWith('touch')) {
|
||||
const rect = progress.getBoundingClientRect();
|
||||
x = e.touches[0].clientX - rect.left;
|
||||
if (e.type === 'touchmove' && e.cancelable) e.preventDefault();
|
||||
} else {
|
||||
x = e.offsetX;
|
||||
}
|
||||
|
||||
const width = progress.offsetWidth;
|
||||
// Clamp x between 0 and width
|
||||
x = Math.max(0, Math.min(x, width));
|
||||
|
||||
const scrubTime = (x / width) * video.duration;
|
||||
if (!Number.isFinite(scrubTime)) return;
|
||||
|
||||
video.currentTime = scrubTime;
|
||||
|
||||
// Visual seek marker
|
||||
seekMarker.style.left = `${(x / width) * 100}%`;
|
||||
seekMarker.classList.remove('active');
|
||||
void seekMarker.offsetWidth; // trigger reflow
|
||||
seekMarker.classList.add('active');
|
||||
}
|
||||
function enterFullScreen() {
|
||||
if (document.fullscreenElement) return;
|
||||
const target = document.getElementById('main') || player;
|
||||
if (/(iPad|iPhone|iPod)/gi.test(navigator.platform))
|
||||
video.webkitEnterFullscreen();
|
||||
else
|
||||
target.requestFullscreen();
|
||||
}
|
||||
function toggleFullScreen(e) {
|
||||
if (document.fullscreenElement) // exit fullscreen
|
||||
document.exitFullscreen();
|
||||
else { // request fullscreen
|
||||
enterFullScreen();
|
||||
}
|
||||
}
|
||||
function toggleFullScreenClasses() {
|
||||
const fsElem = document.fullscreenElement;
|
||||
const isThisPlayerFS = fsElem && (fsElem === player || fsElem.contains(player));
|
||||
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
||||
}
|
||||
|
||||
player.addEventListener('click', e => {
|
||||
const path = e.path || (e.composedPath && e.composedPath());
|
||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||
if (!isControls) {
|
||||
if (isMobile && !player.classList.contains('v0ck_hover')) {
|
||||
player.classList.add('v0ck_hover');
|
||||
return;
|
||||
}
|
||||
togglePlay(e);
|
||||
}
|
||||
});
|
||||
toggle.addEventListener('click', togglePlay);
|
||||
overlay.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
player.classList.add('v0ck_hover');
|
||||
togglePlay();
|
||||
});
|
||||
video.addEventListener('play', updatePlayIcon);
|
||||
|
||||
// Robust initial overlay removal
|
||||
const removeInitial = () => player.classList.remove('v0ck_initial');
|
||||
video.addEventListener('play', removeInitial);
|
||||
video.addEventListener('playing', removeInitial);
|
||||
video.addEventListener('timeupdate', () => {
|
||||
if (video.currentTime > 0.1 && !video.paused && !video.ended) removeInitial();
|
||||
});
|
||||
|
||||
video.addEventListener('pause', updatePlayIcon);
|
||||
video.addEventListener('timeupdate', handleProgress);
|
||||
video.addEventListener('progress', handleProgress);
|
||||
video.addEventListener('ended', () => {
|
||||
if (localStorage.getItem('autoplay') === 'true') {
|
||||
const nextBtn = document.getElementById('next');
|
||||
if (nextBtn && nextBtn.href && !nextBtn.href.endsWith('#')) {
|
||||
nextBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Loader events
|
||||
const showLoader = () => loader.classList.remove('v0ck_hidden');
|
||||
const hideLoader = () => loader.classList.add('v0ck_hidden');
|
||||
video.addEventListener('waiting', showLoader);
|
||||
video.addEventListener('stalled', showLoader);
|
||||
video.addEventListener('canplay', hideLoader);
|
||||
video.addEventListener('playing', hideLoader);
|
||||
video.addEventListener('seeked', hideLoader);
|
||||
video.addEventListener('loadeddata', hideLoader);
|
||||
volumeButton.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
toggleMute(e);
|
||||
});
|
||||
|
||||
const hud = player.querySelector('.v0ck_hud');
|
||||
const hudBar = hud.querySelector('.v0ck_hud_bar');
|
||||
const hudIcon = hud.querySelector('.v0ck_hud_icon');
|
||||
let startX, startY, startVol, isRightSide, gestureType;
|
||||
let hudTimer;
|
||||
|
||||
function showHUD(vol) {
|
||||
hud.classList.remove('v0ck_hidden');
|
||||
hudBar.style.width = `${vol * 100}%`;
|
||||
|
||||
// Update HUD icon based on volume
|
||||
let icon = 'volume_full';
|
||||
if (vol === 0) icon = 'volume_mute';
|
||||
else if (vol <= 0.5) icon = 'volume_mid';
|
||||
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
|
||||
|
||||
clearTimeout(hudTimer);
|
||||
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
||||
}
|
||||
|
||||
player.addEventListener('touchstart', e => {
|
||||
if (!isMobile) return;
|
||||
const touch = e.touches[0];
|
||||
const rect = player.getBoundingClientRect();
|
||||
const x = touch.clientX - rect.left;
|
||||
isRightSide = x > rect.width / 2;
|
||||
gestureType = 'none';
|
||||
|
||||
if (isRightSide) {
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
startVol = video.volume;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
player.addEventListener('touchmove', e => {
|
||||
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const dx = Math.abs(touch.clientX - startX);
|
||||
const dy = Math.abs(touch.clientY - startY);
|
||||
|
||||
// Identify gesture type if not yet locked
|
||||
if (gestureType === 'none') {
|
||||
if (dy > dx && dy > 5) {
|
||||
gestureType = 'volume';
|
||||
} else if (dx > dy && dx > 5) {
|
||||
gestureType = 'other'; // Probably seeking or horizontal swipe
|
||||
return;
|
||||
} else {
|
||||
return; // Too small movement to decide
|
||||
}
|
||||
}
|
||||
|
||||
if (gestureType === 'volume') {
|
||||
const deltaY = startY - touch.clientY; // swipe up is positive
|
||||
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
||||
let newVol = startVol + (deltaY / sensitivity);
|
||||
newVol = Math.max(0, Math.min(1, newVol));
|
||||
|
||||
video.volume = newVol; // Set directly for smoothness
|
||||
volumeSlider.value = newVol; // Update visual slider
|
||||
_volume = newVol;
|
||||
handleVolumeButton(newVol);
|
||||
showHUD(newVol);
|
||||
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
skipButtons.forEach(button => button.addEventListener('click', skip));
|
||||
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
||||
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
||||
progress.addEventListener('mousedown', scrub);
|
||||
progress.addEventListener('touchstart', scrub, { passive: false });
|
||||
progress.addEventListener('touchmove', scrub, { passive: false });
|
||||
fullscreen.addEventListener('click', toggleFullScreen);
|
||||
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
||||
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
||||
|
||||
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
||||
handleVolumeButton(video.volume);
|
||||
|
||||
// Attempt autoplay and show overlay if blocked
|
||||
const shouldAutoplay = window.f0ckSession?.disable_autoplay !== true;
|
||||
if (shouldAutoplay) {
|
||||
const playPromise = togglePlay();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
player.classList.add('v0ck_initial');
|
||||
});
|
||||
} else if (video.paused) {
|
||||
player.classList.add('v0ck_initial');
|
||||
}
|
||||
} else {
|
||||
player.classList.add('v0ck_initial');
|
||||
}
|
||||
|
||||
// Settings Menu Logic
|
||||
const settingsBtn = player.querySelector('.v0ck_settings_btn');
|
||||
const settingsMenu = player.querySelector('.v0ck_settings_menu');
|
||||
const toggleBgSwitch = player.querySelector('#togglebg');
|
||||
const downloadBtn = player.querySelector('#v0ck_download');
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const a = document.createElement('a');
|
||||
a.href = video.src;
|
||||
a.download = video.src.split('/').pop();
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize switch state (defaults to ON if not explicitly 'false')
|
||||
if (toggleBgSwitch && localStorage.getItem('background') !== 'false') {
|
||||
toggleBgSwitch.classList.add('active');
|
||||
}
|
||||
|
||||
const toggleAutoplaySwitch = player.querySelector('#toggleautoplay');
|
||||
if (toggleAutoplaySwitch && localStorage.getItem('autoplay') === 'true') {
|
||||
toggleAutoplaySwitch.classList.add('active');
|
||||
video.loop = false;
|
||||
}
|
||||
|
||||
if(settingsBtn && settingsMenu) {
|
||||
settingsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
settingsMenu.classList.toggle('v0ck_hidden');
|
||||
if (settingsMenu.classList.contains('v0ck_hidden')) {
|
||||
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu/panel when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const isFlashYankUI = e.target.closest('#flash-yank-ui');
|
||||
const isInsidePlayer = player.contains(e.target);
|
||||
|
||||
if(!settingsMenu.contains(e.target) && !settingsBtn.contains(e.target) && !isFlashYankUI) {
|
||||
if (!settingsMenu.classList.contains('v0ck_hidden')) {
|
||||
settingsMenu.classList.add('v0ck_hidden');
|
||||
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile && !isInsidePlayer && !isFlashYankUI) {
|
||||
player.classList.remove('v0ck_hover');
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent menu click from pausing video (only if clicked on non-button area)
|
||||
settingsMenu.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('button') && !e.target.closest('.v0ck_bg_row')) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
// Visual toggle for background switch
|
||||
if (toggleBgSwitch) {
|
||||
const bgRow = toggleBgSwitch.closest('.v0ck_bg_row');
|
||||
const handleBgToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.toggleBackground) {
|
||||
window.toggleBackground();
|
||||
} else {
|
||||
toggleBgSwitch.classList.toggle('active');
|
||||
}
|
||||
};
|
||||
if (bgRow) bgRow.addEventListener('click', handleBgToggle);
|
||||
else toggleBgSwitch.addEventListener('click', handleBgToggle);
|
||||
}
|
||||
|
||||
if (toggleAutoplaySwitch) {
|
||||
const autoplayRow = toggleAutoplaySwitch.closest('.v0ck_bg_row');
|
||||
const handleAutoplayToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.toggleAutoplay) {
|
||||
window.toggleAutoplay();
|
||||
} else {
|
||||
toggleAutoplaySwitch.classList.toggle('active');
|
||||
}
|
||||
video.loop = !toggleAutoplaySwitch.classList.contains('active');
|
||||
};
|
||||
if (autoplayRow) autoplayRow.addEventListener('click', handleAutoplayToggle);
|
||||
else toggleAutoplaySwitch.addEventListener('click', handleAutoplayToggle);
|
||||
}
|
||||
|
||||
// Danmaku toggle
|
||||
const toggleDanmakuSwitch = player.querySelector('#toggledanmaku');
|
||||
if (toggleDanmakuSwitch) {
|
||||
// Sync initial state from localStorage vs site-wide config default
|
||||
const configDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
|
||||
? !!window.f0ckSession.enable_danmaku
|
||||
: true;
|
||||
|
||||
const savedPref = localStorage.getItem('danmaku');
|
||||
const isEnabled = (savedPref !== null) ? (savedPref !== 'false') : configDefault;
|
||||
|
||||
if (isEnabled) {
|
||||
toggleDanmakuSwitch.classList.add('active');
|
||||
}
|
||||
const danmakuRow = toggleDanmakuSwitch.closest('.v0ck_bg_row');
|
||||
const handleDanmakuToggle = (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.danmakuInstance) {
|
||||
window.danmakuInstance.toggle();
|
||||
const on = window.danmakuInstance.isEnabled();
|
||||
toggleDanmakuSwitch.classList.toggle('active', on);
|
||||
if (window.flashMessage) window.flashMessage(on ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||
} else {
|
||||
toggleDanmakuSwitch.classList.toggle('active');
|
||||
const newVal = toggleDanmakuSwitch.classList.contains('active');
|
||||
localStorage.setItem('danmaku', newVal ? 'true' : 'false');
|
||||
if (window.flashMessage) window.flashMessage(newVal ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||
}
|
||||
};
|
||||
if (danmakuRow) danmakuRow.addEventListener('click', handleDanmakuToggle);
|
||||
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
||||
}
|
||||
}
|
||||
this.toggleFullScreen = toggleFullScreen;
|
||||
this.enterFullScreen = enterFullScreen;
|
||||
|
||||
return video;
|
||||
}
|
||||
}
|
||||
|
||||
window.v0ck = v0ck;
|
||||
})();
|
||||
526
public/s/js/wordlist.js
Normal file
526
public/s/js/wordlist.js
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* wordlist.js — German recovery phrase wordlist
|
||||
*
|
||||
* 4096 common German words, lowercase, 4–10 characters.
|
||||
* Sourced from: github.com/MarvinJWendt/wordlist-german
|
||||
* Filtered to: lowercase letters only (including umlauts), 4–10 chars, deduplicated.
|
||||
*
|
||||
* Entropy: 12 words × log2(4096) ≈ 144 bits
|
||||
*
|
||||
* Consumed by messages.js as window.DM_WORDLIST
|
||||
*/
|
||||
'use strict';
|
||||
window.DM_WORDLIST = [
|
||||
'aasgeiers','abbeiztet','abbildes','abblasend','abchecktet','abdackelte','abdämmst','abdanktet',
|
||||
'abdeckband','abdruckten','abebben','abendkreis','abfallglas','abfange','abfederst','abfege',
|
||||
'abflachten','abfliegt','abforderte','abgasarmen','abgebaut','abgehebelt','abgeklärt','abgemühtes',
|
||||
'abgenickte','abgesparte','abgetautem','abgewandte','abgleiche','abgösse','abgrinst','abgusses',
|
||||
'abhärtete','abhefte','abheilt','abheiltest','abholten','abhörern','abhörtet','abkapseln',
|
||||
'abladender','abledern','ablegende','ablehnten','abliefern','abmärschen','abmeldende','abmolkt',
|
||||
'abmühendem','abnähmest','abnehmerin','abnicker','abopreises','abplagt','abplatzten','abprallern',
|
||||
'abpufferte','abpumpt','abräumers','abrechnest','abregnet','abrüstete','abrüttele','absackten',
|
||||
'abschiebt','abschösset','absegele','absegelst','abseiftet','absetztet','absiedeln','absitzest',
|
||||
'abspaltete','absparte','abspielten','abspreize','abspreizte','abstammst','abstellten','abstrahlt',
|
||||
'abstruses','abtauschte','abtreibers','abtrudeln','abtsberg','abwerfen','abwrackend','abzahltest',
|
||||
'abzapfende','abzäunte','abziehend','abzugsarm','abzuordnen','abzurufen','abzuwiegen','accras',
|
||||
'acetate','acetats','acetyl','ächtetest','achthabens','achtmeter','ackerndem','adac',
|
||||
'adaptec','additionen','adelberger','adelsclub','aderfigur','adle','adlerfarns','adlerzange',
|
||||
'adlungen','adretterem','adriahäfen','aeroben','aerophonen','afdler','affenbrot','affenhirn',
|
||||
'afterfeder','aglycone','ahnend','ähnliches','ahornbergs','akkuzellen','aktenstück','aktfigur',
|
||||
'aktivist','aktivseins','aktivzins','aktuellste','akzeptor','alarich','alarmtönen','albertina',
|
||||
'algenfeind','algier','alimente','alkaliurie','allicin','alpstein','alsdorf','altaisch',
|
||||
'altangaben','altbadener','altberg','altbezirke','altkunden','altliga','altmaiers','altvordere',
|
||||
'alukreuzen','alzeys','amalfi','ambitiös','amboss','anacapris','anadyr','anaheim',
|
||||
'analficken','analvene','analyse','anaphase','anatom','anbeiße','anbellend','anbrenne',
|
||||
'anbringens','andeute','andinen','andiner','andragogik','androgyn','anekelnden','anfährt',
|
||||
'anfangsgag','anfassbare','anfordert','anfühle','anfüllst','angebote','angelobtes','angelötete',
|
||||
'angepisst','angespült','angestoßen','anginöses','anglikaner','angrinste','ängstlich','angstspiel',
|
||||
'angucktest','anhustete','anises','anissamen','ankarrtet','ankaufende','anker','ankerndem',
|
||||
'anklamer','anklebst','anklingens','anknüpfte','ankokelte','ankurble','anlaufzone','anlauten',
|
||||
'anlegst','anmachtour','anmahnt','anmaßt','anmeierten','anmietbare','annagelte','annähtet',
|
||||
'anne','annette','anonymem','anpochten','anregbarem','anreistest','anrempelte','anrührend',
|
||||
'ansähe','ansäuerten','ansaugend','ansaugtakt','anschaffen','anschient','anschosst','ansetzen',
|
||||
'ansogst','anspielten','anspien','anstrebten','anteils','antigua','antipagane','antitypus',
|
||||
'anwehen','anwidernde','anzahlend','anzapfbox','anzulachen','anzündetet','anzündung','apfellogos',
|
||||
'apfelmuse','apleona','appetizers','aprilluft','aquarelle','aquasphäre','arachnoide','aral',
|
||||
'archipelen','areole','ariern','armenden','armenhof','armmolche','armstuhls','armzeug',
|
||||
'arschklaps','arsenoxide','artistisch','arzneibuch','arztberuf','asebie','aspartam','assessorin',
|
||||
'assoziiert','astheim','asthmoides','aststumpf','asylpakete','atomsymbol','atonalem','aufbausche',
|
||||
'aufbaust','aufblasbar','aufblinke','aufbrüllte','aufdrängt','auffädeln','auffindet','auffing',
|
||||
'aufführe','aufgössen','aufgrund','aufguckt','aufhackst','aufhalse','aufhellte','aufheulte',
|
||||
'aufholende','aufkäme','aufkehrt','aufklärend','aufklaubt','aufkleber','aufladend','auflebende',
|
||||
'aufließ','aufpasste','aufpralls','aufputzend','aufriegeln','aufriss','aufruft','aufrufziel',
|
||||
'aufschalte','aufstecke','aufstieg','aufteilten','aufwachsen','aufwand','aufwärmend','aufwiegt',
|
||||
'aufwirfst','aufzäume','aufzöge','aufzuckst','aufzuckten','aufzugkern','aufzugs','augenloch',
|
||||
'augenwurm','ausbadest','ausbilder','ausbliebst','ausdehnten','ausfasernd','ausgesöhnt','ausgräbers',
|
||||
'auskostest','auskramst','auskugelst','auskühlst','auslangen','ausliege','ausliest','auslöstet',
|
||||
'ausnützest','ausnutzung','auspuste','ausrenkt','ausrufern','ausrufs','aussagtet','ausschält',
|
||||
'ausschöpfe','außenfront','außenraums','äußernder','ausspielte','ausspracht','ausspruchs','ausspülung',
|
||||
'aussteige','austraten','auswärtige','auswiche','auszahlend','autobomben','autodeltas','autofreak',
|
||||
'autogarage','autohandel','autonymer','autoöle','autostrich','aztekisch','babyjahren','babykatze',
|
||||
'babypuder','backofens','badeleben','badenische','badetasche','bahlsens','bahnarten','bahnrad',
|
||||
'bahnringen','baikal','balaton','baldowerte','ballerina','ballerst','ballerten','balzkampf',
|
||||
'balzrevier','banalen','bandartig','bandleiter','bangendem','bangor','bankdatum','bänken',
|
||||
'bankgulden','bankkredit','bankrate','banne','bannenden','barbusigem','bärenauen','bärenmarke',
|
||||
'bargau','bärlauchs','barockoboe','basaleres','basic','basisnoten','baskisches','basssänger',
|
||||
'bastard','bauanfang','baubohrer','bauchgurt','bauchtanz','baud','bauernkaro','bauernkind',
|
||||
'bauernweg','bauetappe','baufuge','baufunde','bauleiters','baumastes','baumborke','baumfarm',
|
||||
'baumharzen','baumhütten','baumkreise','baummaus','baumplätze','bauopfers','bauplanes','bauportale',
|
||||
'baureis','bayerin','bdst','beamtentum','bebartetem','bebauendem','bebrüteter','becherling',
|
||||
'bedächten','bedachtest','bedenkende','beete','befehdete','beflagge','beflaggst','befriedens',
|
||||
'befruchtet','befugten','begabung','behaftetes','behebendes','beheizten','beherzigen','beherzigte',
|
||||
'behobene','behobst','beichtgeld','beiges','beiköchin','beimäßen','beinbergs','beinharter',
|
||||
'beinschlag','beiwache','bejagendes','beklauten','beknien','bekrönte','beladungen','belang',
|
||||
'belastetes','belegzelle','beleibten','belgischer','belgrader','beliebigem','bellende','belüde',
|
||||
'belügendes','bemüßigend','benannte','benettons','benötigter','benutzers','bepuderter','beraubende',
|
||||
'bereichern','bereift','bereinige','berentetem','bergfeste','bergflohes','berggesetz','berghase',
|
||||
'bergheimer','berghügel','bergponys','bergtruppe','bergzug','berlinale','bernardus','bernhards',
|
||||
'bertrams','berufsfeld','beruhendem','berührend','besabbelnd','besaitens','besaßest','beschäme',
|
||||
'beschere','besenkeil','besprengst','bestärkt','bestiegt','bestürzung','bestussten','besuchte',
|
||||
'betern','betonarme','betonlatte','betonrohre','beträte','betrauerte','betreuers','betriebe',
|
||||
'betrübt','bette','bettelei','bettnässen','betüddelnd','betupfende','beugender','bewandtem',
|
||||
'bewegender','beweise','bewohnerin','bezahlten','bezeichne','bezugserde','bezwang','biberhüte',
|
||||
'bierdepots','biermaß','bierreisen','bilderberg','bildfarbe','bildform','bildideen','bildlesers',
|
||||
'bildstil','bimssteine','binaurales','binomen','biofenster','biomineral','biotin','biowäscher',
|
||||
'bissfester','bitbusse','bitmusters','blanchiere','blätterte','blattlänge','blättrige','blatttyp',
|
||||
'blatttyps','blaubrust','blauroter','blauzungen','blechle','bleidraht','bleikugel','bleistifts',
|
||||
'bleiverbot','blickfang','blinktest','blochs','blockbuchs','blöcken','blocktest','blöderem',
|
||||
'blonden','bloßlag','blutbanner','blutebene','blutgift','blutigeres','blutmonde','blutopfers',
|
||||
'blutrest','blutreste','bluttriebe','blutumlauf','blutzeuge','bobachter','bockballs','bockgesang',
|
||||
'bodengut','bogenkurve','böhnchens','bohnenfeld','bohrender','bohrkäfern','bollwerken','bombay',
|
||||
'bomberbaus','bookshops','boomlandes','bootendem','böotisches','bootrennen','bootsteil','borde',
|
||||
'bordell','bordhund','börsenbai','borsten','bossele','botenstab','bowle','boxkampf',
|
||||
'bpatg','brandopfer','brandstatt','brandweges','brasilias','braunton','brautamt','brautauto',
|
||||
'braver','breeches','breiigem','bremsender','bremskabel','brentano','breyell','briefoper',
|
||||
'brigant','britney','britpop','bromdampf','bronx','brotgaben','brottyp','brownings',
|
||||
'bruchacker','bruchhagen','brückenweg','brühkolben','brûlée','brummenden','brummt','brünetter',
|
||||
'brunnenkur','brüsselern','brütendes','brutheißes','brutstoffs','buchende','buchkästen','buchkörper',
|
||||
'buchmarke','buchtexte','büffele','büffelfell','büffelnden','bügelstube','bühnenbau','bumsenden',
|
||||
'bündelchen','bundesbüro','bündisch','bunds','buntbrust','burberry','bürgersitz','bürgerstil',
|
||||
'burggutes','burglehm','burgplatz','burgrechte','burgtürmen','burisches','buschbank','buschheide',
|
||||
'busreise','bußgang','bußkleid','bußkreuzen','büstenhebe','cafeterien','camelots','carbonaten',
|
||||
'cartridges','causa','cellosaite','charta','cheatest','chemisches','chemtura','cherbourg',
|
||||
'chinolin','chorbaues','chorbube','christians','cidre','classico','cleveren','clipboards',
|
||||
'clownerei','clubmaster','clubnadel','clubnamen','coate','codierten','codiertes','containern',
|
||||
'covers','coversongs','crèmes','cremten','dableiben','dachboxen','dachhelme','dachmodell',
|
||||
'daherläuft','dahingibst','dahinstarb','damenwege','dammbruchs','dammendorf','dampfnebel','dancing',
|
||||
'dankbrief','danksagen','danktage','dankwarten','danutas','darmatmung','darmsaite','darmstein',
|
||||
'darreichen','dasssätzen','datenhelm','datenkrieg','datenlänge','datenwert','datierbar','datum',
|
||||
'dauerbruch','dauereier','dauereis','dauerton','daumenkino','davontrüge','dazutätest','deckbergen',
|
||||
'deckende','deckenteil','defäkation','defektiver','definierst','deklarant','dekokissen','delawares',
|
||||
'delhi','dengelnd','denkbar','denkbombe','denktest','denunziert','deosprays','dependenz',
|
||||
'derbyfelds','dergestalt','derivateme','detailfoto','detonieren','dettingen','deuteten','deutlichen',
|
||||
'deutz','dezimales','diakonaten','diameter','diaphanes','dichtend','dickwurzel','dienstmann',
|
||||
'dieselöl','diesels','dieseltank','diethelm','diktion','dildo','dinkelmehl','direktcode',
|
||||
'dirham','disparate','dispatcher','diwans','dock','doktere','dokument','dolchen',
|
||||
'domfelsens','domtreppe','donaucitys','donezk','doppeldeck','doppeltüre','dorfbrief','dorffehden',
|
||||
'dorfführer','dorfkatze','dorfmeilen','dorfvikare','dorian','dornenwall','dörrens','dortbleibt',
|
||||
'dotzheims','drahtanker','drahtenden','drahtes','drahtmaß','drangehe','drankommt','dranlässt',
|
||||
'draufstand','dreckbude','drehbilder','drehgriff','drehhaken','drehimpuls','drehlagen','drehzahns',
|
||||
'dresdner','dropbox','druckbarem','druckgase','druckgefäß','drucköl','drusischer','dümpeln',
|
||||
'dung','düngungen','durchsahen','dürrem','durstige','duschtuch','düsterheit','ebenen',
|
||||
'ebook','ecktisch','ecktoren','edelgas','edeljoker','edelkatzen','edelstes','effektarm',
|
||||
'ehejahrs','ehepaars','ehescheuer','ehrenbild','ehrenburgs','ehrenkleid','ehrensolde','eichbäume',
|
||||
'eichdorf','eichelberg','eierkarton','eigendruck','eigenklang','eigenraum','eigenwelt','eignend',
|
||||
'einbohrte','einbremst','einbringst','eincremens','eineiigen','einendes','einfeilten','einflogt',
|
||||
'eingefasst','eingelesen','eingewöhne','einhaktet','einhandeln','einholte','einhufers','einjagen',
|
||||
'einknüpfte','einlagige','einloggte','einmachten','einmottete','einmündete','einnähme','einnähmest',
|
||||
'einnähst','einnehme','einredeten','einreibens','einschlägt','einschulte','einsehens','einsingens',
|
||||
'einsitzig','einstufe','einstufige','einstürmst','eintauche','eintrag','einwillige','einzähnig',
|
||||
'einzelnote','einzögest','einzunähen','eisblock','eisdolche','eisenkufen','eisfischen','eisgürtel',
|
||||
'eishäusern','eiskaffees','eisklima','eisklotz','eiskrieg','eislinien','eispapier','eisriegel',
|
||||
'eisweihern','ekelns','ekmnesien','elbkähne','elbseite','elflandes','elfstündig','ellerlinge',
|
||||
'elternbund','eluiert','emdens','empfandest','endblüten','endendem','endes','endformen',
|
||||
'endkosten','endlaut','endung','engelauts','englein','englewood','ensemble','entbietet',
|
||||
'enteisest','entenküken','entenstall','enterisch','entfernen','entflogene','entführst','entlarvtet',
|
||||
'entrausche','entspanne','entsprach','entstört','entwischte','epigonalen','epiphanias','epochaler',
|
||||
'epoxide','erbauender','erbt','erbtanten','erdbaues','erdendem','erdferkeln','erdgase',
|
||||
'erdichte','erdjahrs','erdknollen','erdkunde','erdmonate','erdsteinen','erdstelle','erdsternen',
|
||||
'erduldetet','erdwärts','erdzunge','ereifert','ererbtet','erfuhr','ergauner','ergötzten',
|
||||
'ergründens','ergründung','erhabenes','erhandelst','erhebbaren','erhöbet','erhöhens','erhörst',
|
||||
'erisäpfeln','erkrather','erlittene','erlöschen','ermattung','ermogelnd','ermogelte','ermüdbarer',
|
||||
'ermüdet','erneuern','erneuerter','ernsteste','erntetest','erntewege','eroberern','eroberns',
|
||||
'erörtertem','erosiven','erschöpfen','erspähtet','ersten','erstflug','erstformen','erstkosten',
|
||||
'erstzucht','ersuchend','erteilter','erwägt','erweichter','erwirkens','erzeugerin','erzeugnis',
|
||||
'erzgefäßes','erzhalunke','erzlager','erzogenes','erzstatue','erzstock','erzsünden','eselgrauer',
|
||||
'eskalieren','espresso','essigkraut','essnische','esssessel','estg','ethikbüros','ethnie',
|
||||
'etiennes','etruskers','eulenbäume','eulenhof','euren','euronotruf','europaring','eurovideos',
|
||||
'evertebrat','exakteren','exakterer','exakteste','exiljahre','exilrusse','exocytose','exportland',
|
||||
'exsoldat','externem','extertaler','extremem','fabio','fachgebiet','fachhelfer','fachkenner',
|
||||
'fachmänner','fachreise','fachteilen','fachtitel','fackelnd','fadenalgen','fahl','fahlrotem',
|
||||
'fahr','fahrbaren','fahrdammes','fährdorfes','fährführer','fahrigem','fährkahn','fährnissen',
|
||||
'fahrorte','fahrsystem','fahrtiefe','fahrtitel','fährtypen','fahrwegs','fallarten','fallbäume',
|
||||
'fallblöcke','fallentyp','fallkapsel','falllaub','fallserien','falltritte','fälschers','faltblatt',
|
||||
'falträdern','famagustas','fändest','fanganoden','fangdamm','fangt','farbeigen','farberden',
|
||||
'farbkanäle','farbloser','farbräumen','farbreiner','farbsorte','farbsymbol','färbtest','farbtöne',
|
||||
'farmzäune','fasernde','fasernetze','faserstamm','fassende','fastenplan','fatburnern','fauchenden',
|
||||
'faulige','fauliger','faunischem','faustfilms','fechtens','fechtsport','federbäume','federhaube',
|
||||
'federhebel','federpose','fegten','fehlerhaft','feigheit','feinsieben','feldform','feldhof',
|
||||
'feldkerze','felsfarbe','felshüpfer','fenneks','fernbahn','ferngases','fernhält','fernkabels',
|
||||
'fernmessen','fernraum','fesche','fesselnder','festbühnen','festfahrt','festinhalt','festkonto',
|
||||
'festlegung','festmachte','festpunkt','festsauge','festszenen','festtinten','festtreppe','festwagens',
|
||||
'festziehe','festziehen','fettbergen','fettschutz','fettwerten','feuchtkalt','feudale','feudalen',
|
||||
'feudel','feuerelfe','feuergase','feuerkreis','feuerrabe','feuerst','ficklanze','fickten',
|
||||
'fiepsender','fiesem','figürlich','filiation','filmbarem','filmblatts','filmduo','filmfan',
|
||||
'filterwerk','filziges','filzstift','finalserie','finanzhof','finanzwirt','findig','finesse',
|
||||
'fingernde','fingeryoga','fingiert','finitismus','finnair','finnische','finnischer','fischerort',
|
||||
'fischeulen','fischfangs','fischtrane','fixgehalt','fixiersalz','flachboot','fladen','flammentod',
|
||||
'flanierens','flansches','flaschners','flaut','fläzend','flexoskope','fließtiefe','flinkeren',
|
||||
'flippe','flitzt','flockst','flockt','floppy','florierens','floskel','flottes',
|
||||
'flüchteten','fluchtfall','flugfeld','flugkarte','flugmotors','flummis','flurtüren','flüssigere',
|
||||
'flussszene','flutgraben','flutzeiten','fohlenhöfe','folgesiegs','folgsamere','foltermord','fordernd',
|
||||
'fordernde','fordismus','formfinder','formkunst','formmassen','formnestes','fortfegtet','fortgingst',
|
||||
'fortglitt','fortsetzt','fortstieg','fortströme','fotoblogs','fotomotive','frachten','fräcke',
|
||||
'fragerecht','fraunhofer','freikamst','freiließen','freinehme','freising','freistaate','fremdes',
|
||||
'fremdstart','freudscher','frickeln','fridolins','friedsames','frostnacht','frottiert','frotzele',
|
||||
'fruchten','frühkohl','fuffziger','fühlend','füllsteine','fünfendern','fünfjahrs','fünfphasig',
|
||||
'fünftel','funkteilen','funkzünder','furchen','fürchtetet','furios','fußbades','fusselnde',
|
||||
'fußgelenke','fußhebel','füßling','fußrad','fußwegnetz','futtersack','futures','gabriela',
|
||||
'gabriele','gähnende','gaisberg','gallseife','gamsbartes','gangrades','gangschar','gangtiefen',
|
||||
'gangtür','gangway','ganzstelle','garden','gärmitteln','garnele','gartenvase','gasalarme',
|
||||
'gasgewehr','gasimpuls','gaslagers','gästefarm','gästeliste','gästeraums','gästesuite','gastlehrer',
|
||||
'gastrogen','gastweise','gasuhren','gauligen','gaulle','gauredner','gazetten','gealtertem',
|
||||
'geäugt','gebärender','gebetsort','gebettetes','gebogener','gebrätelte','gebröselt','gecken',
|
||||
'gedrängeln','gedrängten','gedrehtes','gedunsener','gefallener','geflicktes','geflüster','gefoulter',
|
||||
'gefoultes','gegebenem','gegenfrage','gegenstück','gegenwurf','geglaubte','gegossenen','gegürteter',
|
||||
'gehöhnt','gehorchtet','gehülltes','geigensoli','geißle','gejapst','gekappter','gekapselte',
|
||||
'gekarrt','geklemmtem','geklonte','gekloppte','geknicktem','geköderte','gekröpften','gekröses',
|
||||
'gekuppelt','gekürtes','gelabtem','gelangt','gelbäugige','geldvorrat','gelenkt','gelernter',
|
||||
'gelierten','gellten','gelobendes','gemächer','gemasert','gemäuern','gemeinst','gemeißelte',
|
||||
'gemessene','gemimtem','gemütvoll','genagelt','genecktes','genevers','genialstem','genotypus',
|
||||
'gent','gentest','genugtut','genus','geopferten','geordnet','gepennt','gepfiffen',
|
||||
'gepiercte','gepimpte','gepolter','gepralltes','gerät','geräumigem','gerbte','gereckten',
|
||||
'geringst','gerissen','gerobbten','gerold','gerumpelt','gerundivum','gerüttel','gesabbel',
|
||||
'gesalzenen','gesamtbau','gesang','geschellt','geschlüpft','geschwärmt','gesinnte','gespultem',
|
||||
'gestattung','gestirnter','gestülptem','gestürzter','gestyltes','gesunder','getempert','gethsemane',
|
||||
'getreues','getrieft','gewächses','gewälzte','geweiht','gewindetyp','gewinnler','gewitzel',
|
||||
'gewitztem','gewitzten','gezähnte','gezäumtem','gezwicktem','gianna','gierst','gießhauses',
|
||||
'giftdorne','giftfreier','giftzwerge','gigatonne','gilbertos','gildefeste','gipfelpaar','gipsbeins',
|
||||
'gipshöhle','gipsloch','gipslöcher','gipswerks','gladbach','glanzstar','glashelles','glasiertem',
|
||||
'glasputz','glasraums','glassärge','glasurriss','glasvasen','glatteises','gleichlauf','gleichtut',
|
||||
'gleitsohle','glitschig','glitzernde','globale','glotzend','gluckernd','glühphasen','glukagon',
|
||||
'glutwolke','glyphen','goldadler','goldauges','goldballes','goldbären','golddistel','goldfieber',
|
||||
'goldrotem','goldsäcke','goldschiff','goldschuhe','goldspuren','goldstücke','goldturmes','goldvögel',
|
||||
'golfkröte','golfseiten','gösse','göttertee','gottesrede','göttin','götzenbild','grabkulten',
|
||||
'grabschten','gräbst','grabtempel','grabtuch','graden','grafenpaar','grafikfeld','grasplatz',
|
||||
'grasratte','graugrüne','gravidem','greifern','grenzball','grenzenlos','grenzmaße','grenzort',
|
||||
'grenzwölfe','grenzzuges','greven','griffteile','griffwulst','griffzone','griffzonen','grillsoße',
|
||||
'großberg','größenberg','großenhain','großgepäck','großplatte','großpudel','großpudels','großruck',
|
||||
'großtaxi','großträger','grottigen','grugahalle','grünäugig','grundkäfer','grundmann','gründorf',
|
||||
'grundtypen','grünzeugs','grüßaugust','guanoinsel','guaven','gucke','guckloches','gucktet',
|
||||
'güldene','gummiband','gummibooms','gummidruck','gummifeder','gummihaube','gummilöwen','gummisohle',
|
||||
'gurgelns','gurkendem','gurtnägeln','gurtrohr','gussmörtel','gussöfen','gütegrad','guthat',
|
||||
'gutmütiger','haare','haarendem','haarigsten','haarlem','haarpore','haarrest','habsburger',
|
||||
'hackevolle','hafenfigur','hafenmusik','hafentages','haftballen','hafthauses','haftlinsen','haftsumme',
|
||||
'hagebuchen','hagelnden','hageltürme','hagerste','hakelig','hakeln','häkelnd','hakennasen',
|
||||
'halbfestem','halbformat','halbnonnen','halbruinen','halbstarre','half','hallenstil','halsgegend',
|
||||
'halsketten','halsrippe','halsschuss','hammerkopf','hamptons','handgarn','handgeste','handgröße',
|
||||
'handhabend','handlaufs','handlötung','handwinden','hanfgarn','hänflings','hanfmuseum','hängebusig',
|
||||
'hängefußes','hängens','hangseite','hanseatin','hänselte','härteofen','hartplatz','hartstöcke',
|
||||
'harzrand','haselblüte','hasenbahn','hatewellen','hätscheln','hätte','häufte','hauptburg',
|
||||
'hauptforum','hauptgrund','hauptkoch','hauptkonto','hauptliste','hauptmaß','hauptmast','hauptquark',
|
||||
'hauptrohrs','hauptspaß','hausbacken','hauscrew','haushoch','hauskater','häusle','hausregeln',
|
||||
'hausrockes','haustaube','hauswaffe','hautbanken','hautdelle','hautflügel','hautgiften','hautzipfel',
|
||||
'headliners','hebelst','hebende','hebt','heftweise','hegelschen','hehr','heidengeld',
|
||||
'heidis','heikes','heilbare','heilbarem','heilpulver','heiltest','heimatkurs','heimspiel',
|
||||
'heimweber','heimzuges','heiserem','heiterere','heizbandes','heizleiter','heizwagen','hektisches',
|
||||
'hellhörige','hellweißer','helmzier','hepburns','herabkam','herausließ','herausluge','heraussah',
|
||||
'herbringst','herbstwahl','herdrehtet','herforder','herhatte','hermitage','herrischem','herschenkt',
|
||||
'herumband','herumlagen','hervorkam','herwagten','herzass','herzblutes','herzigel','herzkrebs',
|
||||
'herzmasse','herznahem','herzogsgut','herzrisse','herzschlag','herzwärme','herzwürmer','heulten',
|
||||
'hexenrecht','hexwerte','hilfetexte','hilfslohn','hilfspilot','hilfsweg','himmelmann','hinabmusst',
|
||||
'hinabwürfe','hinderte','hindustans','hineintrug','hingangs','hinguckst','hinhängst','hinken',
|
||||
'hinkend','hinkens','hinnahmen','hinsehend','hinspucken','hinstreute','hintragt','hinzerrt',
|
||||
'hinziehst','hinzukämet','hinzuzöget','hirnbeine','hirnleiste','hirnloses','hirschrufe','hitzegrad',
|
||||
'hitzephase','hochbetagt','hochbett','hochladys','hochluden','hochphasen','hochpushst','hochsteht',
|
||||
'höfer','hofflächen','hofftet','hofgartens','hofierst','hofleute','hofwehre','hofwirtes',
|
||||
'höhe','hohlbirne','hohlgriff','hohlnadeln','hohlteil','hohnlachen','holarktis','holistisch',
|
||||
'höllenlärm','höllenriff','holmtiefen','holsteins','holsten','holzbremse','holzeimers','holzfeind',
|
||||
'holzhausen','holzkamm','holzkarren','holzkugeln','holzland','holzpforte','holzspitze','holzstube',
|
||||
'holzstuhls','homologem','homologen','honigsenf','honorable','hopsen','hörerkreis','hornmoos',
|
||||
'hornzelle','hörspulen','hortfundes','hubrädern','hubventile','hubwerk','hufeisen','hüftansatz',
|
||||
'hüftköpfen','hügelreihe','hügelzonen','hühnergott','humorale','hundefarbe','hundeopas','hundepest',
|
||||
'hundezucht','hungertest','hüpfspiels','hurritern','hütehunden','hutes','hyänen','hygiene',
|
||||
'hymnologie','hyoscyamin','iberischem','ibuprofen','ideenreich','idiolatrie','ikonograph','illiquide',
|
||||
'illoyale','immanenter','immotil','impfende','impfserums','impftet','importhaus','imposante',
|
||||
'impotenz','indexes','indio','indirekter','infinitum','injizierst','inkurablen','inliners',
|
||||
'innendekor','innenmaß','innennaht','innenrinde','inseldorf','inselstand','intershops','intimerem',
|
||||
'introns','invarianz','inzests','ironikerin','islam','islamabads','isländern','isochron',
|
||||
'iterierend','jacobys','jagdbeirat','jagdboot','jagdcamp','jagddolch','jagdhütten','jagdklub',
|
||||
'jagdwildes','jago','jahresbuch','jahresgage','jährt','jainismus','jamaikaner','japsendes',
|
||||
'jäten','jaunde','javanerin','jecken','jessy','jobcenters','jobfolge','jobmotors',
|
||||
'jodlerin','jubelfest','jubelmesse','judaistik','judengasse','judengeist','jugendlich','jungadler',
|
||||
'jungamseln','jüngere','jungrobben','jungwanzen','juryfreie','justament','juvenilen','kabanossi',
|
||||
'kabeljau','kaffeebar','kaiserpilz','kalberst','kalbshaxen','kalilauge','kalkböden','kalkfreie',
|
||||
'kaltbad','kältehochs','kaltluft','kampfblatt','kampfluken','kampfname','kampfwert','känguruart',
|
||||
'kanntest','kanonikers','kant','kantische','kapokbaum','karavellen','karbon','karbonaten',
|
||||
'karlstores','karotiden','karsten','kärtchen','käsegebäck','käseleinen','kashmir','käsiger',
|
||||
'kasteiten','kastenbrot','kastor','kasuist','katzenkind','kaufteile','kehlenfick','kehlkopf',
|
||||
'kehrreims','keiftest','keilartig','keilstöße','keimfaden','keimhaften','kellergang','kellerwand',
|
||||
'keltisches','kennedys','kerbebäume','kerbend','kerkrade','kernhauses','kernlamina','kernmodul',
|
||||
'kesselholz','kesseln','kesselst','kick','kickoffs','kidnappt','kieferlose','kiefernart',
|
||||
'kieselalge','kiesgrube','killerwals','kinderbild','kinderehe','kindergang','kinderherz','kinderjury',
|
||||
'kinokultur','kinolänge','kinomuseum','kioske','kioto','kippeliger','kippeliges','kipplauf',
|
||||
'kiras','kirremacht','kirschauge','kirschberg','kistengrab','kitze','kitzelns','klammerfuß',
|
||||
'klarerer','klarstelle','kleinbuchs','kleingerät','kleinhäfen','kleinmutes','kleinpferd','kleistern',
|
||||
'klemmende','klemmhofes','klimastufe','klirrenden','kloake','klöntet','klopper','klubeigene',
|
||||
'klubhütte','klumpfüßen','klüngel','knabenhemd','knaggen','knappere','kniefalls','kniesitz',
|
||||
'kniestroms','knittrigen','knotest','knüpfend','kochäpfeln','kochapfels','kochkünste','kochrezept',
|
||||
'kochseiten','kodexes','kodierten','kohlköpfe','kokswerke','kollergang','kolonialem','kommendes',
|
||||
'kommode','kompagnie','konformem','kongolesen','konjunktur','konnotiere','konsumreiz','konterndes',
|
||||
'kontert','kopffarben','kopfkohl','kopfstück','kopfteile','koppelmann','koppelzeug','korbball',
|
||||
'kordon','kornspitze','körperfett','körperlos','kostenberg','kostenflut','kovariiert','kraftfeld',
|
||||
'kräftigtet','kraftmann','kraftpaket','kraftwort','kralle','kratzigste','krebsherd','kreidens',
|
||||
'kreisamtes','kreiselte','kreistest','kreiszahl','kreuzblume','kreuzmark','kriegsplan','kriegszugs',
|
||||
'krokodils','kröntest','kröpfung','krümchen','krümeligem','kuhheide','kuhknochen','kuhköpfen',
|
||||
'kullerte','kunstdieb','kunstfelle','kunstfront','kunstglied','kunsttrieb','kunstuhr','kupfer',
|
||||
'kupferige','kuppelgrab','kuppelsaal','kürendem','kurgartens','kurlauben','kurssystem','kurst',
|
||||
'kurszüge','kurvenrate','kurzführer','kurzgras','kurzsäbeln','kussechte','kutanen','laborkabel',
|
||||
'lachfaktor','lachfalken','lachmund','lachsrote','lachszucht','lachtest','lackrote','ladbaren',
|
||||
'ladebodens','laderampen','laderost','ladetanks','lagerbar','lähmendes','laminierte','landberg',
|
||||
'landedecke','landegerät','landekufe','ländereien','landesdome','landetests','landheeren','landmark',
|
||||
'landmauer','landmeere','landstand','landvogtes','landzoll','langdorf','langheck','langhornes',
|
||||
'längsseite','längstal','lanker','läppisch','lärmender','lassos','lasttier','laubmulch',
|
||||
'laudator','laufdauer','lauffläche','laufschuh','laufstegen','lauftreppe','lausigem','lausten',
|
||||
'läutens','lautsystem','lavabodens','leanders','lebertrias','lebloser','leckeren','lederbogen',
|
||||
'lederfarbe','ledergasse','lederhelms','lederseil','lederslips','leerläufe','leerzeile','leewärts',
|
||||
'legendem','legenden','lehmglasur','lehmgruben','lehnseids','lehnsstaat','lehrhafte','lehrherr',
|
||||
'lehrherrn','leidtun','leihamtes','leihbasis','leihrad','leihstimme','leim','leithäuser',
|
||||
'lenkgabeln','lennestadt','leprösen','lernstils','lernwegen','lesebuches','lesefundes','leselupe',
|
||||
'lesestäben','leseweise','lesezusatz','leugnenden','leugnens','lichtmaß','liebster','liedchen',
|
||||
'liefe','liegegeld','liegendem','liegeräder','lifestyles','lily','liniertem','linkenden',
|
||||
'linkerei','linné','linuxtag','lipizzaner','lippenpaar','litauens','literatur','litermaß',
|
||||
'lizenzzeug','lobbyismus','lobotomie','lochmasken','lodertest','löffelbund','lohnanteil','lohndiktat',
|
||||
'lohnsatz','lohnseiten','lohnsklave','lokalposse','lollis','lorbeer','lösbarem','lösbarstem',
|
||||
'löschebene','losheulst','löslichere','losmüssten','lospoltert','lössen','losweine','lötend',
|
||||
'lotweise','luandas','lüftchens','lüftender','lufthoheit','lügenbaron','luisenhof','lukrierst',
|
||||
'luminal','lungenfell','lungernd','lutschmund','lutscht','luvseite','lyotrope','mäandernde',
|
||||
'machos','mächtiges','machttrip','macke','madenwurms','mafiöses','magazin','magenkrebs',
|
||||
'magenwurm','magererem','magnet','mahnbrief','maiclub','maidult','mairevolte','maivorgang',
|
||||
'majuskel','makedonen','malchiner','malfelds','malignes','malikiten','maltet','mandarins',
|
||||
'manfred','manifestem','manipels','männertaxi','männlein','manon','manschette','mantelmöwe',
|
||||
'marastisch','maría','mariensaal','mark','markiertet','markschein','marktbreit','marktengen',
|
||||
'marktlagen','marktlaufs','markts','martinshof','märtyrer','märzkämpfe','masern','maßanzügen',
|
||||
'massenbach','massenware','maßgenaue','maßhaltend','maßhielt','massigsten','massimo','mästen',
|
||||
'matrixverb','mattgrünes','mäuerchen','maulhöhle','maultieren','mausinnere','mausoleen','maustest',
|
||||
'maxima','mediokres','meeradlern','meerestage','meerkatze','meernebel','mehltaus','mehlwurms',
|
||||
'mehring','mehrmann','meierei','melbournes','meldegerät','meldelinie','melodrama','merkbücher',
|
||||
'merkreim','messdiener','messing','messrekord','messwagen','methoden','metrischer','mette',
|
||||
'mietstreit','migriert','mikrolage','mikrorille','milchbars','milchberg','milchblume','milchtopf',
|
||||
'miltenberg','mimtest','minigenre','minikocher','minnesangs','miotisch','missendes','missgönnst',
|
||||
'mistbiest','mitbieten','mitgeben','mithabt','mithelfen','mitkampf','mitkriegst','mitleids',
|
||||
'mitreden','mitsenden','mitspielst','mitteldach','mnemosynes','möbelfüße','mobilem','möchtet',
|
||||
'modischem','mohnblüte','molligster','moltkes','momente','mondaugen','mondbebens','mondfähren',
|
||||
'mondgas','mondkreise','mondmobile','mondpreise','mondsüdpol','monetär','monets','mongolider',
|
||||
'mongolides','monomanes','montiere','moorerde','moores','mooreschen','moors','moorstich',
|
||||
'mordmesser','morgendorf','moschee','motorfalke','motorklub','mottolied','mountete','mühsale',
|
||||
'mulatte','mullahs','müllerin','müllfrau','mummelsee','mummelst','mundanerer','münzreihe',
|
||||
'münzstraße','musikpreis','musiktaxis','muskelfeld','muskels','müslis','mutagener','mutmaßt',
|
||||
'muttermal','nabeln','nach','nachbuchen','nachdrehen','nacherben','nachfolgen','nachginge',
|
||||
'nachgraben','nachhalft','nachprüfte','nachrechne','nachruhm','nachtrags','nachtun','nachtwerte',
|
||||
'nackertem','nackten','nadelufers','nadelwalze','nagern','nahekommt','nahelagen','nahender',
|
||||
'nähere','namenspate','nämliche','nansen','narrenberg','narrenräte','nasenzahn','nassem',
|
||||
'nassfesten','naturliebe','nebelkappe','nebelkrähe','nebenapsis','nebenhalle','necktet','negerleins',
|
||||
'neidender','neigen','nelkenöl','neotango','neozoen','nestes','netbotz','netzbuchse',
|
||||
'netzebene','netzstrom','netzteil','netztrafos','neuanfänge','neuenegg','neufarn','neuloten',
|
||||
'neunkraft','neurogenes','neusalzes','newsrooms','nichtigste','nicknamen','nietköpfe','nietzangen',
|
||||
'nihilist','nikosias','nirosta','nisthilfen','nitrose','nobelclubs','noduläre','nordkai',
|
||||
'nordleute','nordlicht','normalkost','nortorf','nörvenich','nostalgie','notabstieg','notar',
|
||||
'notbischof','notenscans','notentyps','notierte','notkapsel','notsystem','notwahl','nudeldick',
|
||||
'nufringen','nullphasen','numerik','nutzfelder','nutzkräfte','nutzpferd','nutzwertes','oberaichs',
|
||||
'oberarmen','oberbilks','oberdevons','oberdonau','oberhöfe','obermengen','oberquinte','oberstift',
|
||||
'oberufers','oberwagen','observator','obsthainen','obstkerns','obstkörbe','ochsenherz','öftesten',
|
||||
'oftmaligem','ohrhämatom','ohrlupe','ohrolive','ohrpinseln','ohrwärmern','okklusive','okkupiere',
|
||||
'oktalskala','oktamer','olekranon','ölenden','opakglases','opel','openoffice','operetten',
|
||||
'opfergefäß','opulente','orangerien','ordnetest','originaler','orkane','ortsakte','ortsämtern',
|
||||
'ortsfremde','ortssinne','oslos','osramlampe','ossärem','ostabhänge','ostdialekt','osterdaten',
|
||||
'osterdeich','ostereis','osterkorn','ostgruppen','osthimmels','osthof','ostkante','ostlage',
|
||||
'ostlinien','ostmeer','ostrings','ostspionin','osttarif','osttrakt','ostwald','oxidiertes',
|
||||
'pachtsumme','pafftet','pagoden','palmfarn','palmfarne','panikmodus','pannonisch','pantschen',
|
||||
'panzertape','papierener','päppele','pappigere','pappkrone','papstmesse','paracortex','paradebett',
|
||||
'parfumduft','parkland','parkplanes','parktet','parlandos','parteifoto','parteiwahl','passende',
|
||||
'passteils','patagonien','pathetik','pauker','paulette','pausbacke','pause','pechfällen',
|
||||
'peepshows','peinigende','peinigst','pektin','pelvis','pendeltags','pendeluhr','pendenzen',
|
||||
'penninikum','pepitahose','periskopen','perlenpfau','perleulen','perlweiße','perlweißem','peronist',
|
||||
'perron','pershings','pestjahre','pfählt','pfandgeld','pfarrblatt','pfeffriger','pfeilnaht',
|
||||
'pferdedarm','pferdeform','pferdeweg','pfleger','pflegeteam','pflügende','pfostens','pfröpfling',
|
||||
'pfundkrise','phasenfrei','phazelie','phenazin','phoibos','photolyse','photophob','picheltest',
|
||||
'pickerl','pickt','pilzart','pilzbefall','pilzbrut','pilzgift','pilzhutes','pilzreich',
|
||||
'pilzsoßen','pilzstiel','pink','pinkerton','pinnt','pinnwand','pirschtest','pissigste',
|
||||
'plankopfs','planten','plapperte','plauderei','pochendes','pokaltor','pökelfisch','polarem',
|
||||
'policen','polizeiamt','polnäherem','polnäherer','polsterst','polygraf','polyphagem','polyvalent',
|
||||
'ponte','poolendem','popelte','popikone','popokneten','porlings','porti','posen',
|
||||
'posiertest','postärztin','postenden','postjobs','postmannes','postmärkte','postsache','postwaggon',
|
||||
'prachatitz','prälat','praliné','prämatur','prämisse','preisindiz','preiskrieg','prellens',
|
||||
'presbytern','pressgangs','pressiert','prickeltet','primark','privathaus','probelager','probendem',
|
||||
'profiler','profiteam','projekte','prokurator','prosciutto','prüderer','prüfern','prüfstufen',
|
||||
'prüfzwecke','prügel','prustenden','pseudograf','puerperium','pulsnitz','pults','pummel',
|
||||
'pumpenhübe','pumpwagen','punkern','pushender','pushendes','puterrot','putzkasten','putzwolle',
|
||||
'quäkern','quälereien','qualm','quappaale','quartierte','quarzkorn','quengele','quengelst',
|
||||
'quengler','queräxten','querulant','rachsucht','raddrucks','radecke','radfelgen','radgröße',
|
||||
'radianten','radiolyse','radkreuze','radpartien','radprofi','radstrecke','rahseglern','raketenbau',
|
||||
'ralligere','randnummer','randsänger','randthemas','randwinkel','randziffer','ranggen','rangierte',
|
||||
'ranwirft','rapsfeld','raschelnd','raschestes','rasenplatz','rasens','rasten','rastendes',
|
||||
'rastklinke','ratiopharm','ratsbücher','räubernd','räubertest','raubmord','raubzwecke','rauchbier',
|
||||
'rauchende','rauchs','rauchspeck','raumgehalt','raumgilde','raumgruppe','raumlösung','raumwinde',
|
||||
'raunend','rauschband','rausfändet','rausgehört','rausholt','rauswagend','realzinsen','rechnest',
|
||||
'rechtloser','redeangst','redeeifers','redehalle','rederecht','redlichere','reduktion','reeps',
|
||||
'referenzen','reformist','regelbaren','regelfach','regelglied','regelhefte','regeltypen','regendicht',
|
||||
'regionales','regress','reichsdorf','reiflichem','reifriesin','reinhards','reinigern','reinkniet',
|
||||
'reinpasse','reinpumpt','reinstoffs','reinströmt','reiselänge','reisesegen','reitgehege','reitpfade',
|
||||
'rekelt','rekorder','rekordzüge','relaiskern','rempelns','rems','renderst','renitentem',
|
||||
'renktest','renneisen','rennfeuers','rennkombi','rennpappe','rennsieges','rentenhöhe','rententurm',
|
||||
'rentierend','replikats','reprise','reputable','residualer','resistives','restloses','restrisiko',
|
||||
'rettichen','reuegefühl','revolution','rezenter','rheinbahn','rheopexie','ribbelns','richtlampe',
|
||||
'richtmaße','riesenbock','riesigere','ringfasan','ringnetze','ringseite','ringstroms','ringzone',
|
||||
'risikohaft','ritzende','rochade','rockstücke','rodelndes','rodeltour','rohmasse','rohrfedern',
|
||||
'rohrkatzen','rohrmolch','rollators','rollenkern','rollgutes','rollige','rollpulte','rollräder',
|
||||
'romys','ronny','ronsdorf','rookie','rosalia','rosarotem','rosenherz','roséweinen',
|
||||
'rostbraten','rostetest','rostzügel','rotbäckige','roteisens','rothäute','rothöfen','rotisseur',
|
||||
'rotklee','rotnasig','rotsehe','rotviolett','rotwerks','routenwahl','rübchen','rubra',
|
||||
'rückenbau','rückfusion','rückraumes','rückwänden','rudercamp','rüdersdorf','ruderten','ruffalles',
|
||||
'rufhebeln','rufspiels','rugby','rugbyballs','ruhehaus','ruhelarven','ruhendem','rühmt',
|
||||
'rumeierte','rumfummelt','rumhurt','rumliefen','rumpfnamen','rumpfrippe','rumprolle','rumrollen',
|
||||
'rundschau','rundzellen','runzligem','saalestadt','saarburger','sabbat','sabbattags','säbelbeins',
|
||||
'sachgüter','sachkredit','sachtiteln','sackgrober','sackgrobes','sadistisch','säender','safttagen',
|
||||
'sägemühle','sägestaub','sähest','sahniges','sakral','säkulum','salbt','saloppem',
|
||||
'salpeter','salpetrig','salzfass','salzgebers','salzhütten','salziges','salzlinien','salzsteppe',
|
||||
'samtene','sandkämmen','sandpilze','sandyacht','sanftestem','sängerstar','sarrasani','säten',
|
||||
'satertag','satiniere','satins','satirische','sattelnd','sattmache','satzbruch','satzfolge',
|
||||
'satzpaare','satztempo','sauertopf','säuge','saugeil','saugfuß','saugpumpe','saugseiten',
|
||||
'sauguter','säuleneibe','säulenpaar','säumender','säumens','saunabad','saunahotel','saunatages',
|
||||
'säuselndem','schachfeld','schachts','schachzüge','schadest','schäfte','schaftende','schalllaut',
|
||||
'schalters','schaltpult','scharrst','schatzburg','schaubach','scheide','scheinhanf','schengener',
|
||||
'scheppernd','scheuern','schieferem','schiefern','schiefging','schiffstau','schillert','schimmre',
|
||||
'schleifern','schleifton','schleifweg','schlendert','schliddern','schlingert','schmaleres','schmatzt',
|
||||
'schmeckst','schmollens','schmore','schmückten','schmutzen','schnappten','schnaubtet','schneeeis',
|
||||
'schneehexe','schnellen','schnepper','schnieften','schnittige','schnoberst','schocklage','schocktet',
|
||||
'schömberg','schonen','schonend','schonische','schote','schrappt','schreiten','schubben',
|
||||
'schuhtyps','schuhwerke','schulatlas','schülerulk','schulhofes','schuljahre','schulsäcke','schultipp',
|
||||
'schulz','schulzeug','schummele','schüppe','schwänken','schwelgen','schwerion','schwingweg',
|
||||
'schwollt','sechsring','sedierende','seeburg','seegrasart','seekrankem','seele','seelenleid',
|
||||
'seeleuten','seemitte','seesack','seesysteme','seewagens','seewanne','segeberg','segelpin',
|
||||
'segeltyps','segge','segneten','seidelbast','seiffert','sektlaune','selbsttode','selbstwahl',
|
||||
'selenide','semper','senfgurke','senfpulver','senfsauce','sengenden','sensor','sensorbild',
|
||||
'serenade','serienzüge','serösem','serothorax','sesamsalz','sexberater','sexpuppe','sextriebs',
|
||||
'siamesisch','sibiriern','sicherst','siebenbach','sieberts','siebtet','sieg','siegelst',
|
||||
'siegeltet','siegend','sieger','siegtores','siezen','silbenfuge','silberbart','silberguss',
|
||||
'silbersarg','silberstab','silberwald','simmels','sinatras','singvogel','sinternd','sippenhaft',
|
||||
'situative','sitzortes','sizilische','skandal','skarabäen','skateshop','skifahrer','skitechnik',
|
||||
'skiwasser','skotom','slawisten','slowene','snobs','socken','soffen','solidem',
|
||||
'sollmengen','soloproben','sondername','songtagen','sonnentanz','soße','soundcity','soupieren',
|
||||
'spähenden','spaltbarer','spaltbares','spanlosen','sparbetrag','spareribs','spargold','sparpack',
|
||||
'speedway','speist','spelunke','spenglern','sperma','sperrbügel','sperrgrad','sperrgriff',
|
||||
'sperrkamm','sperrrecht','spessart','spielchens','spielgrund','spielhahn','spielräume','spieluhr',
|
||||
'spielvideo','spinalnerv','spinn','spinndrüse','spinnend','spinnender','spinnkurve','spitals',
|
||||
'splendides','spornräder','sporntest','sportarena','sportler','sportpause','sportseen','sportsitz',
|
||||
'sportstück','spöttele','sprachzug','springsee','springseil','spritzöle','sprödesten','sprunglauf',
|
||||
'spülte','staatshof','stadtakt','stadttyp','stahlbälle','stählern','stahlpflug','stahlspan',
|
||||
'stalin','stammkopie','stampfend','standards','standest','standleier','standorgel','stanzte',
|
||||
'starrendem','startcodon','starten','startgerät','starttaste','statistik','statteten','staubfäden',
|
||||
'staubmenge','staubwerte','stauern','staulängen','stauseen','steckfeld','stehhöhe','stehkanten',
|
||||
'steileres','steinigste','steinsäge','stellplatz','stellteils','sterndame','sternhöhen','sternwalze',
|
||||
'steuerndes','stibitzend','stichelns','stiefele','stielachse','stiftweg','stilecht','stilform',
|
||||
'stilistik','stillere','stils','stöberte','stofftiere','störanteil','störberufs','störkaviar',
|
||||
'störköchin','strafkarte','strafsache','straften','strahltet','strandtage','strebertyp','strebfront',
|
||||
'streifige','streifzugs','streitwert','strengem','streukanal','streunst','strich','strichelst',
|
||||
'strichen','strichhöhe','strickzeug','strolch','stromkabel','stromnetz','strünken','stufenkeil',
|
||||
'stuhlbein','stulpe','stülpe','stummster','stumpf','stupendes','stupsers','stürmer',
|
||||
'sturstes','sturzbades','stutzigen','stützwand','stylishe','subdiakon','subkomitee','sublunar',
|
||||
'subsumtiv','subsysteme','subtype','suchbaum','suchbooten','suchkegels','suchraums','suchworte',
|
||||
'sudan','südgruppe','südostecke','südostzone','südsterne','südufers','südwinden','südwinds',
|
||||
'süffisance','sulz','sulzbach','sülzens','surfende','symbole','synagogale','synergetik',
|
||||
'syrisch','systemband','tafelberge','tafelgüter','tagereisen','tageskarte','tageskauf','tagungsort',
|
||||
'taillen','taktart','taktteilen','taktzahlen','talebenen','talwärts','tamilische','tannenbaum',
|
||||
'tannenholz','tanzbarer','tanzfächer','tanzkorps','tanzpaare','tanzsäbel','tanzsalon','tappendes',
|
||||
'tapste','tarantella','tarifliste','tariflos','tarnfirma','tasmanier','tastencode','tatbericht',
|
||||
'tatmittels','taubem','taubergung','taufethik','taufname','taufsekte','taumeligen','taumelte',
|
||||
'taurine','tautomerie','teakholzes','teddys','teebücher','teehandel','teepott','teetrinken',
|
||||
'teiches','teigigstes','teilerfolg','teilfeldes','teilhaus','teilhäuser','teilköpfe','teilmarkt',
|
||||
'teilneubau','teilnummer','teilsaldo','teilweisem','telejets','telekabels','tenor','tenside',
|
||||
'termini','terrorist','testbuches','testeid','testklick','testphasen','testregion','testsatzes',
|
||||
'testwert','texanerin','texaners','textwerk','textwolfs','theismus','tiananmen','tickender',
|
||||
'tiefbeet','tiefebbe','tiefst','tiefsten','tierarten','tierbau','tierklasse','tierleiche',
|
||||
'tiermähne','tierrecht','tierreihen','tierspiele','tierstiles','timmendorf','timorese','tintenfass',
|
||||
'tippendem','tischdecke','tischfüße','titelrolle','titelteil','todeskarte','todesnot','tolerablem',
|
||||
'tolerante','tolerierte','tollerem','tonblock','tönen','tongutes','tonkabine','tonpfanne',
|
||||
'tonrufen','tonsignale','tonspuren','tonteils','tontellern','tontiegels','tontöpfe','tonträgern',
|
||||
'tontrauben','topfdeckel','topfebenem','topfgucker','topfrand','toppende','torferden','torfheide',
|
||||
'torläufen','tornetzes','torpaaren','torten','tosend','totarbeite','totoblöcke','tougherem',
|
||||
'tradiertem','traghimmel','tragholm','tragtüte','tragzeit','trainerjob','traktrix','transpiler',
|
||||
'trantüten','trauma','traumbades','träume','traumlied','traumloses','traumpilze','traumtext',
|
||||
'traumtore','treibgas','treidle','trend','trennens','trenntaste','treueiden','treuerem',
|
||||
'treysa','triangel','trinkkur','trippelnd','triptycha','trittfüße','tröglitz','trommelte',
|
||||
'trompetest','tröstendem','trotzigste','tuchelle','tukan','tulpenrock','türblech','türglocke',
|
||||
'türklopfer','turkmene','turmdächer','turmgruppe','türmst','turmuhr','turmwegen','turnsport',
|
||||
'tuschmaler','tuschtest','tuskulaner','twitterin','twitterten','typisierst','überbauter','übergroßes',
|
||||
'überhastet','überlaut','überludet','übernähe','überragte','überredete','überstreut','übertrat',
|
||||
'überwachst','uferrand','uffenheims','uhrarmband','uhrganges','ulla','umbrauste','umdachten',
|
||||
'umfliegen','umhörendem','umhörtest','umkehrte','umklammert','umkodierte','umlage','umlandes',
|
||||
'umlegung','umleiten','umleitet','umlernt','umnietende','umpackende','umsäumend','umsäumte',
|
||||
'umschütte','umsehende','umspulens','umspülte','umstimme','umtauscht','umwälzung','umwandler',
|
||||
'umwehten','umzugs','umzukommen','unbesehene','unbespielt','unbewährt','unbewegt','unbewegten',
|
||||
'unbrutalen','undeutlich','unduldsame','unedlerer','unerlöst','ungeahnten','ungerächte','ungestalte',
|
||||
'ungesüßt','unheilbare','unhold','uniformes','unita','units','unitymedia','university',
|
||||
'unmutes','unpaarer','unstern','unterbinde','unterbügel','untergärig','untergebot','unterlegte',
|
||||
'unterleibs','unterphase','unterpreis','unterschuh','untertritt','unüblichem','unverholzt','unvermählt',
|
||||
'urämisch','urämisches','urbildlich','urchigem','urnengefäß','urschrift','ursprunges','urszenen',
|
||||
'urteilsakt','urvölkern','urweib','usuell','utes','vademecum','vakanzzeit','vakuumform',
|
||||
'valerius','variierst','vegetativ','vehement','vektoren','velgast','ventildüse','verabredet',
|
||||
'verbauten','verbleib','verblühtet','verbots','verbracht','verbundes','verdingte','verdünntem',
|
||||
'vereint','verenge','verfallen','verfaulend','verfehlst','verfeme','verfilmens','verfolgers',
|
||||
'verformst','verfrühtem','vergammeln','vergieße','verglastet','vergucktet','verharrtet','verheilte',
|
||||
'verleidete','verlöschen','vermahlen','vermerke','vermodre','vermottete','vernarbtet','vernetzbar',
|
||||
'vernetztes','verpackung','verpestung','verpolter','verquickt','verratzter','verrohten','verrußen',
|
||||
'versäumen','versautes','verschifft','verschoss','versilbern','versintert','versprüht','verstandes',
|
||||
'versuchens','vertilgst','vertragend','vertranken','vertreibe','vertust','verübelter','verübeltes',
|
||||
'verunziert','verwehtet','verwerft','verwestes','verwiesest','verwirktes','verwitwt','verwühltem',
|
||||
'verwüstest','verzagtet','verzapfter','verzerrtem','verzinsens','verzollbar','vianden','videochips',
|
||||
'viehhüter','vierarmige','vierstufig','vikariaten','villen','violen','virtuose','virustypen',
|
||||
'vizesenior','vogelblüte','vogelbrot','vogelwart','vöhringens','volksstand','vollbades','vollblutes',
|
||||
'volldeich','vollendung','vollgarage','vollkotze','volllast','volltext','vollwellen','vorahnens',
|
||||
'voranalyse','vorankamst','vorausahne','vorderberg','vordruckes','voreilst','voreilten','vorfelder',
|
||||
'vorfluters','vorführe','vorgängige','vorgelegt','vorglühend','vorkauende','vorkauf','vorkeimens',
|
||||
'vorladend','vorlieft','vormann','vornimmt','vorprellt','vorpumpe','vorrangige','vorräumen',
|
||||
'vorschwebt','vorsetzt','vorsinge','vorstehend','vortaunus','vorteige','vortriebs','vortrockne',
|
||||
'vorwerft','vorzieht','vorzugaren','vranitzky','wacherem','wachgebiet','wachküsst','wachruft',
|
||||
'wachsmann','wackligste','wagenbuchs','wagenburg','wagenpanne','wagnissen','wahldingen','wahlfreier',
|
||||
'wahloption','wahlrunden','wahlschein','wahlstadt','wahlweise','wahlzeugen','waidmann','walachei',
|
||||
'waldbau','waldigem','waldkiefer','waldoption','waldtal','walnussöle','walzberge','walzgolde',
|
||||
'walzgut','walzjahr','walzlagern','walzrad','wandelnden','wanderclub','wandle','wappensäle',
|
||||
'warben','ware','wärmtet','warnton','warsteins','wartenden','warthe','waschplatz',
|
||||
'waschseife','watte','webweisers','weckendem','wegbegabst','wegblickt','wegbrechen','wegdösten',
|
||||
'wegföhnend','weggänge','weghörende','wegklicken','wegleitest','weglobe','weglobend','weglösung',
|
||||
'wegpacken','wegputzt','wegrasiert','wegräumens','wegreist','wegsteckt','wegstücken','wegtupft',
|
||||
'wegwandere','wegwarf','wehrerker','wehrhügels','wehst','wehtust','weichkäses','weichlötet',
|
||||
'weidenhahn','weidenklee','weidmannes','weinladen','weinsee','weinsiegel','weinte','weintipp',
|
||||
'weisem','weiß','weissagern','weißliste','weitergebt','weitung','welkere','wellbleche',
|
||||
'wellenkeil','wellerbau','wellrad','weltdamen','weltkenner','weltkernen','weltkunst','weltlage',
|
||||
'weltlehrer','weltnormen','weltseins','weltweiten','wendbar','wendelgang','wendigeren','wendigste',
|
||||
'wendisch','wenigem','werbefirma','werbehymne','werbestar','werdens','werfall','werkareal',
|
||||
'werkbezug','werks','werksbau','werktest','wertfreie','wertstoffs','wertwort','wesertals',
|
||||
'westend','westrampe','wettercode','wetterten','wettiner','wichshilfe','wiesenbach','wildasyl',
|
||||
'wildem','wildforst','wildsäue','wildtieres','wildtulpen','windelkopf','windflöten','windgasse',
|
||||
'windgeist','windstaus','windstrom','winkelform','wirkten','wirrender','wirten','wirtschaft',
|
||||
'wischt','wissenstyp','witwenball','witwer','wohnkultur','wohnmoduls','wohnwesen','wölbtest',
|
||||
'wolframs','workaholic','wortarme','wortfülle','wortkarges','worttreue','wulstartig','wunderwege',
|
||||
'wundnähte','würgegriff','wurmförmig','wurmtet','wursthorn','würzburger','würzigere','wuseln',
|
||||
'wüstenrose','wüstesten','wuterfüllt','wutproben','xenophon','yards','ypsilon','zaberner',
|
||||
'zähigkeit','zahler','zahlung','zählungen','zahme','zahnarztes','zahnbettes','zahnfarben',
|
||||
'zahngrund','zahnherd','zahnmark','zakopane','zapfenloch','zartblaue','zartgefühl','zäsiums',
|
||||
'zaunbrett','zaza','zechte','zedong','zehnworte','zeitlosem','zeitsinn','zeitstufen',
|
||||
'zeitstunde','zeittarifs','zellaltern','zeltartig','zensier','zentralbau','zerbeiß','zerbrächet',
|
||||
'zerfetze','zerklopfst','zernagens','zerreib','zerrte','zersiedeln','zerstören','zerstreute',
|
||||
'zerteilbar','zeuthen','zickigem','ziegeln','zielloches','zielorgans','ziemern','zier',
|
||||
'ziererkern','zierlicher','ziermotive','zierstein','zierstücks','zigarillos','zilli','zilpzalpen',
|
||||
'zimbeln','zimmerten','zimtwasser','zinkdächer','zinkoxide','zinksarg','zinnabbau','zinngefäße',
|
||||
'zinngießen','zinntypen','zissoide','zither','ziviler','zivilpakt','zockereien','zölibatär',
|
||||
'zollflagge','zöllige','zollteich','zolltor','zonendatei','zoophiler','zopfende','zornrote',
|
||||
'zubilligt','zucchino','züchtigend','zuckerteil','zudeckten','zufächeln','zugantenne','zugballs',
|
||||
'zugchefs','zugdrahts','zugesellt','zugestoßen','zugezogen','zugfest','zugregel','zugschirme',
|
||||
'zugteilung','zugwurzel','zuhakens','zuklappen','zulasst','zunähend','zünddüse','zündgase',
|
||||
'zündstufe','zunftlade','zungenbein','zurechnung','zureite','zurrbügeln','zurrbügels','zuschissen',
|
||||
'zuschnitte','zusendens','zusieht','zustiegst','zustoßende','zustreben','zuteil','zuteilens',
|
||||
'zutextete','zutrauen','zutritt','zuwanderst','zuziehung','zuzügliche','zuzunicken','zwanglosem',
|
||||
'zwängten','zwecklosen','zweibach','zweibänder','zweifelst','zweitmiete','zwergklee','zwiefacher',
|
||||
];
|
||||
Reference in New Issue
Block a user