1933 lines
53 KiB
Svelte
1933 lines
53 KiB
Svelte
<script>
|
|
import {tick, onMount} from 'svelte';
|
|
import {navigate} from 'svelte-routing';
|
|
import {fade, fly} from 'svelte/transition';
|
|
import createAccountStore from '../stores/beatleader/account';
|
|
import createPlaylistStore from '../stores/playlists';
|
|
import ssrConfig from '../ssr-config';
|
|
import Pager from '../components/Common/Pager.svelte';
|
|
import Spinner from '../components/Common/Spinner.svelte';
|
|
import ContentBox from '../components/Common/ContentBox.svelte';
|
|
import RangeSlider from 'svelte-range-slider-pips';
|
|
import {debounce} from '../utils/debounce';
|
|
import {formatNumber} from '../utils/format';
|
|
import Switcher from '../components/Common/Switcher.svelte';
|
|
import {
|
|
createBuildFiltersFromLocation,
|
|
buildSearchFromFiltersWithDefaults,
|
|
processFloatFilter,
|
|
processStringFilter,
|
|
processIntFilter,
|
|
processBoolFilter,
|
|
} from '../utils/filters';
|
|
import Button from '../components/Common/Button.svelte';
|
|
import DateRange from '../components/Common/DateRange.svelte';
|
|
import {dateFromUnix, DAY} from '../utils/date';
|
|
import {
|
|
typesDescription,
|
|
requirementsDescription,
|
|
typesMap,
|
|
DifficultyStatus,
|
|
requirementsMap,
|
|
modeDescriptions,
|
|
difficultyDescriptions,
|
|
songStatusesFilterMap,
|
|
songStatusesDescription,
|
|
} from '../utils/beatleader/format';
|
|
import {capitalize} from '../utils/js';
|
|
import {substituteVarsUrl} from '../utils/format';
|
|
import RankedTimer from '../components/Common/RankedTimer.svelte';
|
|
import {Ranked_Const, Unranked_Const} from '../utils/beatleader/consts';
|
|
import {MetaTags} from 'svelte-meta-tags';
|
|
import {BL_API_MAPS_URL, CURRENT_URL} from '../network/queues/beatleader/api-queue';
|
|
import BackToTop from '../components/Common/BackToTop.svelte';
|
|
import {configStore} from '../stores/config';
|
|
import {produce} from 'immer';
|
|
import Switch from '../components/Common/Switch.svelte';
|
|
import Select from '../components/Settings/Select.svelte';
|
|
import Mappers from '../components/Leaderboard/Mappers.svelte';
|
|
import MapCard from '../components/Maps/List/MapCard.svelte';
|
|
import TabSwitcher from '../components/Common/TabSwitcher.svelte';
|
|
import PlaylistPicker from '../components/Leaderboard/PlaylistPicker.svelte';
|
|
import {Svrollbar} from 'svrollbar';
|
|
import {PRIORITY} from '../utils/queue';
|
|
import {fetchJson} from '../network/fetch';
|
|
import AsideBox from '../components/Common/AsideBox.svelte';
|
|
import {SORT_BY_VALUES} from '../components/Maps/List/constants';
|
|
|
|
export let page = 1;
|
|
export let type = 'ranked';
|
|
export let location;
|
|
|
|
const FILTERS_DEBOUNCE_MS = 500;
|
|
|
|
document.body.classList.remove('slim');
|
|
|
|
const account = createAccountStore();
|
|
|
|
const playlists = createPlaylistStore();
|
|
|
|
const params = [
|
|
{key: 'search', default: '', process: processStringFilter},
|
|
{key: 'type', default: 'ranked', process: processStringFilter},
|
|
{key: 'mytype', default: '', process: processStringFilter},
|
|
{key: 'stars_from', default: undefined, process: processFloatFilter},
|
|
{key: 'stars_to', default: undefined, process: processFloatFilter},
|
|
{key: 'accrating_from', default: undefined, process: processFloatFilter},
|
|
{key: 'accrating_to', default: undefined, process: processFloatFilter},
|
|
{key: 'passrating_from', default: undefined, process: processFloatFilter},
|
|
{key: 'passrating_to', default: undefined, process: processFloatFilter},
|
|
{key: 'techrating_from', default: undefined, process: processFloatFilter},
|
|
{key: 'techrating_to', default: undefined, process: processFloatFilter},
|
|
{key: 'date_from', default: null, process: processIntFilter},
|
|
{key: 'date_to', default: null, process: processIntFilter},
|
|
{key: 'date_range', default: 'upload', process: processStringFilter},
|
|
{key: 'sortBy', default: null, process: processStringFilter},
|
|
{key: 'order', default: 'desc', process: processStringFilter},
|
|
{key: 'mode', default: null, process: processStringFilter},
|
|
{key: 'difficulty', default: null, process: processStringFilter},
|
|
{key: 'mapType', default: null, process: processIntFilter},
|
|
{key: 'allTypes', default: 0, process: processIntFilter},
|
|
{key: 'mapRequirements', default: null, process: processIntFilter},
|
|
{key: 'songStatus', default: null, process: processIntFilter},
|
|
{key: 'allRequirements', default: 0, process: processIntFilter},
|
|
{key: 'mappers', default: null, process: processStringFilter},
|
|
{key: 'playlistIds', default: null, process: processStringFilter},
|
|
];
|
|
|
|
const buildFiltersFromLocation = createBuildFiltersFromLocation(params, filters => {
|
|
if (filters.stars_from > filters.stars_to) {
|
|
const tmp = filters.stars_from;
|
|
filters.stars_from = filters.stars_to;
|
|
filters.stars_to = tmp;
|
|
}
|
|
|
|
if (!filters?.sortBy?.length)
|
|
filters.sortBy =
|
|
$configStore.mapsListOptions.defaultSortBy == 'last'
|
|
? ($configStore.mapsListOptions.lastSortBy ?? 'timestamp')
|
|
: $configStore.mapsListOptions.defaultSortBy;
|
|
if (!filters?.order?.length) filters.order = 'desc';
|
|
|
|
if (!filters.mapType) filters.mapType = null;
|
|
|
|
return filters;
|
|
});
|
|
|
|
if (page && !Number.isFinite(page)) page = parseInt(page, 10);
|
|
if (!page || isNaN(page) || page <= 0) page = 1;
|
|
|
|
let currentPage = page;
|
|
let previousPage = page > 1 ? page - 1 : page;
|
|
let currentType = type;
|
|
let currentFilters = buildFiltersFromLocation(location);
|
|
|
|
const typeFilterOptions = [
|
|
{key: 'all', label: 'All maps', iconFa: 'fa fa-music', color: 'var(--beatleader-primary)'},
|
|
{key: 'ost', label: 'OST', iconFa: 'fa fa-compact-disc', color: 'var(--beatleader-primary)'},
|
|
{key: 'nominated', label: 'Nominated', iconFa: 'fa fa-rocket', color: 'var(--beatleader-primary)'},
|
|
{key: 'qualified', label: 'Qualified', iconFa: 'fa fa-check', color: 'var(--beatleader-primary)'},
|
|
{key: 'ranked', label: 'Ranked', iconFa: 'fa fa-cubes', color: 'var(--beatleader-primary)'},
|
|
];
|
|
|
|
const baseMytypeFilterOptions = [
|
|
{key: '', label: 'All maps', iconFa: 'fa fa-music', color: 'var(--beatleader-primary)'},
|
|
{key: 'played', label: 'Played', iconFa: 'fa fa-user', color: 'var(--beatleader-primary)'},
|
|
{key: 'unplayed', label: 'Not played', iconFa: 'fa fa-times', color: 'var(--beatleader-primary)'},
|
|
{key: 'friendsPlayed', label: 'By friends', iconFa: 'fa fa-users', color: 'var(--beatleader-primary)'},
|
|
];
|
|
|
|
let mytypeFilterOptions = baseMytypeFilterOptions;
|
|
|
|
const categoryFilterOptions = Object.entries(typesMap).map(([key, type]) => {
|
|
return {
|
|
key: type,
|
|
label: capitalize(typesDescription?.[key]?.name ?? key),
|
|
icon: `<span class="${typesDescription?.[key]?.icon ?? `${key}-icon`}"></span>`,
|
|
color: typesDescription?.[key]?.color ?? 'var(--beatleader-primary',
|
|
textColor: typesDescription?.[key]?.textColor ?? null,
|
|
};
|
|
});
|
|
|
|
const requirementFilterOptions = Object.entries(requirementsDescription).map(([key, description]) => {
|
|
return {
|
|
key: requirementsMap[key],
|
|
label: capitalize(description?.name ?? key),
|
|
icon: `<span class="${description?.icon ?? `${key}-icon`}"></span>`,
|
|
color: description?.color ?? 'var(--beatleader-primary',
|
|
textColor: description?.textColor ?? null,
|
|
title: description?.title ?? null,
|
|
};
|
|
});
|
|
|
|
const songStatusOptions = Object.entries(songStatusesFilterMap).map(([key, type]) => {
|
|
return {
|
|
key: type,
|
|
label: capitalize(songStatusesDescription?.[key]?.name ?? key),
|
|
icon: `<span class="${songStatusesDescription?.[key]?.icon ?? `${key}-icon`}"></span>`,
|
|
color: songStatusesDescription?.[key]?.color ?? 'var(--beatleader-primary',
|
|
textColor: songStatusesDescription?.[key]?.textColor ?? null,
|
|
title: songStatusesDescription?.[key]?.title ?? null,
|
|
};
|
|
});
|
|
|
|
const modeNullPlaceholder = 'Any mode';
|
|
const modeFilterOptions = [
|
|
{
|
|
key: null,
|
|
label: modeNullPlaceholder,
|
|
},
|
|
].concat(
|
|
Object.entries(modeDescriptions).map(([key, type]) => {
|
|
return {
|
|
key,
|
|
label: capitalize(modeDescriptions?.[key]?.title ?? key),
|
|
icon: `<span class="${modeDescriptions?.[key]?.icon ?? `${key}-icon`}"></span>`,
|
|
color: modeDescriptions?.[key]?.color ?? 'var(--beatleader-primary',
|
|
textColor: modeDescriptions?.[key]?.textColor ?? null,
|
|
};
|
|
})
|
|
);
|
|
|
|
const difficultyNullPlaceholder = 'Any diff';
|
|
const difficultyFilterOptions = [
|
|
{
|
|
key: null,
|
|
label: difficultyNullPlaceholder,
|
|
},
|
|
]
|
|
.concat(
|
|
Object.entries(difficultyDescriptions).map(([key, type]) => {
|
|
return {
|
|
key,
|
|
label: capitalize(difficultyDescriptions?.[key]?.title ?? key),
|
|
icon: `<span class="${difficultyDescriptions?.[key]?.icon ?? `${key}-icon`}"></span>`,
|
|
color: difficultyDescriptions?.[key]?.color ?? 'var(--beatleader-primary',
|
|
textColor: difficultyDescriptions?.[key]?.textColor ?? null,
|
|
};
|
|
})
|
|
)
|
|
.concat({
|
|
key: 'fullspread',
|
|
label: 'Full Spread',
|
|
icon: `<span class="fullspread-icon"></span>`,
|
|
color: 'var(--beatleader-primary)',
|
|
textColor: null,
|
|
});
|
|
|
|
let numOfMaps = null;
|
|
let itemsPerPage = 12;
|
|
|
|
let isLoading = false;
|
|
let loadingPage = null;
|
|
|
|
let allMaps = [];
|
|
let activeRequests = {};
|
|
|
|
function resetCache(resetPages = true) {
|
|
if (resetPages) {
|
|
previousPage = 1;
|
|
currentPage = 1;
|
|
}
|
|
numOfMaps = 0;
|
|
allMaps = [];
|
|
|
|
// for keys in activeRequests
|
|
for (let i in activeRequests) {
|
|
if (activeRequests[i] && activeRequests[i].inProgress) {
|
|
activeRequests[i].controller.abort('resetCache');
|
|
}
|
|
}
|
|
|
|
activeRequests = {};
|
|
}
|
|
|
|
function populateMapsList(page = 1, type = 'ranked', filters = {}, priority = PRIORITY.FG_LOW, options = {}) {
|
|
if (activeRequests[page]) {
|
|
return;
|
|
}
|
|
|
|
// Create abort controller for this request
|
|
const controller = new AbortController();
|
|
const capturePage = page;
|
|
|
|
// Store the request info
|
|
activeRequests[page] = {
|
|
controller,
|
|
inProgress: true,
|
|
};
|
|
|
|
const fetchMaps = () => {
|
|
try {
|
|
fetch(
|
|
substituteVarsUrl(BL_API_MAPS_URL, {page, count: itemsPerPage, ...filters, type}, true, true),
|
|
{...options, credentials: 'include', signal: controller.signal},
|
|
priority
|
|
)
|
|
.then(d => {
|
|
if (d.status == 200) {
|
|
return d.json();
|
|
} else if (d.status === 429 && d.headers.get('retry-after')) {
|
|
const retryAfter = parseInt(d.headers.get('retry-after'));
|
|
setTimeout(() => {
|
|
if (activeRequests[capturePage] && activeRequests[capturePage].inProgress) {
|
|
fetchMaps();
|
|
}
|
|
}, retryAfter * 1000);
|
|
return {};
|
|
}
|
|
console.error('Error fetching maps:', d.status);
|
|
delete activeRequests[capturePage];
|
|
return {};
|
|
})
|
|
.then(response => {
|
|
let newMaps = response.data;
|
|
|
|
if (!newMaps) return;
|
|
for (let i = 0; i < newMaps.length; i++) {
|
|
newMaps[i].index = (capturePage - 1) * itemsPerPage + i;
|
|
}
|
|
|
|
for (let i = 0; i < allMaps.length; i++) {
|
|
const element = allMaps[i];
|
|
const fetchedMap = newMaps.find(m => m.index == element.index);
|
|
if (fetchedMap) {
|
|
if (allMaps[i].placeholder && allMaps[i].updateCallback) {
|
|
allMaps[i].updateCallback(fetchedMap);
|
|
}
|
|
allMaps[i] = fetchedMap;
|
|
}
|
|
}
|
|
|
|
numOfMaps = response.metadata.total;
|
|
|
|
if (allMaps.length > numOfMaps) {
|
|
allMaps = allMaps.slice(0, numOfMaps);
|
|
}
|
|
|
|
if (activeRequests[capturePage]) {
|
|
activeRequests[capturePage].inProgress = false;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
delete activeRequests[capturePage];
|
|
});
|
|
} catch (error) {
|
|
delete activeRequests[capturePage];
|
|
}
|
|
};
|
|
|
|
fetchMaps();
|
|
}
|
|
|
|
function fetchMaps(page = 1, type = 'ranked', filters = {}, priority = PRIORITY.FG_LOW, options = {}) {
|
|
if (allMaps.length < (page + 1) * itemsPerPage) {
|
|
while (allMaps.length < (page + 1) * itemsPerPage) {
|
|
allMaps.push({
|
|
index: allMaps.length,
|
|
name: 'Loading...',
|
|
artist: 'Unknown Artist',
|
|
hash: '00000000000000000000000000000000',
|
|
cover: 'https://via.placeholder.com/150',
|
|
placeholder: true,
|
|
});
|
|
}
|
|
|
|
allMaps = allMaps;
|
|
}
|
|
|
|
if (numOfMaps && allMaps.length > numOfMaps) {
|
|
allMaps = allMaps.slice(0, numOfMaps);
|
|
}
|
|
|
|
if (page > 1) {
|
|
populateMapsList(page - 1, type, filters, priority, options);
|
|
}
|
|
|
|
populateMapsList(page, type, filters, priority, options);
|
|
|
|
if (!numOfMaps || page < Math.ceil(numOfMaps / itemsPerPage)) {
|
|
populateMapsList(page + 1, type, filters, priority, options);
|
|
}
|
|
|
|
for (let i in activeRequests) {
|
|
if (i != page && i != page - 1 && i != page + 1 && activeRequests[i].inProgress) {
|
|
activeRequests[i].controller.abort('fetchMaps');
|
|
activeRequests[i].inProgress = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
let scrollChange = false;
|
|
|
|
function changePageAndFilters(newPage, newFilters, replace, setUrl = true) {
|
|
currentFilters = newFilters;
|
|
|
|
sortValue = currentFilters.sortBy;
|
|
orderValue = currentFilters.order;
|
|
dateRangeValue = currentFilters.date_range;
|
|
|
|
sortValues = sortValues1.map(v => {
|
|
let result = {...v};
|
|
if (result.value == 'timestamp') {
|
|
switch (currentType) {
|
|
case 'ranked':
|
|
result.name = 'Rank date';
|
|
result.title = 'Sort by the date map become ranked';
|
|
break;
|
|
case 'qualified':
|
|
result.name = 'Qualification date';
|
|
result.title = 'Sort by the map qualification date';
|
|
break;
|
|
case 'nominated':
|
|
result.name = 'Nomination date';
|
|
result.title = 'Sort by the map nomination date';
|
|
break;
|
|
|
|
default:
|
|
result.name = 'Upload date';
|
|
result.title = 'Sort by the map upload date';
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
switch (currentType) {
|
|
case 'ranked':
|
|
dateRangeOptions = [...dateRangeOptions1, {value: 'ranked', name: 'Map ranking', icon: 'fa-star'}];
|
|
break;
|
|
case 'qualified':
|
|
dateRangeOptions = [...dateRangeOptions1, {value: 'qualification', name: 'Map qualification', icon: 'fa-vote-yea'}];
|
|
break;
|
|
case 'nominated':
|
|
dateRangeOptions = [...dateRangeOptions1, {value: 'nomination', name: 'Map nomination', icon: 'fa-cheak-circle'}];
|
|
break;
|
|
default:
|
|
dateRangeOptions = dateRangeOptions1;
|
|
}
|
|
|
|
newPage = parseInt(newPage, 10);
|
|
if (isNaN(newPage)) newPage = 1;
|
|
|
|
currentPage = newPage;
|
|
|
|
if (setUrl) {
|
|
const query = buildSearchFromFiltersWithDefaults(currentFilters, params);
|
|
const url = `/maps/${currentType}/${currentPage}${query.length ? '?' + query : ''}`;
|
|
if (replace) {
|
|
window.history.replaceState({}, '', url);
|
|
} else {
|
|
window.history.pushState({}, '', url);
|
|
}
|
|
}
|
|
|
|
fetchMaps(currentPage, currentType, {...currentFilters});
|
|
|
|
if (!scrollChange) {
|
|
isAutoScrolling = true;
|
|
requestAnimationFrame(() => {
|
|
if (previousPageAnchor && currentPage > 1) {
|
|
const newPosition = previousPageAnchor.offsetTop - 20;
|
|
safeScrollTo({top: newPosition, behavior: 'instant'});
|
|
} else {
|
|
safeScrollTo({top: 0, behavior: 'instant'});
|
|
}
|
|
});
|
|
}
|
|
|
|
scrollChange = false;
|
|
}
|
|
|
|
function navigateToCurrentPageAndFilters(replaceState) {
|
|
changePageAndFilters(currentPage, currentFilters, replaceState);
|
|
}
|
|
|
|
function onPageChanged(event) {
|
|
if (event.detail.initial || !Number.isFinite(event.detail.page)) return;
|
|
|
|
previousPage = currentPage;
|
|
currentPage = event.detail.page + 1;
|
|
|
|
navigateToCurrentPageAndFilters(true);
|
|
}
|
|
|
|
function onSearchChanged(e) {
|
|
var search = e.target.value ?? '';
|
|
|
|
if (search.length > 0 && search.length < 2) return;
|
|
|
|
currentFilters.search = search;
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onTypeChanged(event) {
|
|
if (!event?.detail) return;
|
|
|
|
currentType = event.detail.key ?? '';
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
async function onCategoryModeChanged() {
|
|
await tick();
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onCategoryChanged(event) {
|
|
if (!event?.detail?.key) return;
|
|
|
|
if (!currentFilters.mapType) currentFilters.mapType = 0;
|
|
|
|
if (currentFilters.mapType & event.detail.key) currentFilters.mapType &= currentFilters.mapType ^ event.detail.key;
|
|
else currentFilters.mapType |= event.detail.key;
|
|
|
|
if (!currentFilters.mapType) currentFilters.mapType = null;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onRequirementsChanged(event) {
|
|
if (!event?.detail?.key) return;
|
|
|
|
if (!currentFilters.mapRequirements) currentFilters.mapRequirements = 0;
|
|
|
|
if (currentFilters.mapRequirements & event.detail.key)
|
|
currentFilters.mapRequirements &= currentFilters.mapRequirements ^ event.detail.key;
|
|
else currentFilters.mapRequirements |= event.detail.key;
|
|
|
|
if (!currentFilters.mapRequirements) currentFilters.mapRequirements = null;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onSongStatusChanged(event) {
|
|
if (!event?.detail?.key) return;
|
|
|
|
if (!currentFilters.songStatus) currentFilters.songStatus = 0;
|
|
|
|
if (currentFilters.songStatus & event.detail.key) currentFilters.songStatus &= currentFilters.songStatus ^ event.detail.key;
|
|
else currentFilters.songStatus |= event.detail.key;
|
|
|
|
if (!currentFilters.songStatus) currentFilters.songStatus = null;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onMyTypeChanged(event) {
|
|
if (!event?.detail) return;
|
|
|
|
currentFilters.mytype = event.detail.key ?? '';
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
async function onModeChanged(event) {
|
|
await tick();
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
async function onDifficultyChanged(event) {
|
|
await tick();
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function starsChanged() {
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onStarsChanged(event, ratingType) {
|
|
if (!Array.isArray(event?.detail?.values) || event.detail.values.length !== 2) return;
|
|
|
|
if (sliderLimits.MIN_STARS != event.detail.values[0] || Number.isFinite(currentFilters[ratingType + '_from'])) {
|
|
currentFilters[ratingType + '_from'] = Number.isFinite(event.detail.values[0]) ? event.detail.values[0] : undefined;
|
|
}
|
|
|
|
if (sliderLimits.MAX_STARS != event.detail.values[1] || Number.isFinite(currentFilters[ratingType + '_to'])) {
|
|
currentFilters[ratingType + '_to'] = Number.isFinite(event.detail.values[1]) ? event.detail.values[1] : undefined;
|
|
}
|
|
starsChanged();
|
|
}
|
|
const debouncedOnStarsChanged = debounce(onStarsChanged, FILTERS_DEBOUNCE_MS);
|
|
|
|
let sortValues1 = SORT_BY_VALUES;
|
|
let sortValues = sortValues1;
|
|
let sortValue = sortValues[0].value;
|
|
|
|
function onSortChange(event) {
|
|
if (!event?.detail?.value || event.detail.value == currentFilters.sortBy) return null;
|
|
|
|
currentFilters.sortBy = event.detail.value;
|
|
|
|
$configStore = produce($configStore, draft => {
|
|
draft.mapsListOptions.lastSortBy = event.detail.value;
|
|
});
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
let orderValues = [
|
|
{value: 'asc', name: 'Ascending', icon: 'fa-arrow-up'},
|
|
{value: 'desc', name: 'Descending', icon: 'fa-arrow-down'},
|
|
];
|
|
let orderValue = orderValues[0].value;
|
|
|
|
function onOrderChange(event) {
|
|
if (!event?.detail?.value || event.detail.value == currentFilters.order) return null;
|
|
|
|
currentFilters.order = event.detail.value;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
let dateRangeOptions1 = [
|
|
{value: 'upload', name: 'Map upload', icon: 'fa-upload'},
|
|
{value: 'score', name: 'Recent score', icon: 'fa-calculator'},
|
|
];
|
|
|
|
let dateRangeOptions = dateRangeOptions1;
|
|
|
|
let dateRangeValue = dateRangeOptions[0].value;
|
|
|
|
function onDateRangeChanged(event) {
|
|
if (!event?.detail?.value || event.detail.value == currentFilters.date_range) return null;
|
|
|
|
currentFilters.date_range = event.detail.value;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
function onDateRangeChange(event) {
|
|
if (!event?.detail) return;
|
|
|
|
currentFilters.date_from = event.detail?.from ? parseInt(event.detail.from.getTime() / 1000) : null;
|
|
currentFilters.date_to = event.detail?.to ? parseInt(event.detail.to.getTime() / 1000) : null;
|
|
|
|
currentFilters = currentFilters;
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onMappersChange(event) {
|
|
currentFilters.mappers = event.detail.join(',');
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
function onPlaylistIdsChange(event) {
|
|
currentFilters.playlistIds = event.detail.join(',');
|
|
|
|
resetCache();
|
|
|
|
navigateToCurrentPageAndFilters();
|
|
}
|
|
|
|
var showAllRatings = false;
|
|
|
|
function updateProfileSettings(account) {
|
|
if (account?.player?.profileSettings) {
|
|
showAllRatings = account.player.profileSettings.showAllRatings;
|
|
}
|
|
}
|
|
|
|
const debouncedOnDateRangeChanged = debounce(onDateRangeChange, FILTERS_DEBOUNCE_MS);
|
|
|
|
let searchToPlaylist = false;
|
|
let makingPlaylist = false;
|
|
let mapCount = 100;
|
|
let duplicateDiffs = false;
|
|
let playlistTitle = 'Search result';
|
|
function generatePlaylist() {
|
|
makingPlaylist = true;
|
|
|
|
playlists.generatePlaylist(mapCount, {...currentFilters, duplicateDiffs, playlistTitle}, () => {
|
|
navigate('/playlists');
|
|
});
|
|
}
|
|
|
|
function generateMetaTitle() {
|
|
let title = '';
|
|
|
|
// Base title by type
|
|
if (currentType === 'ranked') title = 'Ranked Maps';
|
|
else if (currentType === 'qualified') title = 'Qualified Maps';
|
|
else if (currentType === 'nominated') title = 'Nominated Maps';
|
|
else if (currentType === 'ost') title = 'OST Maps';
|
|
else title = 'Maps';
|
|
|
|
// Add search term if present
|
|
if (currentFilters.search) {
|
|
title = `"${currentFilters.search}" in ${title}`;
|
|
}
|
|
|
|
// Add star rating if present
|
|
if (currentFilters.stars_from && currentFilters.stars_to) {
|
|
title += ` (${formatNumber(currentFilters.stars_from, 1)}★-${formatNumber(currentFilters.stars_to, 1)}★)`;
|
|
} else if (currentFilters.stars_from) {
|
|
title += ` (${formatNumber(currentFilters.stars_from, 1)}★+)`;
|
|
} else if (currentFilters.stars_to) {
|
|
title += ` (up to ${formatNumber(currentFilters.stars_to, 1)}★)`;
|
|
}
|
|
|
|
return title;
|
|
}
|
|
|
|
function generateMetaDescription() {
|
|
let description = '';
|
|
|
|
// Base description by type
|
|
if (currentType === 'ranked') description = 'List of ranked Beat Saber maps';
|
|
else if (currentType === 'qualified') description = 'List of qualified Beat Saber maps';
|
|
else if (currentType === 'nominated') description = 'List of nominated Beat Saber maps';
|
|
else if (currentType === 'ost') description = 'List of Beat Saber OST maps';
|
|
else description = 'Search for Beat Saber maps';
|
|
|
|
// Build filter descriptions
|
|
let filters = [];
|
|
|
|
// Date range
|
|
if (currentFilters.date_from && currentFilters.date_to) {
|
|
const fromDate = dateFromUnix(currentFilters.date_from);
|
|
const toDate = dateFromUnix(currentFilters.date_to);
|
|
filters.push(`from ${fromDate.toLocaleDateString()} to ${toDate.toLocaleDateString()}`);
|
|
} else if (currentFilters.date_from) {
|
|
const fromDate = dateFromUnix(currentFilters.date_from);
|
|
filters.push(`after ${fromDate.toLocaleDateString()}`);
|
|
} else if (currentFilters.date_to) {
|
|
const toDate = dateFromUnix(currentFilters.date_to);
|
|
filters.push(`before ${toDate.toLocaleDateString()}`);
|
|
}
|
|
|
|
// Star rating
|
|
if (currentFilters.stars_from && currentFilters.stars_to) {
|
|
filters.push(
|
|
`with star rating between ${formatNumber(currentFilters.stars_from, 1)} and ${formatNumber(currentFilters.stars_to, 1)}`
|
|
);
|
|
} else if (currentFilters.stars_from) {
|
|
filters.push(`with star rating above ${formatNumber(currentFilters.stars_from, 1)}`);
|
|
} else if (currentFilters.stars_to) {
|
|
filters.push(`with star rating below ${formatNumber(currentFilters.stars_to, 1)}`);
|
|
}
|
|
|
|
// Difficulty
|
|
if (currentFilters.difficulty) {
|
|
const difficultyName = difficultyFilterOptions.find(d => d.key === currentFilters.difficulty)?.label;
|
|
if (difficultyName) filters.push(`on ${difficultyName} difficulty`);
|
|
}
|
|
|
|
// Mode
|
|
if (currentFilters.mode) {
|
|
const modeName = modeFilterOptions.find(m => m.key === currentFilters.mode)?.label;
|
|
if (modeName) filters.push(`in ${modeName} mode`);
|
|
}
|
|
|
|
// Search term
|
|
if (currentFilters.search) {
|
|
filters.push(`matching "${currentFilters.search}"`);
|
|
}
|
|
|
|
// Add filters to description
|
|
if (filters.length > 0) {
|
|
description += ' ' + filters.join(', ');
|
|
}
|
|
|
|
return description;
|
|
}
|
|
|
|
$: updateProfileSettings($account);
|
|
|
|
$: changePageAndFilters(page, buildFiltersFromLocation(location), false, false);
|
|
|
|
$: starsKey =
|
|
currentFilters.sortBy == 'accRating' || currentFilters.sortBy == 'passRating' || currentFilters.sortBy == 'techRating'
|
|
? currentFilters.sortBy
|
|
: 'stars';
|
|
|
|
$: metaTitle = generateMetaTitle();
|
|
$: metaDescription = generateMetaDescription();
|
|
$: hasRatingsByDefault = currentType === 'ranked' || currentType === 'nominated' || currentType === 'qualified';
|
|
$: starFiltersDisabled = !hasRatingsByDefault && !showAllRatings;
|
|
$: sliderLimits = hasRatingsByDefault ? Ranked_Const : Unranked_Const;
|
|
|
|
let previousPageAnchor;
|
|
let currentPageAnchor;
|
|
|
|
let scrollContainer = document.documentElement || document.body;
|
|
let asideContainer;
|
|
function scrollToPage(page) {
|
|
previousPage = currentPage;
|
|
currentPage = page + 1;
|
|
scrollChange = true;
|
|
|
|
navigateToCurrentPageAndFilters(true);
|
|
}
|
|
|
|
let isAutoScrolling = false;
|
|
|
|
function safeScrollTo(options) {
|
|
isAutoScrolling = true;
|
|
scrollContainer.style.scrollBehavior = options.behavior || 'smooth';
|
|
scrollContainer.scrollTo(options);
|
|
requestAnimationFrame(() => {
|
|
setTimeout(() => {
|
|
isAutoScrolling = false;
|
|
scrollContainer.style.scrollBehavior = 'auto';
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
function onScroll() {
|
|
if (isAutoScrolling) return;
|
|
|
|
const containerTop = window.scrollY;
|
|
if (containerTop < 100) {
|
|
scrollToPage(0);
|
|
return;
|
|
}
|
|
|
|
const containerBottom = containerTop + window.innerHeight;
|
|
|
|
// Check if current and previous anchors are outside visible area
|
|
const currentNotVisible =
|
|
!currentPageAnchor ||
|
|
currentPageAnchor.getBoundingClientRect().top > window.innerHeight ||
|
|
currentPageAnchor.getBoundingClientRect().bottom < 0;
|
|
|
|
const previousNotVisible =
|
|
!previousPageAnchor ||
|
|
previousPageAnchor.getBoundingClientRect().top > window.innerHeight ||
|
|
previousPageAnchor.getBoundingClientRect().bottom < 0;
|
|
|
|
if (!previousNotVisible && previousPageAnchor.getBoundingClientRect().top > window.innerHeight / 2 && currentPage > 1) {
|
|
scrollToPage(currentPage - 2);
|
|
} else if (!currentNotVisible && currentPageAnchor.getBoundingClientRect().top < window.innerHeight / 2) {
|
|
scrollToPage(currentPage);
|
|
} else if (currentNotVisible && previousNotVisible) {
|
|
// If neither anchor is visible, look for other page anchors
|
|
let otherAnchors = Array.from(document.querySelectorAll('.other-page-anchor')).sort(
|
|
(a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top
|
|
);
|
|
|
|
// First try to find visible anchors
|
|
let foundVisibleAnchor = false;
|
|
for (const anchor of otherAnchors) {
|
|
const rect = anchor.getBoundingClientRect();
|
|
if (rect.top >= 0 && rect.bottom <= window.innerHeight) {
|
|
const page = parseInt(anchor.textContent);
|
|
if (!isNaN(page)) {
|
|
scrollToPage(page - 1);
|
|
foundVisibleAnchor = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no visible anchors found, find closest anchor below viewport
|
|
if (!foundVisibleAnchor) {
|
|
let closestAnchor = null;
|
|
let closestDistance = Infinity;
|
|
|
|
if (previousPageAnchor) {
|
|
otherAnchors.push(previousPageAnchor);
|
|
}
|
|
|
|
if (currentPageAnchor) {
|
|
otherAnchors.push(currentPageAnchor);
|
|
}
|
|
|
|
for (const anchor of otherAnchors) {
|
|
const rect = anchor.getBoundingClientRect();
|
|
const distance = Math.abs(rect.bottom - window.innerHeight);
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestAnchor = anchor;
|
|
}
|
|
}
|
|
|
|
if (closestAnchor && closestAnchor.getBoundingClientRect().bottom < 0) {
|
|
const page = parseInt(closestAnchor.textContent);
|
|
if (!isNaN(page)) {
|
|
scrollToPage(page);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const now = Date.now() / 1000;
|
|
const today = dateFromUnix(now - 60 * 60 * 24);
|
|
const lastWeek = dateFromUnix(now - 60 * 60 * 24 * 7);
|
|
const lastYear = dateFromUnix(now - 60 * 60 * 24 * 365);
|
|
|
|
let isDateFilterOpen = !!(currentFilters.date_from || currentFilters.date_to);
|
|
let isCategoryFilterOpen = !!currentFilters.mapType;
|
|
let isRequirementsFilterOpen = !!currentFilters.mapRequirements;
|
|
let isStarsFilterOpen = !!(
|
|
currentFilters.stars_from ||
|
|
currentFilters.stars_to ||
|
|
currentFilters.accrating_from ||
|
|
currentFilters.accrating_to ||
|
|
currentFilters.passrating_from ||
|
|
currentFilters.passrating_to ||
|
|
currentFilters.techrating_from ||
|
|
currentFilters.techrating_to
|
|
);
|
|
|
|
let isPlaylistOpen = false;
|
|
let lastY = null;
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Maps / {currentPage} - {ssrConfig.name}</title>
|
|
</svelte:head>
|
|
|
|
<svelte:window on:scroll={onScroll} />
|
|
|
|
<section class="align-content">
|
|
<article class="page-content" transition:fade|global>
|
|
<div class="maps-box">
|
|
{#if allMaps?.length}
|
|
<div class="songs" class:long={$configStore.mapCards.wideCards}>
|
|
{#each allMaps as song, idx (song.index)}
|
|
{@const page = Math.floor(idx / itemsPerPage)}
|
|
{#if idx == 0}
|
|
<div class="first-page-spacer"></div>
|
|
{:else if idx % itemsPerPage == 0}
|
|
{#if page == currentPage - 1}
|
|
<div class="page-split page-maker-{currentPage - 1}" bind:this={previousPageAnchor}>
|
|
{currentPage - 1}
|
|
</div>
|
|
{:else if page == currentPage}
|
|
<div class="page-split page-maker-{currentPage}" bind:this={currentPageAnchor}>
|
|
{currentPage}
|
|
</div>
|
|
{:else}
|
|
<div class="page-split page-maker-{page} other-page-anchor">
|
|
{page}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
<MapCard
|
|
map={song}
|
|
{starsKey}
|
|
forcePlaceholder={currentPage != page && currentPage - 1 != page && currentPage - 2 != page}
|
|
sortBy={currentFilters.sortBy}
|
|
dateType={currentType} />
|
|
{/each}
|
|
</div>
|
|
{:else if isLoading}
|
|
<Spinner />
|
|
{:else}
|
|
<div class="no-maps-found">
|
|
<p>Can't find any maps.</p>
|
|
<a href="https://bsmg.wiki/mapping/">Try making a new one!</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</article>
|
|
|
|
<aside
|
|
class="maps-aside-container"
|
|
class:long={$configStore.mapCards.wideCards}
|
|
on:wheel={e => {
|
|
if (scrollContainer && !$configStore.preferences.mapsFiltersOpen) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
|
|
scrollContainer.scrollTop += e.deltaY;
|
|
scrollContainer.scrollLeft += e.deltaX;
|
|
}
|
|
}}
|
|
on:touchstart={e => {
|
|
lastY = e.touches[0].clientY;
|
|
}}
|
|
on:touchmove={e => {
|
|
if (scrollContainer && !$configStore.preferences.mapsFiltersOpen && e.touches.length > 0 && lastY !== null) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
|
|
const newY = e.touches[0].clientY;
|
|
const delta = lastY - newY;
|
|
scrollContainer.scrollTop += delta;
|
|
lastY = newY;
|
|
}
|
|
}}
|
|
on:touchend={() => (lastY = null)}
|
|
on:touchcancel={() => (lastY = null)}
|
|
bind:this={asideContainer}>
|
|
<AsideBox title="Filters" boolname={window?.innerWidth < 767 ? 'mapsFiltersOpenMobile' : 'mapsFiltersOpen'} faicon="fas fa-filter">
|
|
<div class="sorting-options">
|
|
<Select bind:value={sortValue} on:change={onSortChange} fontSize="0.8" options={sortValues} />
|
|
<Select bind:value={orderValue} on:change={onOrderChange} fontSize="0.8" options={orderValues} />
|
|
</div>
|
|
<section class="filter">
|
|
<input
|
|
on:input={debounce(onSearchChanged, FILTERS_DEBOUNCE_MS)}
|
|
type="text"
|
|
class="search"
|
|
placeholder="Search for a map(Song/Author/Hash)..."
|
|
value={currentFilters.search} />
|
|
</section>
|
|
|
|
<section class="filter">
|
|
<Mappers
|
|
currentMapperId={$account.player && $account.player.playerInfo.mapperId}
|
|
mapperIds={currentFilters.mappers?.split(',').map(id => parseInt(id)) ?? []}
|
|
on:change={e => onMappersChange(e)} />
|
|
</section>
|
|
|
|
<section class="filter">
|
|
<PlaylistPicker
|
|
playlistIds={(currentFilters.playlistIds?.length && currentFilters.playlistIds?.split(',')) ?? []}
|
|
on:change={e => onPlaylistIdsChange(e)} />
|
|
</section>
|
|
|
|
<section class="filter">
|
|
<Switcher values={typeFilterOptions} value={typeFilterOptions.find(o => o.key === currentType)} on:change={onTypeChanged} />
|
|
</section>
|
|
|
|
{#if $account.id}
|
|
<section class="filter">
|
|
<Switcher
|
|
values={mytypeFilterOptions}
|
|
value={mytypeFilterOptions.find(o => o.key === currentFilters.mytype)}
|
|
on:change={onMyTypeChanged} />
|
|
</section>
|
|
{/if}
|
|
|
|
<section class="filter">
|
|
<Switcher
|
|
values={songStatusOptions}
|
|
value={songStatusOptions.filter(c => currentFilters.songStatus & c.key)}
|
|
multi={true}
|
|
on:change={onSongStatusChanged} />
|
|
</section>
|
|
|
|
<section
|
|
class="filter dropdown-filter"
|
|
class:has-value={!!(
|
|
currentFilters.stars_from ||
|
|
currentFilters.stars_to ||
|
|
currentFilters.accrating_from ||
|
|
currentFilters.accrating_to ||
|
|
currentFilters.passrating_from ||
|
|
currentFilters.passrating_to ||
|
|
currentFilters.techrating_from ||
|
|
currentFilters.techrating_to
|
|
)}>
|
|
<div class="dropdown-header" on:click={() => (isStarsFilterOpen = !isStarsFilterOpen)}>
|
|
<div class="header-content">
|
|
<i class="fas fa-star" />
|
|
<span>Ratings</span>
|
|
</div>
|
|
<i class="fas fa-chevron-{isStarsFilterOpen ? 'up' : 'down'}" />
|
|
</div>
|
|
|
|
{#if isStarsFilterOpen}
|
|
<div class="dropdown-content" transition:fade>
|
|
<section class="filter" class:disabled={starFiltersDisabled}>
|
|
<label>
|
|
Stars
|
|
<span>{formatNumber(currentFilters.stars_from, 2, false, 'Any')}<sup>★</sup></span> to
|
|
<span>{formatNumber(currentFilters.stars_to, 2, false, 'Any')}<sup>★</sup></span>
|
|
{#if currentFilters.stars_from || currentFilters.stars_to}
|
|
<button
|
|
class="remove-type"
|
|
title="Remove"
|
|
on:click={() => {
|
|
currentFilters.stars_from = null;
|
|
currentFilters.stars_to = null;
|
|
starsChanged();
|
|
}}><i class="fas fa-xmark" /></button>
|
|
{/if}
|
|
</label>
|
|
<RangeSlider
|
|
range
|
|
min={sliderLimits.MIN_STARS}
|
|
max={sliderLimits.MAX_STARS}
|
|
step={sliderLimits.STAR_GRANULARITY}
|
|
values={[
|
|
Number.isFinite(currentFilters.stars_from) ? currentFilters.stars_from : Number.NEGATIVE_INFINITY,
|
|
Number.isFinite(currentFilters.stars_to) ? currentFilters.stars_to : Number.POSITIVE_INFINITY,
|
|
]}
|
|
float
|
|
hoverable
|
|
pips
|
|
pipstep={sliderLimits.STAR_STEP}
|
|
all="label"
|
|
on:change={e => debouncedOnStarsChanged(e, 'stars')}
|
|
disabled={starFiltersDisabled} />
|
|
</section>
|
|
|
|
<section class="filter" class:disabled={starFiltersDisabled}>
|
|
<label>
|
|
Acc rating
|
|
<span>{formatNumber(currentFilters.accrating_from, 2, false, 'Any')}<sup>★</sup></span> to
|
|
<span>{formatNumber(currentFilters.accrating_to, 2, false, 'Any')}<sup>★</sup></span>
|
|
{#if currentFilters.accrating_from || currentFilters.accrating_to}
|
|
<button
|
|
class="remove-type"
|
|
title="Remove"
|
|
on:click={() => {
|
|
currentFilters.accrating_from = null;
|
|
currentFilters.accrating_to = null;
|
|
starsChanged();
|
|
}}><i class="fas fa-xmark" /></button>
|
|
{/if}
|
|
</label>
|
|
<RangeSlider
|
|
range
|
|
min={sliderLimits.MIN_STARS}
|
|
max={sliderLimits.MAX_STARS}
|
|
step={sliderLimits.STAR_GRANULARITY}
|
|
values={[
|
|
Number.isFinite(currentFilters.accrating_from) ? currentFilters.accrating_from : Number.NEGATIVE_INFINITY,
|
|
Number.isFinite(currentFilters.accrating_to) ? currentFilters.accrating_to : Number.POSITIVE_INFINITY,
|
|
]}
|
|
float
|
|
hoverable
|
|
pips
|
|
pipstep={sliderLimits.STAR_STEP}
|
|
all="label"
|
|
on:change={e => debouncedOnStarsChanged(e, 'accrating')}
|
|
disabled={starFiltersDisabled} />
|
|
</section>
|
|
|
|
<section class="filter" class:disabled={starFiltersDisabled}>
|
|
<label>
|
|
Pass rating
|
|
<span>{formatNumber(currentFilters.passrating_from, 2, false, 'Any')}<sup>★</sup></span> to
|
|
<span>{formatNumber(currentFilters.passrating_to, 2, false, 'Any')}<sup>★</sup></span>
|
|
{#if currentFilters.passrating_from || currentFilters.passrating_to}
|
|
<button
|
|
class="remove-type"
|
|
title="Remove"
|
|
on:click={() => {
|
|
currentFilters.passrating_from = null;
|
|
currentFilters.passrating_to = null;
|
|
starsChanged();
|
|
}}><i class="fas fa-xmark" /></button>
|
|
{/if}
|
|
</label>
|
|
<RangeSlider
|
|
range
|
|
min={sliderLimits.MIN_STARS}
|
|
max={sliderLimits.MAX_STARS}
|
|
step={sliderLimits.STAR_GRANULARITY}
|
|
values={[
|
|
Number.isFinite(currentFilters.passrating_from) ? currentFilters.passrating_from : Number.NEGATIVE_INFINITY,
|
|
Number.isFinite(currentFilters.passrating_to) ? currentFilters.passrating_to : Number.POSITIVE_INFINITY,
|
|
]}
|
|
float
|
|
hoverable
|
|
pips
|
|
pipstep={sliderLimits.STAR_STEP}
|
|
all="label"
|
|
on:change={e => debouncedOnStarsChanged(e, 'passrating')}
|
|
disabled={starFiltersDisabled} />
|
|
</section>
|
|
|
|
<section class="filter" class:disabled={starFiltersDisabled}>
|
|
<label>
|
|
Tech rating
|
|
<span>{formatNumber(currentFilters.techrating_from, 2, false, 'Any')}<sup>★</sup></span> to
|
|
<span>{formatNumber(currentFilters.techrating_to, 2, false, 'Any')}<sup>★</sup></span>
|
|
{#if currentFilters.techrating_from || currentFilters.techrating_to}
|
|
<button
|
|
class="remove-type"
|
|
title="Remove"
|
|
on:click={() => {
|
|
currentFilters.techrating_from = null;
|
|
currentFilters.techrating_to = null;
|
|
starsChanged();
|
|
}}><i class="fas fa-xmark" /></button>
|
|
{/if}
|
|
</label>
|
|
<RangeSlider
|
|
range
|
|
min={sliderLimits.MIN_STARS}
|
|
max={sliderLimits.MAX_STARS}
|
|
step={sliderLimits.STAR_GRANULARITY}
|
|
values={[
|
|
Number.isFinite(currentFilters.techrating_from) ? currentFilters.techrating_from : Number.NEGATIVE_INFINITY,
|
|
Number.isFinite(currentFilters.techrating_to) ? currentFilters.techrating_to : Number.POSITIVE_INFINITY,
|
|
]}
|
|
float
|
|
hoverable
|
|
pips
|
|
pipstep={sliderLimits.STAR_STEP}
|
|
all="label"
|
|
on:change={e => debouncedOnStarsChanged(e, 'techrating')}
|
|
disabled={starFiltersDisabled} />
|
|
</section>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="filter dropdown-filter" class:has-value={!!(currentFilters.date_from || currentFilters.date_to)}>
|
|
<div class="dropdown-header" on:click={() => (isDateFilterOpen = !isDateFilterOpen)}>
|
|
<div class="header-content">
|
|
<i class="fas fa-calendar-alt" />
|
|
<span>Date</span>
|
|
</div>
|
|
<i class="fas fa-chevron-{isDateFilterOpen ? 'up' : 'down'}" />
|
|
</div>
|
|
|
|
{#if isDateFilterOpen}
|
|
<div class="dropdown-content" transition:fade>
|
|
<div class="date-range-container">
|
|
<label>Date of</label>
|
|
<Select bind:value={dateRangeValue} on:change={onDateRangeChanged} fontSize="0.8" options={dateRangeOptions} />
|
|
</div>
|
|
|
|
<DateRange
|
|
dateFrom={dateFromUnix(currentFilters.date_from)}
|
|
dateTo={dateFromUnix(currentFilters.date_to)}
|
|
on:change={debouncedOnDateRangeChanged} />
|
|
|
|
<div class="time-presets">
|
|
<Button
|
|
label="Today"
|
|
type={Math.abs(dateFromUnix(currentFilters.date_from)?.getTime() - today.getTime()) < 600000 ? 'primary' : 'default'}
|
|
on:click={() => onDateRangeChange({detail: {from: today, to: null}})} />
|
|
<Button
|
|
label="Last week"
|
|
type={Math.abs(dateFromUnix(currentFilters.date_from)?.getTime() - lastWeek.getTime()) < 600000 ? 'primary' : 'default'}
|
|
on:click={() => onDateRangeChange({detail: {from: lastWeek, to: null}})} />
|
|
<Button
|
|
label="Last year"
|
|
type={Math.abs(dateFromUnix(currentFilters.date_from)?.getTime() - lastYear.getTime()) < 600000 ? 'primary' : 'default'}
|
|
on:click={() => onDateRangeChange({detail: {from: lastYear, to: null}})} />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="filter dropdown-filter" class:has-value={!!currentFilters.mapType}>
|
|
<div class="dropdown-header" on:click={() => (isCategoryFilterOpen = !isCategoryFilterOpen)}>
|
|
<div class="header-content">
|
|
<i class="fas fa-tags" />
|
|
<span>Categories</span>
|
|
</div>
|
|
<i class="fas fa-chevron-{isCategoryFilterOpen ? 'up' : 'down'}" />
|
|
</div>
|
|
|
|
{#if isCategoryFilterOpen}
|
|
<div class="dropdown-content" transition:fade>
|
|
<Select
|
|
bind:value={currentFilters.allTypes}
|
|
on:change={() => onCategoryModeChanged()}
|
|
fontSize="0.8"
|
|
options={[
|
|
{name: 'ANY category', value: 0},
|
|
{name: 'ALL categories', value: 1},
|
|
{name: 'NO categories', value: 2},
|
|
]} />
|
|
|
|
<Switcher
|
|
values={categoryFilterOptions}
|
|
value={categoryFilterOptions.filter(c => currentFilters.mapType & c.key)}
|
|
multi={true}
|
|
on:change={onCategoryChanged} />
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="filter dropdown-filter" class:has-value={!!currentFilters.mapRequirements}>
|
|
<div class="dropdown-header" on:click={() => (isRequirementsFilterOpen = !isRequirementsFilterOpen)}>
|
|
<div class="header-content">
|
|
<i class="fas fa-list-check" />
|
|
<span>Requirements</span>
|
|
</div>
|
|
<i class="fas fa-chevron-{isRequirementsFilterOpen ? 'up' : 'down'}" />
|
|
</div>
|
|
|
|
{#if isRequirementsFilterOpen}
|
|
<div class="dropdown-content" transition:fade>
|
|
<Select
|
|
bind:value={currentFilters.allRequirements}
|
|
on:change={() => onCategoryModeChanged()}
|
|
fontSize="0.8"
|
|
options={[
|
|
{name: 'ANY map feature', value: 0},
|
|
{name: 'ALL map features', value: 1},
|
|
{name: 'NO map features', value: 2},
|
|
]} />
|
|
|
|
<Switcher
|
|
values={requirementFilterOptions}
|
|
value={requirementFilterOptions.filter(c => currentFilters.mapRequirements & c.key)}
|
|
multi={true}
|
|
on:change={onRequirementsChanged} />
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="filter">
|
|
<div class="mode-and-diff">
|
|
<div>
|
|
<label>Has diff</label>
|
|
<Select
|
|
bind:value={currentFilters.difficulty}
|
|
on:change={onDifficultyChanged}
|
|
fontSize="0.8"
|
|
options={difficultyFilterOptions}
|
|
nullPlaceholder={difficultyNullPlaceholder}
|
|
nameSelector={x => x.label}
|
|
valueSelector={x => x.key} />
|
|
</div>
|
|
<div>
|
|
<label>Has mode</label>
|
|
<Select
|
|
bind:value={currentFilters.mode}
|
|
on:change={onModeChanged}
|
|
fontSize="0.8"
|
|
options={modeFilterOptions}
|
|
nullPlaceholder={modeNullPlaceholder}
|
|
nameSelector={x => x.label}
|
|
valueSelector={x => x.key} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="filter dropdown-filter">
|
|
<div class="dropdown-header" on:click={() => (isPlaylistOpen = !isPlaylistOpen)}>
|
|
<div class="header-content">
|
|
<i class="fas fa-list" />
|
|
<span>Generate Playlist</span>
|
|
</div>
|
|
<i class="fas fa-chevron-{isPlaylistOpen ? 'up' : 'down'}" />
|
|
</div>
|
|
|
|
{#if isPlaylistOpen}
|
|
<div class="dropdown-content" transition:fade>
|
|
{#if makingPlaylist}
|
|
<Spinner />
|
|
{:else}
|
|
<span>Maps count:</span>
|
|
<RangeSlider
|
|
range
|
|
min={0}
|
|
max={1000}
|
|
step={1}
|
|
values={[mapCount]}
|
|
hoverable
|
|
float
|
|
pips
|
|
pipstep={100}
|
|
all="label"
|
|
on:change={event => {
|
|
mapCount = event.detail.values[0];
|
|
}} />
|
|
<div class="duplicateDiffsContainer">
|
|
<input type="checkbox" id="duplicateDiffs" label="Duplicate map per diff" bind:checked={duplicateDiffs} />
|
|
<label for="duplicateDiffs" title="Will include every diff as a separate map entry">Duplicate map per diff</label>
|
|
</div>
|
|
<div class="playlistTitleContainer">
|
|
<label for="playlistTitle" title="Name of the playlist" style="margin: 0;">Title</label>
|
|
<input type="text" id="playlistTitle" label="Title" bind:value={playlistTitle} />
|
|
</div>
|
|
<Button
|
|
cls="playlist-button"
|
|
iconFa="fas fa-wand-magic-sparkles"
|
|
label="Generate playlist"
|
|
on:click={() => generatePlaylist()} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
<div class="compact-pager-container">
|
|
<Pager
|
|
totalItems={numOfMaps}
|
|
{itemsPerPage}
|
|
itemsPerPageValues={null}
|
|
currentPage={currentPage - 1}
|
|
{loadingPage}
|
|
itemWidth={23}
|
|
mode={numOfMaps ? 'pages' : 'simple'}
|
|
on:page-changed={onPageChanged} />
|
|
</div>
|
|
</AsideBox>
|
|
</aside>
|
|
<Svrollbar viewport={asideContainer} />
|
|
</section>
|
|
|
|
<MetaTags
|
|
title={metaTitle}
|
|
description={metaDescription}
|
|
openGraph={{
|
|
title: metaTitle,
|
|
description: metaDescription,
|
|
images: [{url: CURRENT_URL + '/assets/logo-small.png'}],
|
|
siteName: ssrConfig.name + ' - Maps',
|
|
}}
|
|
twitter={{
|
|
handle: '@handle',
|
|
site: '@beatleader_',
|
|
cardType: 'summary',
|
|
title: metaTitle,
|
|
description: metaDescription,
|
|
image: CURRENT_URL + '/assets/logo-small.png',
|
|
imageAlt: ssrConfig.name + "'s logo",
|
|
}} />
|
|
|
|
<style>
|
|
.align-content {
|
|
display: flex;
|
|
justify-content: flex-end !important;
|
|
}
|
|
|
|
.page-content {
|
|
width: 100%;
|
|
overscroll-behavior: none;
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.songs-container {
|
|
display: flex;
|
|
height: calc(100% - 1.3em);
|
|
margin-top: -1em;
|
|
margin-bottom: -2.9em;
|
|
}
|
|
|
|
.maps-box {
|
|
left: 0;
|
|
margin-top: -1em !important;
|
|
display: flex;
|
|
justify-content: center;
|
|
padding-right: 10em;
|
|
overflow: visible;
|
|
}
|
|
|
|
:global(.tab-container) {
|
|
display: none;
|
|
justify-content: space-between;
|
|
position: absolute !important;
|
|
bottom: 4em;
|
|
left: 0.4em;
|
|
z-index: 14 !important;
|
|
border-radius: 12px !important;
|
|
overflow: hidden;
|
|
padding: 1.2em 0.7em 0.8em 0.8em !important;
|
|
}
|
|
|
|
.first-page-spacer {
|
|
height: 1.4em;
|
|
width: 100%;
|
|
}
|
|
|
|
:global(.compact-pager-container) {
|
|
padding: 0.5em;
|
|
border-radius: 12px !important;
|
|
overflow: hidden;
|
|
height: 5em;
|
|
}
|
|
|
|
:global(.compact-pager-container .pagination) {
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
:global(.compact-pager-container .pagination .position) {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
width: 97%;
|
|
}
|
|
|
|
.filter {
|
|
flex: 1;
|
|
}
|
|
|
|
:global(.tab-container .switch-types) {
|
|
flex-grow: 1;
|
|
margin-top: -1em;
|
|
margin-bottom: 1.5em;
|
|
}
|
|
|
|
.sorting-options {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
position: relative;
|
|
margin-bottom: 1.5em;
|
|
}
|
|
|
|
article {
|
|
width: calc(100% - 25em);
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
aside {
|
|
position: fixed;
|
|
right: 1em;
|
|
width: 26em;
|
|
top: 0;
|
|
padding-left: 0.5em;
|
|
padding-right: 0.5em;
|
|
padding-top: 5em;
|
|
max-height: 100%;
|
|
overflow: auto;
|
|
/* hide scrollbar */
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
z-index: 6;
|
|
overscroll-behavior: none;
|
|
}
|
|
|
|
aside::-webkit-scrollbar {
|
|
/* hide scrollbar */
|
|
display: none;
|
|
}
|
|
|
|
aside .filter {
|
|
margin-bottom: 1.5rem;
|
|
transition: opacity 300ms;
|
|
}
|
|
|
|
aside .filter.disabled {
|
|
opacity: 0.25;
|
|
}
|
|
|
|
aside label {
|
|
display: block;
|
|
font-weight: 500;
|
|
margin: 0.75rem 0;
|
|
}
|
|
|
|
aside .filter.disabled label {
|
|
cursor: help;
|
|
}
|
|
|
|
aside label span {
|
|
color: var(--beatleader-primary);
|
|
}
|
|
|
|
aside select {
|
|
border-radius: 0.2em;
|
|
padding: 0.4em 0.2em 0.4em 0.6em;
|
|
margin-bottom: 0.25em;
|
|
appearance: menulist-button;
|
|
}
|
|
|
|
aside select option {
|
|
color: var(--textColor);
|
|
background-color: var(--background);
|
|
}
|
|
|
|
aside input {
|
|
width: 100%;
|
|
font-size: 1em;
|
|
color: var(--beatleader-primary);
|
|
background-color: var(--foreground);
|
|
border: none;
|
|
border-bottom: 1px solid var(--faded);
|
|
outline: none;
|
|
}
|
|
|
|
aside :global(.switch-types) {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
aside :global(.switch-types .icon) {
|
|
width: 1.8em !important;
|
|
margin-left: -0.4em !important;
|
|
}
|
|
|
|
aside h2:not(:first-of-type) {
|
|
margin-top: 1.5em;
|
|
}
|
|
|
|
aside :global(.rangeSlider.pip-labels) {
|
|
margin-top: 1.5em;
|
|
margin-bottom: 4em;
|
|
}
|
|
|
|
input::placeholder {
|
|
color: var(--faded) !important;
|
|
}
|
|
|
|
.top-container {
|
|
position: relative;
|
|
height: 1.6em;
|
|
backdrop-filter: blur(6px);
|
|
background-color: #00000094;
|
|
z-index: 6;
|
|
margin: -1em;
|
|
}
|
|
|
|
:global(.compact-pager-container .pagination) {
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.switchers-container {
|
|
margin-left: 3.4em;
|
|
position: relative;
|
|
z-index: 10;
|
|
}
|
|
|
|
.songs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
column-gap: 2em;
|
|
row-gap: 0em;
|
|
justify-content: center;
|
|
align-items: start;
|
|
align-content: baseline;
|
|
position: relative;
|
|
max-width: 75em;
|
|
}
|
|
|
|
.songs.long {
|
|
max-width: 85em;
|
|
}
|
|
|
|
.maps-filters-container {
|
|
height: 100%;
|
|
overflow: scroll;
|
|
|
|
/* hide scrollbar */
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.maps-filters-container::-webkit-scrollbar {
|
|
/* hide scrollbar */
|
|
display: none;
|
|
}
|
|
|
|
.top-anchor {
|
|
width: 100%;
|
|
margin-top: 1em;
|
|
margin-bottom: 1.5em;
|
|
}
|
|
|
|
.bottom-anchor {
|
|
width: 100%;
|
|
bottom: 0;
|
|
margin-bottom: 2em;
|
|
}
|
|
|
|
.pager-and-switch {
|
|
display: flex;
|
|
align-items: baseline;
|
|
}
|
|
|
|
.table-switches {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
}
|
|
|
|
.mode-and-diff {
|
|
display: flex;
|
|
gap: 1em;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-split {
|
|
width: 60%;
|
|
justify-content: center;
|
|
display: flex;
|
|
margin-top: -2.2em;
|
|
border-bottom: solid 1px white;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
:global(.pager-and-switch .pagination) {
|
|
flex-grow: 1;
|
|
}
|
|
|
|
:global(.maps-aside-container .aside-box) {
|
|
min-width: unset;
|
|
}
|
|
|
|
:global(.mobile-filters-button) {
|
|
font-size: 0.8em !important;
|
|
height: 1.6em !important;
|
|
margin: -0.6em 0.4em 0 0 !important;
|
|
}
|
|
|
|
:global(.maps-filters-box) {
|
|
position: fixed;
|
|
height: 89%;
|
|
overflow: auto;
|
|
}
|
|
|
|
.playlist-buttons {
|
|
display: flex;
|
|
margin-top: 1em;
|
|
column-gap: 0.5em;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.duplicateDiffsContainer {
|
|
display: flex;
|
|
}
|
|
|
|
.playlistTitleContainer {
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
#duplicateDiffs {
|
|
width: auto;
|
|
}
|
|
|
|
:global(.playlist-button) {
|
|
height: 1.6em;
|
|
}
|
|
|
|
.time-presets {
|
|
display: flex;
|
|
gap: 0.5em;
|
|
margin-top: 0.4em;
|
|
}
|
|
|
|
:global(.time-presets .button) {
|
|
height: 2em;
|
|
padding: 0.4em;
|
|
}
|
|
|
|
.date-range-container {
|
|
display: flex;
|
|
gap: 0.4em;
|
|
align-items: center;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
|
|
.remove-type {
|
|
border: none;
|
|
color: rgb(255, 0, 0);
|
|
background-color: transparent;
|
|
cursor: pointer;
|
|
transform: translate(-7px, -2px);
|
|
}
|
|
|
|
.dropdown-filter {
|
|
border: 1px solid var(--faded);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.dropdown-filter.has-value {
|
|
border-color: rgba(255, 100, 150, 0.5);
|
|
}
|
|
|
|
.dropdown-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem 1rem;
|
|
background-color: var(--foreground);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.dropdown-header:hover {
|
|
background-color: var(--background);
|
|
}
|
|
|
|
.header-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.dropdown-content {
|
|
padding: 1rem;
|
|
background-color: var(--foreground);
|
|
}
|
|
|
|
.dropdown-filter + .dropdown-filter {
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.mobile-switcher {
|
|
display: none;
|
|
}
|
|
|
|
.no-maps-found {
|
|
width: 70vw;
|
|
height: 100%;
|
|
text-align: center;
|
|
align-content: center;
|
|
}
|
|
|
|
:global(.maps-type-button, .my-type-button) {
|
|
margin-bottom: -0.5em !important;
|
|
height: 3.5em;
|
|
border-radius: 12px 12px 0 0 !important;
|
|
width: 7em;
|
|
flex-direction: column;
|
|
align-items: center !important;
|
|
justify-content: center !important;
|
|
}
|
|
|
|
:global(.maps-type-button span, .my-type-button span) {
|
|
font-weight: 900;
|
|
}
|
|
|
|
:global(.maps-type-button i, .my-type-button i) {
|
|
margin-right: 0 !important;
|
|
}
|
|
|
|
@media screen and (min-width: 2000px) {
|
|
aside {
|
|
left: calc(50vw + 32em);
|
|
right: unset;
|
|
}
|
|
|
|
aside.long {
|
|
left: calc(50vw + 40em);
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 2000px) {
|
|
.maps-box {
|
|
padding-right: unset;
|
|
justify-content: start;
|
|
padding-left: 2em;
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 1275px) {
|
|
.desktop-switcher {
|
|
display: none;
|
|
}
|
|
.mobile-switcher {
|
|
display: block;
|
|
}
|
|
.songs {
|
|
flex: 0;
|
|
}
|
|
:global(.my-type-button) {
|
|
margin-bottom: unset !important;
|
|
height: unset;
|
|
border-radius: unset !important;
|
|
width: unset;
|
|
flex-direction: unset;
|
|
align-items: unset !important;
|
|
justify-content: unset !important;
|
|
}
|
|
|
|
:global(.my-type-button span) {
|
|
font-weight: unset;
|
|
}
|
|
|
|
:global(.my-type-button i) {
|
|
margin-right: unset !important;
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 767px) {
|
|
.songs {
|
|
margin-left: 0;
|
|
margin-right: 0;
|
|
row-gap: 0.2em;
|
|
flex: 1;
|
|
}
|
|
|
|
.filter {
|
|
margin: 1em 0;
|
|
}
|
|
|
|
.songs-container {
|
|
margin-bottom: 0;
|
|
height: 105%;
|
|
}
|
|
|
|
.compact-pager-container {
|
|
margin: unset;
|
|
}
|
|
|
|
.page-split {
|
|
margin-top: -1em;
|
|
}
|
|
|
|
.first-page-spacer {
|
|
height: 4em;
|
|
}
|
|
|
|
:global(.tab-container) {
|
|
display: flex;
|
|
}
|
|
|
|
:global(.filter .switch-types) {
|
|
margin-top: -1em;
|
|
margin-bottom: 0.4em;
|
|
}
|
|
|
|
aside {
|
|
display: block;
|
|
position: fixed;
|
|
top: 4em;
|
|
padding: 0.5em;
|
|
left: 0;
|
|
width: 100%;
|
|
max-height: 95%;
|
|
}
|
|
|
|
.maps-box {
|
|
padding: 0 !important;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
:global(.maps-type-button) {
|
|
margin-bottom: -0.5em !important;
|
|
height: 3em;
|
|
padding-top: 0.8em !important;
|
|
border-radius: 8px 8px 0 0 !important;
|
|
width: 6em;
|
|
font-size: 0.7em !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.first-page-spacer {
|
|
height: 5em;
|
|
}
|
|
}
|
|
|
|
@media screen and (max-width: 520px) {
|
|
.song-line .main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
grid-column-gap: 0.75em;
|
|
}
|
|
|
|
.songinfo {
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|