click the squares?!?!?!?!!?!?!?

This commit is contained in:
2026-09-30 19:15:10 +02:00
parent c8b722a7bc
commit a5c75dd8e5
21 changed files with 6747 additions and 3694 deletions
+1
View File
@@ -133,6 +133,7 @@
"dm_attachment_expiry_days": 90,
"halls_enabled": true,
"userhalls_enabled": true,
"square_clicker_enabled": true,
"enable_userhall_image_upload": true,
"enable_oc": true,
"abyss_enabled": true,
+2
View File
@@ -128,6 +128,8 @@ websrv:
dm_attachment_expiry_days: 90
halls_enabled: true
userhalls_enabled: true
# Square Clicker: audio/video items become playable rhythm-game maps (hotkey o); false turns it off
square_clicker_enabled: true
enable_userhall_image_upload: true
enable_oc: true
abyss_enabled: true
+34
View File
@@ -0,0 +1,34 @@
-- Square Clicker: user-made beatmaps for audio and video items and their scores.
-- notes: JSON array of { t: ms from song start, x: 0..1, y: 0..1 (viewport fractions), d: hold ms (0 = tap),
-- p: optional slider path [[dt ms, x, y], ...] recorded while the hold was dragged }
CREATE TABLE IF NOT EXISTS public.sqc_maps (
id serial PRIMARY KEY,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE,
user_id integer REFERENCES "user"(id) ON DELETE SET NULL,
title text NOT NULL DEFAULT 'Untitled',
notes jsonb NOT NULL DEFAULT '[]'::jsonb,
note_count integer NOT NULL DEFAULT 0,
duration_ms integer NOT NULL DEFAULT 0,
plays integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT 0
);
-- album_item_id: set for maps of one entry of an album (each entry is its own track)
ALTER TABLE public.sqc_maps ADD COLUMN IF NOT EXISTS album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_sqc_maps_item ON public.sqc_maps(item_id, created_at DESC);
CREATE TABLE IF NOT EXISTS public.sqc_scores (
id serial PRIMARY KEY,
map_id integer NOT NULL REFERENCES sqc_maps(id) ON DELETE CASCADE,
user_id integer REFERENCES "user"(id) ON DELETE CASCADE,
score integer NOT NULL DEFAULT 0,
accuracy real NOT NULL DEFAULT 0,
max_combo integer NOT NULL DEFAULT 0,
hits jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at integer NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON public.sqc_scores(map_id, score DESC);
+3712 -3680
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -3337,6 +3337,11 @@ body.sidebar-right-hidden #sidebar-drag-zone::after {
display: flex;
}
/* Tuner "Hide Eye": hides the disc and everything drawn inside it (inner bars, void tunnel, note) */
.sidebar-media-placeholder.audio.eye-hidden .audio-cover-circle {
visibility: hidden !important;
}
.thumb > .sidebar-media-placeholder .audio-cover-circle,
.album-thumb-item > .sidebar-media-placeholder .audio-cover-circle,
.item-preview .sidebar-media-placeholder .audio-cover-circle {
+173 -5
View File
@@ -3723,6 +3723,10 @@ window.cancelAnimFrame = (function () {
const newSrc = item.src || item.dest;
const mime = (item.mime || '').toLowerCase();
// Current entry for other scripts (Square Clicker keys its maps by album entry)
container.dataset.currentSubId = String(item.id || '');
container.dataset.currentSubMime = mime;
setTimeout(() => document.dispatchEvent(new CustomEvent('f0ck:albumChange', { detail: { container, id: item.id, mime } })), 0);
// Update URL hash with subf0ck slug (or id fallback) so links are shareable directly to this subf0ck
const subKey = item.slug || item.subf0ck_id || item.id;
@@ -6006,6 +6010,8 @@ window.cancelAnimFrame = (function () {
glowSmoothness: 0.95,
showCoverInEye: 0,
showCoverInBg: 0,
hideEye: 0,
hideEyeGlow: 0,
showCoverArt: 0,
solidCover: 1,
coverPosX: 0,
@@ -6533,6 +6539,8 @@ window.cancelAnimFrame = (function () {
// Cover Art & Glow Section
{ section: 'Cover Art & Glow Reactivity', key: 'showCoverInEye', label: 'Show Cover in Eye / Disc (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' },
{ section: 'Cover Art & Glow Reactivity', key: 'showCoverInBg', label: 'Show Cover in Background (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' },
{ section: 'Cover Art & Glow Reactivity', key: 'hideEye', label: 'Hide Eye / Disc Completely (1=Hidden, 0=Visible)', min: 0, max: 1, step: 1, unit: '' },
{ section: 'Cover Art & Glow Reactivity', key: 'hideEyeGlow', label: 'Hide Eye Glow & Reactive Gradient (1=Hidden, 0=Visible)', min: 0, max: 1, step: 1, unit: '' },
{ section: 'Cover Art & Glow Reactivity', key: 'coverSize', label: 'Eye / Disc Diameter', min: 40, max: 600, step: 5, unit: 'px' },
{ section: 'Cover Art & Glow Reactivity', key: 'coverPosX', label: 'Eye Position Offset X', min: -400, max: 400, step: 5, unit: 'px' },
{ section: 'Cover Art & Glow Reactivity', key: 'coverPosY', label: 'Eye Position Offset Y', min: -300, max: 300, step: 5, unit: 'px' },
@@ -6710,6 +6718,7 @@ window.cancelAnimFrame = (function () {
? Number(cfg.showCoverInEye) === 1
: (cfg.showCoverArt !== undefined ? Number(cfg.showCoverArt) === 1 : Number(cfg.solidCover) === 0);
const showBg = Number(cfg.showCoverInBg) === 1;
const hideEye = Number(cfg.hideEye) === 1;
const color = cfg.coverColor || '#000000';
const coverOp = cfg.coverOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.coverOpacity))) : 1.0;
@@ -6734,6 +6743,7 @@ window.cancelAnimFrame = (function () {
});
playerPlaceholders.forEach(ph => {
ph.classList.toggle('eye-hidden', hideEye);
let backdrop = ph.querySelector('.audio-cover-backdrop');
const c = ph.querySelector('.audio-cover-circle');
const note = c ? c.querySelector('i') : ph.querySelector(':scope > i');
@@ -6918,6 +6928,9 @@ window.cancelAnimFrame = (function () {
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="laser" title="Laser cursor: colour, trail, glow, click and drag effects">
<i class="fa-solid fa-wand-magic-sparkles"></i> Laser
</button>
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="sqc" title="Square Clicker: sounds, gameplay, auto maps" style="display: none;">
<i class="fa-solid fa-circle-dot"></i> Square Clicker
</button>
</div>
<button type="button" class="f0ck-tuner-close" id="f0ck-tuner-close" title="Close">✕</button>
</div>
@@ -7073,6 +7086,23 @@ window.cancelAnimFrame = (function () {
</div>
</div>
<!-- Pane: Square Clicker (window.f0ckSqcSettings from square-clicker.js, loaded when the feature is on) -->
<div id="f0ck-tuner-pane-sqc" class="f0ck-tuner-subtab-pane" style="display: none;">
<div class="f0ck-tuner-body">
<div class="f0ck-tuner-presets-bar">
<div style="font-size: 11px; opacity: 0.85; font-weight: 700; display: flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-circle-dot"></i> Square Clicker
</div>
<div class="f0ck-tuner-presets-actions">
<button type="button" id="sqc-tuner-preview" class="f0ck-tuner-btn-factory-reset" title="Play the hit sound"><i class="fa-solid fa-volume-high"></i> Test sound</button>
<button type="button" id="sqc-tuner-reset" class="f0ck-tuner-btn-factory-reset" title="Reset all Square Clicker settings to their defaults"><i class="fa-solid fa-rotate-left"></i> Reset</button>
</div>
</div>
<div id="sqc-tuner-rows"></div>
<div id="sqc-tuner-info" style="padding: 4px 12px 12px; font-size: 11px; opacity: 0.6; line-height: 1.4;">Changes apply to the next map you play and are saved on this device.</div>
</div>
</div>
<!-- Pane: Laser cursor (window.f0ckLaser from laser-cursor.js; rows built from its schema) -->
<div id="f0ck-tuner-pane-laser" class="f0ck-tuner-subtab-pane" style="display: none;">
<div class="f0ck-tuner-body">
@@ -7118,7 +7148,7 @@ window.cancelAnimFrame = (function () {
});
const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab');
if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank' || savedSubtab === 'laser')) {
if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank' || savedSubtab === 'laser' || (savedSubtab === 'sqc' && window.f0ckSqcSettings))) {
switchSubtab(savedSubtab);
}
@@ -7267,6 +7297,139 @@ window.cancelAnimFrame = (function () {
render();
})();
// ── Square Clicker pane (window.f0ckSqcSettings from square-clicker.js) ─────────────────────
// square-clicker.js is only loaded when the feature is on: the tab stays hidden until it
// announces itself, then the rows are built from its schema.
(() => {
const tabBtn = panel.querySelector('.f0ck-tuner-subtab-btn[data-subtab="sqc"]');
const rowsEl = panel.querySelector('#sqc-tuner-rows');
if (!tabBtn || !rowsEl) return;
const api = () => window.f0ckSqcSettings || null;
const esc = (v) => String(v).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
const fmt = (row, v) => {
const n = Number(v);
const shown = (row.step && row.step < 1) ? n.toFixed(row.step < 0.1 ? 2 : 1) : String(Math.round(n));
return shown + (row.unit || '');
};
const selStyle = 'width: 100%; height: 30px; background: var(--bg, #111); color: var(--white, #fff); border: 1px solid var(--nav-border-color, rgba(255,255,255,0.2)); font-family: inherit; font-size: 12px;';
const build = () => {
const a = api();
if (!a || rowsEl.dataset.built) return;
const st = a.getState();
let html = '';
a.schema.forEach(row => {
if (row.section) { html += '<div class="f0ck-tuner-section-title">' + esc(row.section) + '</div>'; return; }
if (row.type === 'actions') {
html += '<div class="f0ck-tuner-row"><div style="display: flex; gap: 6px; flex-wrap: wrap;">' +
row.buttons.map(([name, label]) => '<button type="button" class="f0ck-tuner-mode-btn" data-sqc-action="' + esc(name) + '" style="flex: 1; padding: 6px 8px; font-size: 11px;">' + esc(label) + '</button>').join('') +
'</div></div>';
return;
}
const v = st[row.key];
html += '<div class="f0ck-tuner-row" data-sqc-key="' + row.key + '"' + (row.showIf ? ' data-sqc-show-if="' + esc(row.showIf) + '"' : '') + '>';
if (row.type === 'toggle') {
html += '<div class="f0ck-tuner-row-label"><span>' + esc(row.label) + '</span></div>' +
'<div style="display: flex; gap: 6px;">' +
'<button type="button" class="f0ck-tuner-mode-btn" data-sqc-toggle="0" style="flex: 1; padding: 4px 8px; font-size: 11px;">Off</button>' +
'<button type="button" class="f0ck-tuner-mode-btn" data-sqc-toggle="1" style="flex: 1; padding: 4px 8px; font-size: 11px;">On</button>' +
'</div>';
} else if (row.type === 'select') {
html += '<div class="f0ck-tuner-row-label"><span>' + esc(row.label) + '</span></div>' +
'<select style="' + selStyle + '">' + row.options.map(([k, l]) => '<option value="' + esc(k) + '">' + esc(l) + '</option>').join('') + '</select>';
} else if (row.type === 'color') {
html += '<div class="f0ck-tuner-row-label"><span>' + esc(row.label) + '</span><span data-sqc-value>' + esc(v) + '</span></div>' +
'<input type="color" value="' + esc(v) + '" style="width: 100%; height: 30px; padding: 2px 4px; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.2)); background: var(--bg, #111); cursor: pointer;" />';
} else if (row.type === 'file') {
html += '<div class="f0ck-tuner-row-label"><span>' + esc(row.label) + '</span><span data-sqc-value></span></div>' +
'<div style="display: flex; gap: 6px;">' +
'<label class="f0ck-tuner-mode-btn" style="flex: 1; padding: 4px 8px; font-size: 11px; text-align: center; cursor: pointer;">Upload<input type="file" accept="' + esc(row.accept || '') + '" style="display: none;"></label>' +
'<button type="button" class="f0ck-tuner-mode-btn" data-sqc-clear style="flex: 1; padding: 4px 8px; font-size: 11px;">Remove</button>' +
'</div>';
} else {
html += '<div class="f0ck-tuner-row-label"><span>' + esc(row.label) + '</span><span data-sqc-value>' + fmt(row, v) + '</span></div>' +
'<input type="range" min="' + row.min + '" max="' + row.max + '" step="' + row.step + '" value="' + esc(v) + '" />';
}
html += '</div>';
});
rowsEl.innerHTML = html;
rowsEl.dataset.built = '1';
rowsEl.querySelectorAll('[data-sqc-action]').forEach(b => {
b.addEventListener('click', () => {
if (!api()) return;
const r = api().action(b.dataset.sqcAction) || {};
if (r.msg && window.flashMessage) window.flashMessage(r.msg, 2500, r.ok ? 'success' : 'error');
});
});
rowsEl.querySelectorAll('[data-sqc-key]').forEach(rowEl => {
const key = rowEl.dataset.sqcKey;
const row = a.schema.find(r => r.key === key);
const valEl = rowEl.querySelector('[data-sqc-value]');
if (row.type === 'file') {
const input = rowEl.querySelector('input[type="file"]');
input.addEventListener('change', async () => {
const f = input.files && input.files[0];
input.value = '';
if (!f || !api()) return;
const r = await api().setCustomSound(f);
if (!r.ok && window.flashMessage) window.flashMessage(r.msg || 'Could not use that file', 3000, 'error');
else if (api()) api().preview();
});
rowEl.querySelector('[data-sqc-clear]').addEventListener('click', () => { if (api()) api().clearCustomSound(); });
return;
}
const input = rowEl.querySelector('input, select');
if (input) {
input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => {
if (valEl && row.type === 'range') valEl.textContent = fmt(row, input.value);
if (valEl && row.type === 'color') valEl.textContent = input.value;
if (api()) api().set(key, input.value);
if (key === 'hitSound' || key === 'volume') { if (api()) api().preview(); }
});
}
rowEl.querySelectorAll('[data-sqc-toggle]').forEach(b => {
b.addEventListener('click', () => { if (api()) api().set(key, +b.dataset.sqcToggle); });
});
});
};
const render = (st) => {
const a = api();
if (!a) return;
tabBtn.style.display = '';
build();
st = st || a.getState();
rowsEl.querySelectorAll('[data-sqc-key]').forEach(rowEl => {
const key = rowEl.dataset.sqcKey;
const row = a.schema.find(r => r.key === key);
const valEl = rowEl.querySelector('[data-sqc-value]');
if (row.type === 'file') {
if (valEl) valEl.textContent = st.customSound || 'none';
rowEl.style.opacity = st.hitSound === 'custom' || !st.customSound ? '' : '0.6';
return;
}
if (row.showIf) rowEl.style.display = +st[row.showIf] ? '' : 'none';
const input = rowEl.querySelector('input, select');
if (input && document.activeElement !== input) input.value = st[key];
if (valEl && row.type === 'range') valEl.textContent = fmt(row, st[key]);
if (valEl && row.type === 'color') valEl.textContent = st[key];
rowEl.querySelectorAll('[data-sqc-toggle]').forEach(b => b.classList.toggle('active', +b.dataset.sqcToggle === (+st[key] ? 1 : 0)));
});
};
panel.querySelector('#sqc-tuner-reset').addEventListener('click', () => { if (api()) api().reset(); });
panel.querySelector('#sqc-tuner-preview').addEventListener('click', () => { if (api()) api().preview(); });
const onChange = (e) => {
if (!panel.isConnected) { window.removeEventListener('f0ck:sqc_settings_changed', onChange); return; }
render(e.detail);
};
window.addEventListener('f0ck:sqc_settings_changed', onChange);
tabBtn.addEventListener('click', () => render());
render();
})();
// ── Einfach / Erweitert Toggle (Background Tab) ────────────────────────
const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows');
const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows');
@@ -8016,6 +8179,8 @@ window.cancelAnimFrame = (function () {
window.audioVisualizerTuning.showCoverArt = (isShow || Number(window.audioVisualizerTuning.showCoverInEye) === 1) ? 1 : 0;
window.audioVisualizerTuning.solidCover = (window.audioVisualizerTuning.showCoverArt === 1) ? 0 : 1;
updateCoverArtSolidMode();
} else if (s.key === 'hideEye') {
updateCoverArtSolidMode();
} else if (s.key === 'showCoverArt') {
const isShow = Number(input.value) === 1;
window.audioVisualizerTuning.showCoverArt = isShow ? 1 : 0;
@@ -9330,6 +9495,7 @@ window.cancelAnimFrame = (function () {
noteIcon = ph ? ph.querySelector('i') : null;
}
const coverCircle = ph ? ph.querySelector('.audio-cover-circle') : null;
if (ph) ph.classList.toggle('eye-hidden', Number(cfg.hideEye) === 1);
let bgEl = ph ? ph.querySelector('.audio-reactive-bg') : null;
if (ph && !bgEl) {
bgEl = document.createElement('div');
@@ -10037,7 +10203,7 @@ window.cancelAnimFrame = (function () {
if (oldGhosts.length) oldGhosts.forEach(g => g.remove());
}
const eyeNoGlow = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1;
const eyeNoGlow = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1 || Number(cfg.hideEyeGlow) === 1;
const eyeNoShadow = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1;
if (!coverCircle._sqHiddenByTunnel) {
if (eyeNoShadow) {
@@ -10057,7 +10223,7 @@ window.cancelAnimFrame = (function () {
// Dynamic Background Gradient Layer Rendering
if (bgEl) {
const enableBg = Number(cfg.enableBgGradient !== undefined ? cfg.enableBgGradient : 1) === 1;
if (!enableBg || audioElement.paused) {
if (!enableBg || audioElement.paused || Number(cfg.hideEyeGlow) === 1) {
bgEl.style.opacity = '0';
} else {
const baseOp = cfg.bgGradientOpacity !== undefined ? Number(cfg.bgGradientOpacity) : 0.40;
@@ -10878,7 +11044,7 @@ window.cancelAnimFrame = (function () {
const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100)));
const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100)));
const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100)));
const eyeNoGlowB = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1;
const eyeNoGlowB = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1 || Number(cfg.hideEyeGlow) === 1;
const eyeNoShadowB = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1;
if (!coverCircle._sqHiddenByTunnel) {
if (eyeNoShadowB) {
@@ -10901,7 +11067,7 @@ window.cancelAnimFrame = (function () {
coverCircle._visualizerDriving = false;
const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35;
const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0;
const eyeNoGlowC = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1;
const eyeNoGlowC = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1 || Number(cfg.hideEyeGlow) === 1;
const eyeNoShadowC = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1;
if (!coverCircle._sqHiddenByTunnel) {
if (eyeNoShadowC) {
@@ -15141,6 +15307,8 @@ window.cancelAnimFrame = (function () {
},
"i": clickOnElementBinding("a#a_addtag"),
"l": () => window.toggleBackground(),
// Square Clicker panel (square-clicker.js; says so when the item has nothing to play)
"o": () => { if (typeof window.f0ckSqcOpen === 'function') window.f0ckSqcOpen(); },
"y": () => {
copyCurrentUrl();
},
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -716,7 +716,7 @@ window.initUploadForm = (selector) => {
if (fileInput) {
try {
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
fileInput.accept = Object.keys(mimesObj).join(',');
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
} catch {}
}
if (selectedFiles.length > 0) {
@@ -726,7 +726,7 @@ window.initUploadForm = (selector) => {
if (fileInput) {
try {
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
fileInput.accept = Object.keys(mimesObj).join(',');
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
} catch {}
}
if (!isAlbumModeActive()) {
@@ -1530,7 +1530,9 @@ window.initUploadForm = (selector) => {
}
const mimeOk = !file.type || allowedMimes.includes(file.type);
const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt);
// Beatmap sets (.osz): the server turns them into an audio post with Square Clicker maps
const extOk = (allowedExts.length > 0 && allowedExts.includes(fileExt)) ||
(fileExt === 'osz' && form.getAttribute('data-beatmaps') === '1');
if (allowedMimes.length > 0 && !mimeOk && !extOk) {
const errorMsg = `File type ${file.type || '.' + fileExt} is not allowed.`;
+3 -1
View File
@@ -82,9 +82,11 @@ const updateHoverStates = () => {
const cwModal = document.getElementById('content-warning-modal');
if ((cwModal && cwModal.style.display !== 'none') || isMobile) return;
// A Square Clicker game covers the page: the cursor over the player's area is playing, not hovering
const inGame = document.body.classList.contains('sqc-session');
document.querySelectorAll('.v0ck').forEach(p => {
const rect = p.getBoundingClientRect();
const isOver = mouseX >= rect.left && mouseX <= rect.right &&
const isOver = !inGame && mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
p.classList.toggle("v0ck_hover", isOver);
const gal = p.closest('.album-gallery-container');
+342
View File
@@ -0,0 +1,342 @@
import JSZip from 'jszip';
import db from './sql.mjs';
import { MAX_NOTES, MAX_HOLD_MS, cleanNotes, cleanTitle, insertMap } from './square_clicker.mjs';
// Beatmap import for Square Clicker: .osz sets (zip: audio, background, one .osu text file per
// difficulty) and loose .osu files, converted to Square Clicker notes { t, x, y, d, p? }.
//
// Standard and catch: circle / fruit -> tap, slider / juice stream -> slider (curve sampled, repeats
// become a path that goes back and forth, duration from length, timing and slider velocity),
// spinner / banana shower -> hold in the middle.
// Taiko: every object sits in the middle of the playfield, so positions are generated: a path that
// flows over the screen (don turns gently, kat turns sharply, distance grows with the time gap).
// Drumroll and denden -> hold.
// Mania: columns become lanes side by side, long notes holds; chords collapse to one note (one cursor).
const PF_W = 512, PF_H = 384; // playfield in osu pixels
// playfield -> viewport fractions, leaving room for the top bar and the edges
const toX = (x) => Math.max(0.02, Math.min(0.98, 0.1 + (x / PF_W) * 0.8));
const toY = (y) => Math.max(0.02, Math.min(0.98, 0.14 + (y / PF_H) * 0.74));
const MAX_OSU_BYTES = 4 * 1024 * 1024; // one .osu file
const MAX_DIFFS = 40;
// ── .osu text -> sections ─────────────────────────────────────────────────────
export const parseOsu = (text) => {
const out = { General: {}, Metadata: {}, Difficulty: {}, Events: [], TimingPoints: [], HitObjects: [] };
let sec = null;
for (let line of String(text).replace(/^/, '').split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith('//')) continue;
const m = /^\[(\w+)\]$/.exec(line);
if (m) { sec = m[1]; continue; }
if (!sec) continue;
if (sec === 'General' || sec === 'Metadata' || sec === 'Difficulty') {
const i = line.indexOf(':');
if (i > 0) out[sec][line.slice(0, i).trim()] = line.slice(i + 1).trim();
} else if (Array.isArray(out[sec])) {
out[sec].push(line);
}
}
return out;
};
// ── curves ────────────────────────────────────────────────────────────────────
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const bezier = (pts) => {
let len = 0;
for (let i = 1; i < pts.length; i++) len += dist(pts[i - 1], pts[i]);
const n = Math.max(2, Math.min(200, Math.ceil(len / 4)));
const out = [];
for (let s = 0; s <= n; s++) {
let q = pts.slice();
const t = s / n;
while (q.length > 1) q = q.slice(1).map((p, i) => lerp(q[i], p, t)); // de Casteljau
out.push(q[0]);
}
return out;
};
const catmull = (pts) => {
const out = [];
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
for (let s = 0; s < 16; s++) {
const t = s / 16, t2 = t * t, t3 = t2 * t;
out.push([0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3)));
}
}
out.push(pts[pts.length - 1]);
return out;
};
// Perfect circle through three points; null when they are (nearly) on a line
const arc = (a, b, c) => {
const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
if (Math.abs(d) < 1e-3) return null;
const sq = (p) => p[0] * p[0] + p[1] * p[1];
const cx = (sq(a) * (b[1] - c[1]) + sq(b) * (c[1] - a[1]) + sq(c) * (a[1] - b[1])) / d;
const cy = (sq(a) * (c[0] - b[0]) + sq(b) * (a[0] - c[0]) + sq(c) * (b[0] - a[0])) / d;
const r = Math.hypot(a[0] - cx, a[1] - cy);
const a0 = Math.atan2(a[1] - cy, a[0] - cx);
let a2 = Math.atan2(c[1] - cy, c[0] - cx);
// direction: the way that passes through b (sign of the turn a -> b -> c)
const ccw = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) > 0;
if (ccw) { while (a2 < a0) a2 += 2 * Math.PI; } else { while (a2 > a0) a2 -= 2 * Math.PI; }
// sample well past the end point: the slider length decides where it stops
const span = (a2 - a0) * 2;
const n = Math.max(8, Math.min(200, Math.ceil(Math.abs(span) * r / 4)));
const out = [];
for (let s = 0; s <= n; s++) { const t = a0 + span * s / n; out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); }
return out;
};
// Slider curve as a polyline (osu pixels), before cutting to the slider length
const curve = (type, pts) => {
if (type === 'P' && pts.length === 3) {
const c = arc(pts[0], pts[1], pts[2]);
if (c) return c;
return pts;
}
if (type === 'L') return pts;
if (type === 'C') return catmull(pts);
// Bezier (also the fallback): a repeated point starts a new segment
const out = [];
let seg = [pts[0]];
for (let i = 1; i < pts.length; i++) {
if (pts[i][0] === pts[i - 1][0] && pts[i][1] === pts[i - 1][1]) { out.push(...bezier(seg)); seg = [pts[i]]; }
else seg.push(pts[i]);
}
out.push(...bezier(seg));
return out;
};
// Cut / extend a polyline to exactly `length`, then resample it to k points evenly along it
const fitLength = (poly, length, k) => {
const cum = [0];
for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + dist(poly[i - 1], poly[i]));
const total = cum[cum.length - 1];
const at = (s) => {
if (poly.length < 2) return poly[0];
if (s >= total) { // past the end: go on along the last direction
const a = poly[poly.length - 2], b = poly[poly.length - 1], l = dist(a, b) || 1;
return [b[0] + (b[0] - a[0]) / l * (s - total), b[1] + (b[1] - a[1]) / l * (s - total)];
}
let i = 1;
while (i < cum.length - 1 && cum[i] < s) i++;
const seg = cum[i] - cum[i - 1] || 1;
return lerp(poly[i - 1], poly[i], (s - cum[i - 1]) / seg);
};
return Array.from({ length: k }, (_, i) => at(length * i / (k - 1)));
};
// ── timing ────────────────────────────────────────────────────────────────────
// -> sorted [{ time, beatLength, sv }]: the beat length and slider velocity in effect from `time` on
const timingOf = (lines) => {
const out = [];
let beat = 500, sv = 1;
const pts = lines.map(l => l.split(',')).filter(p => p.length >= 2)
.map(p => ({ time: +p[0], bl: +p[1], unin: p.length > 6 ? p[6] === '1' : +p[1] > 0 }))
.filter(p => isFinite(p.time) && isFinite(p.bl))
.sort((a, z) => a.time - z.time || (z.unin - a.unin)); // uninherited first at the same time
for (const p of pts) {
if (p.unin && p.bl > 0) { beat = p.bl; sv = 1; }
else if (!p.unin && p.bl < 0) sv = Math.max(0.1, Math.min(10, -100 / p.bl));
out.push({ time: p.time, beatLength: beat, sv });
}
if (!out.length) out.push({ time: 0, beatLength: 500, sv: 1 });
return out;
};
const timingAt = (tps, t) => {
let cur = tps[0];
for (const p of tps) { if (p.time <= t + 1) cur = p; else break; }
return cur;
};
// ── generated positions (taiko) ───────────────────────────────────────────────
// A path that wanders over the screen: every note turns it a little (don) or a lot (kat), notes further
// apart in time are further apart on screen, and a spot that would cover a note still on screen (or leave
// the play area) is avoided by turning further until the spot is free
const flowPlace = (list) => {
const ASPECT = 1.6; // x/y are fractions of a wide viewport: a y step this much larger is the same distance
const ON_SCREEN_MS = 1200; // notes this close in time are visible together
const FREE_X = 0.065, FREE_Y = 0.12; // about a note and a bit, as viewport fractions
const inside = (x, y) => x >= 0.15 && x <= 0.85 && y >= 0.2 && y <= 0.82;
const out = [];
let x = 0.5, y = 0.5, ang = -Math.PI / 2, prevEnd = null;
for (const n of list) {
if (prevEnd !== null) {
const want = ang + (n.kat ? 2.2 : 0.3);
const step = Math.max(0.11, Math.min(0.3, (n.t - prevEnd) / 1000 * 0.35));
const recent = out.filter(o => n.t - o.t < ON_SCREEN_MS);
const free = (px, py) => inside(px, py) && !recent.some(o => Math.abs(o.x - px) < FREE_X && Math.abs(o.y - py) < FREE_Y);
let best = null;
for (let k = 0; k < 24 && !best; k++) { // want, want+0.26, want-0.26, want+0.52, ...
const a = want + (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 0.26;
const px = x + Math.cos(a) * step, py = y + Math.sin(a) * step * ASPECT;
if (free(px, py)) best = [a, px, py];
}
if (!best) { // boxed in: the farthest in-bounds spot from the recent notes
let far = -1;
for (let k = 0; k < 24; k++) {
const a = want + k * Math.PI / 12;
const px = Math.max(0.15, Math.min(0.85, x + Math.cos(a) * step)), py = Math.max(0.2, Math.min(0.82, y + Math.sin(a) * step * ASPECT));
const dmin = recent.reduce((m, o) => Math.min(m, Math.hypot(o.x - px, (o.y - py) / ASPECT)), Infinity);
if (dmin > far) { far = dmin; best = [a, px, py]; }
}
}
[ang, x, y] = best;
}
prevEnd = n.t + (n.d || 0); // after a hold the gap counts from its end
out.push({ t: n.t, x: +x.toFixed(4), y: +y.toFixed(4), d: n.d || 0 });
}
return out;
};
// ── one difficulty -> notes ───────────────────────────────────────────────────
const MODES = ['standard', 'taiko', 'catch', 'mania'];
// -> { version, creator, notes, mode, error? }
export const convertOsu = (text) => {
const o = parseOsu(text);
const mode = parseInt(o.General.Mode || '0', 10) || 0;
const version = o.Metadata.Version || 'Normal';
const res = { version, creator: o.Metadata.Creator || '', mode, meta: o.Metadata, audioFile: o.General.AudioFilename || '', notes: [] };
if (!MODES[mode]) { res.error = 'unknown game mode ' + mode; return res; }
const tps = timingOf(o.TimingPoints);
const mult = parseFloat(o.Difficulty.SliderMultiplier) || 1.4;
const keys = Math.max(1, Math.min(18, Math.round(parseFloat(o.Difficulty.CircleSize) || 4))); // mania columns
const holdUntil = (t, end) => Math.max(0, Math.min(MAX_HOLD_MS, Math.round(end) - t));
const sliderMs = (t, p) => {
const tp = timingAt(tps, t);
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
return (Math.max(1, +p[7] || 0) / (mult * 100 * tp.sv) * tp.beatLength) * slides;
};
let notes = [];
const flow = []; // taiko: { t, d, kat } placed afterwards
for (const line of o.HitObjects) {
const p = line.split(',');
if (p.length < 4) continue;
const x = +p[0], y = +p[1], t = Math.round(+p[2]), type = +p[3];
if (!isFinite(x) || !isFinite(y) || !isFinite(t) || t < 0) continue;
if (mode === 1) { // taiko: hitSound whistle (2) or clap (8) = kat
if (type & 2) flow.push({ t, d: holdUntil(t, t + sliderMs(t, p)) }); // drumroll
else if (type & 8) flow.push({ t, d: holdUntil(t, +p[5] || t) }); // denden
else if (type & 1) flow.push({ t, d: 0, kat: !!((+p[4] || 0) & 10) });
continue;
}
if (mode === 3) { // mania: x picks the column; type 128 = long note, end time in the extras
const col = Math.max(0, Math.min(keys - 1, Math.floor(x * keys / PF_W)));
const d = type & 128 ? holdUntil(t, parseInt(String(p[5] || '').split(':')[0], 10) || t) : 0;
notes.push({ t, x: +(0.15 + (col + 0.5) / keys * 0.7).toFixed(4), y: 0.62, d });
continue;
}
if (type & 2) { // slider: x,y,time,type,hitSound,curve,slides,length,...
const [ctype, ...cps] = String(p[5] || '').split('|');
const ctrl = [[x, y], ...cps.map(s => s.split(':').map(Number)).filter(q => q.length === 2 && q.every(isFinite))];
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
const length = Math.max(1, +p[7] || 0);
const tp = timingAt(tps, t);
const perSlide = length / (mult * 100 * tp.sv) * tp.beatLength;
const d = Math.round(perSlide * slides);
if (!isFinite(d) || d <= 0 || ctrl.length < 2) { notes.push({ t, x: toX(x), y: toY(y), d: 0 }); continue; }
const k = Math.max(2, Math.min(Math.ceil(length / 12) + 1, 40, Math.floor(900 / slides)));
const leg = fitLength(curve(ctype, ctrl), length, k);
const path = [];
for (let s = 0; s < slides; s++) {
const pts = s % 2 ? leg.slice().reverse() : leg;
pts.forEach((q, i) => {
if (s > 0 && i === 0) return; // the turn point is already there
path.push([Math.round(perSlide * (s + i / (k - 1))), +toX(q[0]).toFixed(4), +toY(q[1]).toFixed(4)]);
});
}
notes.push({ t, x: toX(x), y: toY(y), d: Math.min(MAX_HOLD_MS, d), p: path });
} else if (type & 8) { // spinner: x,y,time,type,hitSound,endTime -> hold in the middle
const end = Math.round(+p[5] || t);
notes.push({ t, x: 0.5, y: toY(PF_H / 2), d: Math.max(0, Math.min(MAX_HOLD_MS, end - t)) });
} else if (type & 1) {
notes.push({ t, x: toX(x), y: toY(y), d: 0 });
}
}
if (mode === 1) notes = flowPlace(flow.sort((a, z) => a.t - z.t));
notes.sort((a, z) => a.t - z.t);
// one cursor: notes at the same moment (mania chords) collapse to the first
notes = notes.filter((n, i) => i === 0 || n.t - notes[i - 1].t > 1);
if (!notes.length) res.error = 'no hit objects';
else if (notes.length > MAX_NOTES) res.error = 'too many notes (' + notes.length + ', max ' + MAX_NOTES + ')';
res.notes = notes;
return res;
};
// Title of an imported map: "Insane (mapper)", other modes marked: "ONI (mapper, taiko)"
export const importedTitle = (diff) => {
const extra = [diff.creator, diff.mode ? MODES[diff.mode] : ''].filter(Boolean).join(', ');
return diff.version + (extra ? ' (' + extra + ')' : '');
};
// ── .osz -> { meta, audio: { name, data }, background: { name, data } | null, diffs, skipped } ──
// null when the zip has no .osu files (then it is a normal archive)
export const readOsz = async (buffer) => {
let zip;
try { zip = await JSZip.loadAsync(buffer); } catch (_) { return null; }
const files = Object.values(zip.files).filter(f => !f.dir);
const osuFiles = files.filter(f => /\.osu$/i.test(f.name)).slice(0, MAX_DIFFS);
if (!osuFiles.length) return null;
const byName = (name) => {
if (!name) return null;
const want = name.replace(/\\/g, '/').toLowerCase();
return files.find(f => f.name.toLowerCase() === want) || files.find(f => f.name.toLowerCase().endsWith('/' + want)) || null;
};
const diffs = [], skipped = [];
let meta = null, audioName = null, bgName = null;
for (const f of osuFiles) {
const text = await f.async('string');
if (text.length > MAX_OSU_BYTES) { skipped.push({ version: f.name, reason: 'file too large' }); continue; }
const d = convertOsu(text);
if (d.error) { skipped.push({ version: d.version, reason: d.error }); continue; }
diffs.push(d);
if (!meta) meta = d.meta;
if (!audioName) audioName = d.audioFile;
if (!bgName) {
const ev = parseOsu(text).Events.find(l => /^0\s*,\s*0\s*,/.test(l));
const m = ev && /^0\s*,\s*0\s*,\s*"?([^",]+)"?/.exec(ev);
if (m) bgName = m[1].trim();
}
}
// audio: the file the difficulties name, or any audio file in the set
const audioFile = byName(audioName) || files.find(f => /\.(mp3|ogg|wav|flac|m4a)$/i.test(f.name));
const bgFile = byName(bgName) || files.find(f => /\.(jpe?g|png)$/i.test(f.name));
diffs.sort((a, z) => a.notes.length - z.notes.length); // easiest first
return {
meta: meta || {},
audio: audioFile ? { name: audioFile.name, data: await audioFile.async('nodebuffer') } : null,
background: bgFile ? { name: bgFile.name, data: await bgFile.async('nodebuffer') } : null,
diffs,
skipped,
};
};
// "Artist - Title" for the item title
export const setTitle = (meta) => [meta.Artist || meta.ArtistUnicode, meta.Title || meta.TitleUnicode].filter(Boolean).join(' - ');
// Store converted difficulties as maps of an item (or album entry). A difficulty that is already
// there (same title and note count) is skipped, so importing a set twice adds nothing.
// -> { ids, skipped: [{ version, reason }] }
export const saveDiffs = async ({ itemId, albumItemId = null, userId, diffs, durationMs = 0 }) => {
const ids = [], skipped = [];
for (const d of diffs) {
const v = cleanNotes(d.notes, 0);
if (v.error) { skipped.push({ version: d.version, reason: v.error }); continue; }
const title = cleanTitle(importedTitle(d));
const [dupe] = await db`
SELECT id FROM sqc_maps
WHERE item_id = ${itemId} AND album_item_id IS NOT DISTINCT FROM ${albumItemId}::int
AND title = ${title} AND note_count = ${v.notes.length}
LIMIT 1
`;
if (dupe) { skipped.push({ version: d.version, reason: 'already imported' }); continue; }
const last = v.notes[v.notes.length - 1];
ids.push(await insertMap({ itemId, albumItemId, userId, title, notes: v.notes, durationMs: durationMs || last.t + last.d + 2000 }));
}
return { ids, skipped };
};
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "Kommentarfeld fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Flash-Yank ein-/ausschalten",
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
"open_settings": "Einstellungen öffnen/schließen",
"tag_exclude": "Tag-Ausschluss öffnen",
"tag_input": "Tag-Eingabe öffnen",
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "focus comment input",
"send_comment": "send comment",
"flash_yank": "enable/disable flash yank",
"square_clicker": "Square Clicker (audio / video items)",
"open_settings": "open/close settings",
"tag_exclude": "open tag exclude",
"tag_input": "open tag input",
+1
View File
@@ -297,6 +297,7 @@
"focus_comment": "focus op commentaarinvoer",
"send_comment": "opmerking verzenden",
"flash_yank": "flash yank in/uitschakelen",
"square_clicker": "Square Clicker (audio- / video-posts)",
"open_settings": "instellingen openen/sluiten",
"tag_exclude": "open tag uitsluiten",
"tag_input": "open tag invoer",
+1
View File
@@ -295,6 +295,7 @@
"focus_comment": "Kommentareingabe fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
"open_settings": "Einstellungen auf-/zuklappen",
"tag_exclude": "Etiketten-Ausschluss öffnen",
"tag_input": "Etiketten-Eingabe öffnen",
+189
View File
@@ -0,0 +1,189 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import audit from '../../audit.mjs';
import { isSquareClickerActive, cleanNotes, cleanTitle, canSeeItem, resolveTarget, insertMap } from '../../square_clicker.mjs';
import { convertOsu, saveDiffs } from '../../beatmap.mjs';
// Square Clicker API: user-made beatmaps for audio and video items and their scores.
// 404 when the feature is switched off (websrv.square_clicker_enabled: false, see inc/square_clicker.mjs).
// Maps of an album belong to one of its entries (album_item_id), since every entry is its own track.
const notFound = (res) => res.json({ success: false, msg: 'Not found' }, 404);
const bodyOf = (req) => req.body || req.post || {};
export default router => {
// List the maps of an item
router.get(/^\/api\/v2\/sqc\/maps\/?$/, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const itemId = parseInt(req.url.qs?.item_id, 10);
if (!itemId) return res.json({ success: false, msg: 'item_id required' }, 400);
const subId = parseInt(req.url.qs?.album_item_id, 10) || null;
const [item] = await db`SELECT id, username, COALESCE(visibility, 0) AS visibility FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
if (!item || !canSeeItem(item, req.session)) return notFound(res);
const maps = await db`
SELECT m.id, m.title, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id, m.album_item_id,
u.user AS username,
(SELECT MAX(s.score) FROM sqc_scores s WHERE s.map_id = m.id) AS best
FROM sqc_maps m
LEFT JOIN "user" u ON u.id = m.user_id
WHERE m.item_id = ${itemId} AND m.album_item_id IS NOT DISTINCT FROM ${subId}::int
ORDER BY m.created_at DESC
LIMIT 100
`;
return res.json({ success: true, maps });
});
// One map with its notes and top 10 scores
router.get(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`
SELECT m.id, m.item_id, m.album_item_id, m.title, m.notes, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id,
u.user AS username, i.username AS item_owner, COALESCE(i.visibility, 0) AS visibility
FROM sqc_maps m
JOIN items i ON i.id = m.item_id AND i.active = true AND i.is_deleted = false
LEFT JOIN "user" u ON u.id = m.user_id
WHERE m.id = ${id}
LIMIT 1
`;
if (!map || !canSeeItem({ visibility: map.visibility, username: map.item_owner }, req.session)) return notFound(res);
const scores = await db`
SELECT s.score, s.accuracy, s.max_combo, s.created_at, u.user AS username
FROM sqc_scores s
LEFT JOIN "user" u ON u.id = s.user_id
WHERE s.map_id = ${id}
ORDER BY s.score DESC, s.created_at ASC
LIMIT 10
`;
delete map.item_owner;
delete map.visibility;
return res.json({ success: true, map, scores });
});
// Create a map (logged in; audio and video items)
router.post(/^\/api\/v2\/sqc\/maps\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const b = bodyOf(req);
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
const duration = Math.max(0, Math.min(6 * 3600 * 1000, parseInt(b.duration_ms, 10) || 0));
const v = cleanNotes(b.notes, duration);
if (v.error) return res.json({ success: false, msg: v.error }, 400);
const id = await insertMap({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, title: b.title, notes: v.notes, durationMs: duration });
return res.json({ success: true, id });
});
// Import beatmap difficulties (.osu texts; the browser unpacks .osz sets and sends only these) as maps
// of an item or album entry. Standard mode only; other modes and invalid files are reported as skipped.
router.post(/^\/api\/v2\/sqc\/import\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const b = bodyOf(req);
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
const files = Array.isArray(b.files) ? b.files.slice(0, 40) : [];
if (!files.length) return res.json({ success: false, msg: 'No .osu files' }, 400);
if (files.reduce((sum, f) => sum + String((f && f.text) || '').length, 0) > 16 * 1024 * 1024) {
return res.json({ success: false, msg: 'Too large' }, 413);
}
const diffs = [], skipped = [];
for (const f of files) {
const d = convertOsu(String((f && f.text) || ''));
if (d.error) skipped.push({ version: d.version || String((f && f.name) || '?'), reason: d.error });
else diffs.push(d);
}
diffs.sort((a, z) => a.notes.length - z.notes.length);
const duration = Math.max(0, parseInt(b.duration_ms, 10) || 0);
const r = await saveDiffs({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, diffs, durationMs: duration });
return res.json({ success: true, imported: r.ids.length, ids: r.ids, skipped: [...skipped, ...r.skipped] });
});
// Edit a map (its creator, or staff: audited). Title and/or notes; changed notes clear the old scores,
// which were made on a different map.
router.put(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, item_id, user_id, title, duration_ms FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const isOwner = map.user_id === req.session.id;
const isStaff = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const b = bodyOf(req);
const upd = {};
if (b.title !== undefined) upd.title = cleanTitle(b.title);
let notesChanged = false;
if (b.notes !== undefined) {
const v = cleanNotes(b.notes, map.duration_ms);
if (v.error) return res.json({ success: false, msg: v.error }, 400);
upd.notes = db.json(v.notes);
upd.note_count = v.notes.length;
notesChanged = true;
}
if (!Object.keys(upd).length) return res.json({ success: false, msg: 'Nothing to change' }, 400);
await db`UPDATE sqc_maps SET ${db(upd)} WHERE id = ${id}`;
let scoresCleared = 0;
if (notesChanged) {
const r = await db`DELETE FROM sqc_scores WHERE map_id = ${id}`;
scoresCleared = r.count || 0;
await db`UPDATE sqc_maps SET plays = 0 WHERE id = ${id}`;
}
if (!isOwner) {
audit.log(req.session.id, 'sqc_map_edit', 'sqc_map', id, { item_id: map.item_id, title: upd.title || map.title, notes_changed: notesChanged }).catch(() => {});
}
return res.json({ success: true, id, scores_cleared: scoresCleared });
});
// Submit a score (logged in). Plausibility-checked against the map, not trusted blindly.
router.post(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/scores\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, note_count FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const b = bodyOf(req);
const hits = b.hits && typeof b.hits === 'object' ? b.hits : {};
const h = {
300: Math.max(0, parseInt(hits[300], 10) || 0),
100: Math.max(0, parseInt(hits[100], 10) || 0),
50: Math.max(0, parseInt(hits[50], 10) || 0),
miss: Math.max(0, parseInt(hits.miss, 10) || 0),
};
if (h[300] + h[100] + h[50] + h.miss !== map.note_count) {
return res.json({ success: false, msg: 'Score does not match the map' }, 400);
}
const maxCombo = Math.max(0, Math.min(map.note_count, parseInt(b.max_combo, 10) || 0));
const accuracy = map.note_count ? +(((h[300] * 300 + h[100] * 100 + h[50] * 50) / (map.note_count * 300)) * 100).toFixed(2) : 0;
// Upper bound: every note a 300 at full combo multiplier
const maxScore = map.note_count * 300 * (1 + map.note_count / 25);
const score = Math.max(0, Math.min(Math.round(maxScore), parseInt(b.score, 10) || 0));
await db`
INSERT INTO sqc_scores ${db({
map_id: id,
user_id: req.session.id,
score,
accuracy,
max_combo: maxCombo,
hits: db.json(h),
created_at: ~~(Date.now() / 1e3),
})}
`;
await db`UPDATE sqc_maps SET plays = plays + 1 WHERE id = ${id}`;
return res.json({ success: true, score, accuracy });
});
// Delete a map: its creator, or staff (audited)
router.delete(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, item_id, user_id, title FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const isOwner = map.user_id === req.session.id;
const isStaff = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
await db`DELETE FROM sqc_maps WHERE id = ${id}`;
if (!isOwner) {
audit.log(req.session.id, 'sqc_map_delete', 'sqc_map', id, { item_id: map.item_id, title: map.title }).catch(() => {});
}
return res.json({ success: true });
});
return router;
};
+91
View File
@@ -0,0 +1,91 @@
import cfg from './config.mjs';
import db from './sql.mjs';
// Square Clicker: audio / video items become playable rhythm-game maps. For everyone, on unless
// websrv.square_clicker_enabled is false. Callers may pass (date, session); both are ignored.
export const isSquareClickerActive = () => cfg.websrv?.square_clicker_enabled !== false;
export const MAX_NOTES = 3000;
export const MAX_HOLD_MS = 60000;
const MAX_TITLE = 80;
const MAX_PATH_POINTS = 1000; // per slider
const MAX_MAP_PATH_POINTS = 50000; // per map
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0));
export const cleanTitle = (t) => String(t || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE) || 'Untitled';
// Validate + normalise a notes array (used by create, edit and imports). -> { notes } or { error }
// Slider path: [[dt, x, y], ...] inside the hold (0..d), sorted by time; dropped if under 2 points
export const cleanNotes = (raw, duration) => {
if (!Array.isArray(raw) || raw.length < 1) return { error: 'A map needs at least one note' };
if (raw.length > MAX_NOTES) return { error: 'Too many notes (max ' + MAX_NOTES + ')' };
const cleanPath = (p, d) => {
if (!Array.isArray(p) || !d) return null;
const pts = p.slice(0, MAX_PATH_POINTS)
.filter(q => Array.isArray(q) && q.length >= 3)
.map(q => [Math.max(0, Math.min(d, Math.round(Number(q[0]) || 0))), +clamp01(q[1]).toFixed(4), +clamp01(q[2]).toFixed(4)])
.sort((a, z) => a[0] - z[0]);
return pts.length >= 2 ? pts : null;
};
const notes = raw
.map(n => {
const d = Math.max(0, Math.min(MAX_HOLD_MS, Math.round(Number(n && n.d) || 0)));
const note = { t: Math.max(0, Math.round(Number(n && n.t) || 0)), x: +clamp01(n && n.x).toFixed(4), y: +clamp01(n && n.y).toFixed(4), d };
const path = cleanPath(n && n.p, d);
if (path) note.p = path;
return note;
})
.filter(n => !duration || n.t <= duration + 1000)
.sort((a, z) => a.t - z.t);
if (!notes.length) return { error: 'No valid notes' };
if (notes.reduce((sum, n) => sum + (n.p ? n.p.length : 0), 0) > MAX_MAP_PATH_POINTS) return { error: 'Slider paths too long' };
return { notes };
};
// Can the session see this item (private items only for their owner and staff)
export const canSeeItem = (item, session) => {
if ((item.visibility || 0) !== 2) return true;
if (!session) return false;
return session.admin || session.is_moderator || (item.username || '').toLowerCase() === (session.user || '').toLowerCase();
};
// Audio and video files; YouTube items are iframes the game can't time or control
const playableMime = (mime) => {
mime = String(mime || '');
return (mime.startsWith('audio/') || mime.startsWith('video/')) && mime !== 'video/youtube';
};
// What a map belongs to: an item, or one entry of an album item (every entry is its own track).
// -> { item, albumItemId } or { error, code }. albumItemId is null for normal items.
export const resolveTarget = async (itemId, albumItemId, session) => {
itemId = parseInt(itemId, 10) || 0;
albumItemId = parseInt(albumItemId, 10) || null;
const [item] = await db`SELECT id, mime, username, COALESCE(visibility, 0) AS visibility, is_album FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
if (!item || !canSeeItem(item, session)) return { error: 'Not found', code: 404 };
if (albumItemId) {
const [sub] = await db`SELECT id, mime FROM album_items WHERE id = ${albumItemId} AND item_id = ${itemId} LIMIT 1`;
if (!sub) return { error: 'Not found', code: 404 };
if (!playableMime(sub.mime)) return { error: 'Maps are only for audio and video', code: 400 };
return { item, albumItemId: sub.id };
}
if (!playableMime(item.mime)) return { error: 'Maps are only for audio and video items', code: 400 };
return { item, albumItemId: null };
};
// Store a map (notes already validated with cleanNotes). -> id
export const insertMap = async ({ itemId, albumItemId = null, userId, title, notes, durationMs = 0 }) => {
const [row] = await db`
INSERT INTO sqc_maps ${db({
item_id: itemId,
album_item_id: albumItemId,
user_id: userId,
title: cleanTitle(title),
notes: db.json(notes),
note_count: notes.length,
duration_ms: Math.max(0, Math.min(6 * 3600 * 1000, Math.round(durationMs) || 0)),
created_at: ~~(Date.now() / 1e3),
})}
RETURNING id
`;
return row.id;
};
+37
View File
@@ -29,6 +29,7 @@ import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security, { getIpHashSource } from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { startRetention } from "./inc/retention.mjs";
import { isSquareClickerActive } from "./inc/square_clicker.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -649,6 +650,36 @@ process.on('uncaughtException', err => {
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`);
// Square Clicker: user-made beatmaps for audio and video items and their scores
await runMigration(db`
CREATE TABLE IF NOT EXISTS sqc_maps (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES "user"(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT 'Untitled',
notes JSONB NOT NULL DEFAULT '[]'::jsonb,
note_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
plays INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
)
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_sqc_maps_item ON sqc_maps(item_id, created_at DESC)`);
// Maps of one album entry (each entry is its own track)
await runMigration(db`ALTER TABLE sqc_maps ADD COLUMN IF NOT EXISTS album_item_id INTEGER REFERENCES album_items(id) ON DELETE CASCADE`);
await runMigration(db`
CREATE TABLE IF NOT EXISTS sqc_scores (
id SERIAL PRIMARY KEY,
map_id INTEGER NOT NULL REFERENCES sqc_maps(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES "user"(id) ON DELETE CASCADE,
score INTEGER NOT NULL DEFAULT 0,
accuracy REAL NOT NULL DEFAULT 0,
max_combo INTEGER NOT NULL DEFAULT 0,
hits JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at INTEGER NOT NULL DEFAULT 0
)
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON sqc_scores(map_id, score DESC)`);
// Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters
// all reference that id, and tags_assign has a FK on tags.id
@@ -1985,6 +2016,8 @@ process.on('uncaughtException', err => {
get rules_text_b64() { return Buffer.from(getRulesText() || '').toString('base64'); },
get terms_text_b64() { return Buffer.from(getTermsText() || '').toString('base64'); },
get halls() { return getHalls(); },
// Square Clicker: on unless websrv.square_clicker_enabled is false (resolved per request in render below)
square_clicker_active: false,
halls_enabled: cfg.websrv.halls_enabled !== false,
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
@@ -2234,6 +2267,9 @@ process.on('uncaughtException', err => {
data.user_logged_in_bool = !!activeReq.session;
data.csrf_token = activeReq.session?.csrf_token || data.csrf_token || '';
globals.csrf_token = data.csrf_token;
// Also on data: @include renders (the footer that loads square-clicker.js) run without a request and inherit it
data.square_clicker_active = isSquareClickerActive(new Date(), activeReq.session);
globals.square_clicker_active = data.square_clicker_active;
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
@@ -2247,6 +2283,7 @@ process.on('uncaughtException', err => {
data.user_strict_bool = false;
data.user_logged_in_bool = false;
globals.csrf_token = data.csrf_token || '';
globals.square_clicker_active = (data && typeof data.square_clicker_active === 'boolean') ? data.square_clicker_active : isSquareClickerActive();
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
data.onara = cfgOnara === true;
+66 -3
View File
@@ -13,6 +13,8 @@ import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
import { addPrivateItem } from "./inc/private_items.mjs";
import { resolveAuditIP, logAnonActivity } from "./inc/anon_auth.mjs";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "./inc/transcode.mjs";
import { isSquareClickerActive } from "./inc/square_clicker.mjs";
import { readOsz, saveDiffs, setTitle } from "./inc/beatmap.mjs";
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
@@ -205,7 +207,7 @@ export const handleUpload = async (req, res, self) => {
const tagsRaw = parts.tags;
const comment = parts.comment ? String(parts.comment).trim() : '';
const rawTitle = parts.title ? String(parts.title).trim() : '';
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
let title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
@@ -330,7 +332,9 @@ export const handleUpload = async (req, res, self) => {
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
mime = 'application/x-shockwave-flash';
}
if (!allowedMimes.includes(mime)) {
// Beatmap sets (.osz) come as octet-stream; they are unpacked into an audio item further down
const isBeatmapSet = isSquareClickerActive() && /\.osz$/i.test(file.filename || '');
if (!allowedMimes.includes(mime) && !isBeatmapSet) {
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
}
}
@@ -677,6 +681,33 @@ export const handleUpload = async (req, res, self) => {
// Verify actual MIME (second check after file-command detection)
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
// Beatmap set (a zip with .osu difficulties, usually .osz): the item becomes its song, the
// background its cover art, and every standard-mode difficulty a Square Clicker map
let beatmapSet = null;
if (isSquareClickerActive() && !is_album && /zip/.test(actualMime)) {
beatmapSet = await readOsz(await fs.readFile(tmpPath)).catch(e => { console.warn('[UPLOAD] Beatmap read failed:', e.message); return null; });
if (beatmapSet) {
const fail = (msg) => fs.unlink(tmpPath).catch(() => { }).then(() => sendJson(res, { success: false, msg }, 400));
if (!beatmapSet.audio) return fail('This beatmap set has no audio file.');
if (!beatmapSet.diffs.length) {
return fail('No playable difficulty in this beatmap set' + (beatmapSet.skipped.length ? ' (' + beatmapSet.skipped.map(d => d.version + ': ' + d.reason).join('; ') + ')' : '') + '.');
}
await fs.writeFile(tmpPath, beatmapSet.audio.data);
actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
// headerless mp3s are sometimes not recognised: trust the extension then
if (!actualMime.startsWith('audio/')) {
const ext = (beatmapSet.audio.name.split('.').pop() || '').toLowerCase();
actualMime = { mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4' }[ext] || actualMime;
}
if (!title) {
const t = setTitle(beatmapSet.meta);
if (t) title = t.substring(0, 500);
}
console.log(`[UPLOAD] Beatmap set: ${beatmapSet.diffs.length} difficulties, audio ${beatmapSet.audio.name} (${actualMime})`);
}
}
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
@@ -808,6 +839,14 @@ export const handleUpload = async (req, res, self) => {
if (repostBySum) {
await fs.unlink(tmpPath).catch(() => {});
// The song of a beatmap set is already here: add the difficulties to that item instead
if (beatmapSet) {
const [existing] = await db`SELECT id, mime FROM items WHERE id = ${repostBySum} AND is_deleted = false LIMIT 1`;
if (existing && /^audio\//.test(existing.mime || '')) {
const r = await saveDiffs({ itemId: existing.id, userId: req.session.id, diffs: beatmapSet.diffs });
return sendJson(res, { success: false, msg: `This song already exists: ${r.ids.length} beatmap difficult${r.ids.length === 1 ? 'y was' : 'ies were'} added to it`, repost: repostBySum, maps_added: r.ids.length }, 409);
}
}
return sendJson(res, { success: false, msg: 'This file already exists', repost: repostBySum }, 409);
}
if (repostByPhash) {
@@ -868,7 +907,7 @@ export const handleUpload = async (req, res, self) => {
// Insert
const size = file?.data ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
const size = (file?.data && !beatmapSet) ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
let originalFilename = file ? (file.filename || null) : null;
if (!originalFilename && inputUrl) {
try {
@@ -1161,6 +1200,30 @@ export const handleUpload = async (req, res, self) => {
}
}
if (beatmapSet) {
// Background as cover art (genThumbnail picks up an existing ca/<id>.webp for audio)
if (beatmapSet.background) {
const bgTmp = path.join(cfg.paths.tmp, `${itemid}_beatmap_bg.tmp`);
try {
await fs.writeFile(bgTmp, beatmapSet.background.data);
const cDir = isPending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
await queue.spawn('magick', [bgTmp + '[0]', '-resize', '1920x1920>', path.join(cDir, `${itemid}.webp`)]);
} catch (bgErr) {
console.warn(`[UPLOAD] Beatmap background failed for item ${itemid}:`, bgErr.message);
}
await fs.unlink(bgTmp).catch(() => {});
}
try {
const src = linkedToExisting ? path.join(manualApproval ? path.join(cfg.paths.pending, 'b') : cfg.paths.b, filename) : destPath;
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', src], { quiet: true, ignoreExitCode: true });
const durationMs = Math.round((parseFloat(probe.stdout) || 0) * 1000);
const r = await saveDiffs({ itemId: itemid, userId: req.session.id, diffs: beatmapSet.diffs, durationMs });
console.log(`[UPLOAD] Beatmap set: ${r.ids.length} maps for item ${itemid}` + (r.skipped.length ? `, skipped ${r.skipped.map(d => d.version + ': ' + d.reason).join('; ')}` : ''));
} catch (mapErr) {
console.error(`[UPLOAD] Beatmap maps failed for item ${itemid}:`, mapErr);
}
}
try {
if (!thumbProcessed) {
await queue.genThumbnail(filename, actualMime, itemid, '', isPending, dynThumbSize);
+3
View File
@@ -965,6 +965,9 @@
<script src="/s/js/sidebar-activity.js?v={{ ts }}"></script>
<script src="/s/js/flash_yank.js?v={{ ts }}"></script>
<script src="/s/js/laser-cursor.js?v={{ ts }}"></script>
@if(square_clicker_active)
<script src="/s/js/square-clicker.js?v={{ ts }}"></script>
@endif
@if(show_koepfe && !(session && session.hide_koepfe))
<script>window.f0ckKoepfe = {{ koepfe_json }};</script>
<script src="/s/js/koepfe.js?v={{ ts }}"></script>
+3
View File
@@ -1074,6 +1074,9 @@ async function loginWithPasskey() {
<div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>&#46;</kbd></span><span>{{ t('shortcuts.focus_comment') }}</span></div>
<div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>Enter</kbd></span><span>{{ t('shortcuts.send_comment') }}</span></div>
<div class="shortcut-item"><span><kbd>s</kbd></span><span>{{ t('shortcuts.flash_yank') }}</span></div>
@if(square_clicker_active)
<div class="shortcut-item"><span><kbd>o</kbd></span><span>{{ t('shortcuts.square_clicker') }}</span></div>
@endif
@if(session)
<div class="shortcut-item"><span><kbd>Shift</kbd> + <kbd>S</kbd></span><span>{{ t('shortcuts.open_settings') }}</span></div>
@endif
+2 -2
View File
@@ -1,4 +1,4 @@
<form id="upload-form" class="upload-form {{ shitpost_mode ? 'shitpost-mode-active' : '' }}" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}" data-min-tags="{{ min_tags }}" data-comment-max-length="{{ comment_max_length }}" data-enable-swf="{{ enable_swf ? '1' : '0' }}" data-enable-archive="{{ enable_archive ? '1' : '0' }}">
<form id="upload-form" class="upload-form {{ shitpost_mode ? 'shitpost-mode-active' : '' }}" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}" data-min-tags="{{ min_tags }}" data-comment-max-length="{{ comment_max_length }}" data-enable-swf="{{ enable_swf ? '1' : '0' }}" data-enable-archive="{{ enable_archive ? '1' : '0' }}" data-beatmaps="{{ square_clicker_active ? '1' : '0' }}">
<div class="form-section">
<div class="upload-mode-tabs">
<button type="button" class="upload-mode-tab active" data-mode="file">
@@ -20,7 +20,7 @@
<!-- File input area -->
<div class="upload-mode-content" id="mode-file">
<div class="drop-zone" id="upload-form-drop-zone">
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}" multiple>
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}@if(square_clicker_active),.osz@endif" multiple>
<div class="drop-zone-prompt">
<p class="drop-zone-title" style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }} <span class="drop-zone-max" style="font-size: 0.8em; font-weight: 400; opacity: 0.6;">(max {{ max_file_size }})</span>@if(session.admin) <span style="font-size: 0.8em; font-weight: 400; color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
</div>