You've already forked AstralRinth
forked from didirus/AstralRinth
Sidebar refinements (#2306)
* Begin sidebar refinement, change back to left as default * New filters proof of concept * Hide if only one option * Version filters * Update changelog page * Use new cosmetic variable for sidebar position * Fix safari issue and change defaults to left filters, right sidebars * Fix download modal on safari and firefox * Add date published tooltip to versions page * Improve selection consistency * Fix lint and extract i18n * Remove unnecessary observer options
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="accordion-wrapper">
|
||||
<div class="accordion-wrapper" :class="{ 'has-content': hasContent }">
|
||||
<div class="accordion-content">
|
||||
<div>
|
||||
<div class="content-container" v-bind="$attrs">
|
||||
<div v-bind="$attrs" ref="slotContainer" class="content-container">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,11 +14,39 @@
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const slotContainer = ref();
|
||||
|
||||
const hasContent = ref(false);
|
||||
|
||||
const mutationObserver = ref<MutationObserver | null>(null);
|
||||
|
||||
function updateContent() {
|
||||
if (!slotContainer.value) return false;
|
||||
|
||||
hasContent.value = slotContainer.value ? slotContainer.value.children.length > 0 : false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mutationObserver.value = new MutationObserver(updateContent);
|
||||
|
||||
mutationObserver.value.observe(slotContainer.value, {
|
||||
childList: true,
|
||||
});
|
||||
|
||||
updateContent();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mutationObserver.value) {
|
||||
mutationObserver.value.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.accordion-content {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -28,15 +56,15 @@ defineOptions({
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content:has(* .content-container:empty) {
|
||||
grid-template-rows: 0fr;
|
||||
.has-content .accordion-content {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.accordion-content > div {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-wrapper:has(* .content-container:empty) {
|
||||
.accordion-wrapper.has-content {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,164 +1,153 @@
|
||||
<template>
|
||||
<div class="card flex-card experimental-styles-within">
|
||||
<span class="text-lg font-bold text-contrast">Filter</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="iconified-input w-full">
|
||||
<label class="hidden" for="search">Search</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search"
|
||||
v-model="queryFilter"
|
||||
name="search"
|
||||
type="search"
|
||||
placeholder="Search filters..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="experimental-styles-within flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ManySelect
|
||||
v-model="selectedPlatforms"
|
||||
:options="filterOptions.platform"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Platform
|
||||
<template #option="{ option }">
|
||||
{{ formatCategory(option) }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedGameVersions"
|
||||
:options="filterOptions.gameVersion"
|
||||
search
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Game versions
|
||||
<template #footer>
|
||||
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||
</template>
|
||||
</ManySelect>
|
||||
<ManySelect
|
||||
v-model="selectedChannels"
|
||||
:options="filterOptions.channel"
|
||||
@change="updateFilters"
|
||||
>
|
||||
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||
Channels
|
||||
<template #option="{ option }">
|
||||
{{ option === "release" ? "Release" : option === "beta" ? "Beta" : "Alpha" }}
|
||||
</template>
|
||||
</ManySelect>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<button
|
||||
v-if="Object.keys(selectedFilters).length !== 0"
|
||||
class="btn icon-only"
|
||||
v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
|
||||
class="tag-list__item text-contrast transition-transform active:scale-[0.95]"
|
||||
@click="clearFilters"
|
||||
>
|
||||
<FilterXIcon />
|
||||
<XCircleIcon />
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(value, key, index) in filters"
|
||||
:key="key"
|
||||
:class="`border-0 border-b border-solid border-button-bg py-2 last:border-b-0`"
|
||||
>
|
||||
<button
|
||||
class="flex !w-full bg-transparent px-0 py-2 font-extrabold text-contrast transition-all active:scale-[0.98]"
|
||||
@click="
|
||||
() => {
|
||||
filterAccordions[index].isOpen
|
||||
? filterAccordions[index].close()
|
||||
: filterAccordions[index].open();
|
||||
}
|
||||
"
|
||||
v-for="channel in selectedChannels"
|
||||
:key="`remove-filter-${channel}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
|
||||
@click="toggleFilter('channel', channel)"
|
||||
>
|
||||
<template v-if="key === 'gameVersion'"> Game versions </template>
|
||||
<template v-else>
|
||||
{{ $capitalizeString(key) }}
|
||||
</template>
|
||||
<DropdownIcon
|
||||
class="ml-auto h-5 w-5 transition-transform"
|
||||
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }"
|
||||
/>
|
||||
<XIcon />
|
||||
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="version in selectedGameVersions"
|
||||
:key="`remove-filter-${version}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
@click="toggleFilter('gameVersion', version)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ version }}
|
||||
</button>
|
||||
<button
|
||||
v-for="platform in selectedPlatforms"
|
||||
:key="`remove-filter-${platform}`"
|
||||
class="tag-list__item transition-transform active:scale-[0.95]"
|
||||
:style="`--_color: var(--color-platform-${platform})`"
|
||||
@click="toggleFilter('platform', platform)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatCategory(platform) }}
|
||||
</button>
|
||||
<Accordion ref="filterAccordions" :open-by-default="true">
|
||||
<ScrollablePanel
|
||||
:class="{ 'h-[18rem]': value.length >= 8 && key === 'gameVersion' }"
|
||||
:no-max-height="key !== 'gameVersion'"
|
||||
>
|
||||
<div class="mr-1 flex flex-col gap-1">
|
||||
<div v-for="filter in value" :key="filter" class="group flex gap-1">
|
||||
<button
|
||||
:class="`flex !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all active:scale-[0.98] ${selectedFilters[key]?.includes(filter) ? 'bg-brand-highlight text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg'}`"
|
||||
@click="toggleFilter(key, filter)"
|
||||
>
|
||||
<span v-if="filter === 'release'" class="h-2 w-2 rounded-full bg-brand" />
|
||||
<span v-else-if="filter === 'beta'" class="h-2 w-2 rounded-full bg-orange" />
|
||||
<span v-else-if="filter === 'alpha'" class="h-2 w-2 rounded-full bg-red" />
|
||||
<span class="truncate text-sm">{{ $formatCategory(filter) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
<Checkbox
|
||||
v-if="key === 'gameVersion'"
|
||||
v-model="showSnapshots"
|
||||
class="mx-2"
|
||||
:label="`Show all versions`"
|
||||
/>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DropdownIcon, FilterXIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { ScrollablePanel, Checkbox } from "@modrinth/ui";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
<script setup lang="ts">
|
||||
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
|
||||
import { ManySelect, Checkbox } from "@modrinth/ui";
|
||||
import { formatCategory } from "@modrinth/utils";
|
||||
import type { ModrinthVersion } from "@modrinth/utils";
|
||||
|
||||
const props = defineProps<{ versions: ModrinthVersion[] }>();
|
||||
|
||||
const props = defineProps({
|
||||
versions: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["switch-page"]);
|
||||
|
||||
const allChannels = ref(["release", "beta", "alpha"]);
|
||||
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
const filterAccordions = ref([]);
|
||||
|
||||
const queryFilter = ref("");
|
||||
const showSnapshots = ref(false);
|
||||
const filters = computed(() => {
|
||||
const filters = {};
|
||||
|
||||
const tempLoaders = new Set();
|
||||
const tempVersions = new Set();
|
||||
const tempReleaseChannels = new Set();
|
||||
type FilterType = "channel" | "gameVersion" | "platform";
|
||||
type Filter = string;
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const filters: Record<FilterType, Filter[]> = {
|
||||
channel: [],
|
||||
gameVersion: [],
|
||||
platform: [],
|
||||
};
|
||||
|
||||
const platformSet = new Set();
|
||||
const gameVersionSet = new Set();
|
||||
const channelSet = new Set();
|
||||
|
||||
for (const version of props.versions) {
|
||||
for (const loader of version.loaders) {
|
||||
tempLoaders.add(loader);
|
||||
platformSet.add(loader);
|
||||
}
|
||||
for (const gameVersion of version.game_versions) {
|
||||
tempVersions.add(gameVersion);
|
||||
gameVersionSet.add(gameVersion);
|
||||
}
|
||||
tempReleaseChannels.add(version.version_type);
|
||||
channelSet.add(version.version_type);
|
||||
}
|
||||
|
||||
if (tempReleaseChannels.size > 0) {
|
||||
filters.type = Array.from(tempReleaseChannels);
|
||||
if (channelSet.size > 0) {
|
||||
filters.channel = Array.from(channelSet) as Filter[];
|
||||
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
|
||||
}
|
||||
if (tempVersions.size > 0) {
|
||||
const gameVersions = tags.value.gameVersions.filter((x) => tempVersions.has(x.version));
|
||||
if (gameVersionSet.size > 0) {
|
||||
const gameVersions = tags.value.gameVersions.filter((x) => gameVersionSet.has(x.version));
|
||||
|
||||
filters.gameVersion = gameVersions
|
||||
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
|
||||
.map((x) => x.version);
|
||||
}
|
||||
if (tempLoaders.size > 0) {
|
||||
filters.platform = Array.from(tempLoaders);
|
||||
if (platformSet.size > 0) {
|
||||
filters.platform = Array.from(platformSet) as Filter[];
|
||||
}
|
||||
|
||||
const filteredObj = {};
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const filters = queryFilter.value
|
||||
? value.filter((x) => x.toLowerCase().includes(queryFilter.value.toLowerCase()))
|
||||
: value;
|
||||
|
||||
if (filters.length > 0) {
|
||||
filteredObj[key] = filters;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredObj;
|
||||
return filters;
|
||||
});
|
||||
|
||||
const selectedFilters = ref({});
|
||||
const selectedChannels = ref<string[]>([]);
|
||||
const selectedGameVersions = ref<string[]>([]);
|
||||
const selectedPlatforms = ref<string[]>([]);
|
||||
|
||||
if (route.query.type) {
|
||||
selectedFilters.value.type = getArrayOrString(route.query.type);
|
||||
}
|
||||
if (route.query.gameVersion) {
|
||||
selectedFilters.value.gameVersion = getArrayOrString(route.query.gameVersion);
|
||||
}
|
||||
if (route.query.platform) {
|
||||
selectedFilters.value.platform = getArrayOrString(route.query.platform);
|
||||
}
|
||||
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
|
||||
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||
|
||||
async function toggleFilters(type, filters) {
|
||||
async function toggleFilters(type: FilterType, filters: Filter[]) {
|
||||
for (const filter of filters) {
|
||||
await toggleFilter(type, filter);
|
||||
}
|
||||
@@ -166,54 +155,58 @@ async function toggleFilters(type, filters) {
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
type: selectedFilters.value.type,
|
||||
gameVersion: selectedFilters.value.gameVersion,
|
||||
platform: selectedFilters.value.platform,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function toggleFilter(type, filter, skipRouter) {
|
||||
if (!selectedFilters.value[type]) {
|
||||
selectedFilters.value[type] = [];
|
||||
async function toggleFilter(type: FilterType, filter: Filter, skipRouter = false) {
|
||||
if (type === "channel") {
|
||||
selectedChannels.value = selectedChannels.value.includes(filter)
|
||||
? selectedChannels.value.filter((x) => x !== filter)
|
||||
: [...selectedChannels.value, filter];
|
||||
} else if (type === "gameVersion") {
|
||||
selectedGameVersions.value = selectedGameVersions.value.includes(filter)
|
||||
? selectedGameVersions.value.filter((x) => x !== filter)
|
||||
: [...selectedGameVersions.value, filter];
|
||||
} else if (type === "platform") {
|
||||
selectedPlatforms.value = selectedPlatforms.value.includes(filter)
|
||||
? selectedPlatforms.value.filter((x) => x !== filter)
|
||||
: [...selectedPlatforms.value, filter];
|
||||
}
|
||||
|
||||
const index = selectedFilters.value[type].indexOf(filter);
|
||||
if (index !== -1) {
|
||||
selectedFilters.value[type].splice(index, 1);
|
||||
} else {
|
||||
selectedFilters.value[type].push(filter);
|
||||
}
|
||||
|
||||
if (selectedFilters.value[type].length === 0) {
|
||||
delete selectedFilters.value[type];
|
||||
}
|
||||
|
||||
if (!skipRouter) {
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
type: selectedFilters.value.type,
|
||||
gameVersion: selectedFilters.value.gameVersion,
|
||||
platform: selectedFilters.value.platform,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
await updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFilters() {
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
c: selectedChannels.value,
|
||||
g: selectedGameVersions.value,
|
||||
l: selectedPlatforms.value,
|
||||
},
|
||||
});
|
||||
|
||||
emit("switch-page", 1);
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
selectedFilters.value = {};
|
||||
selectedChannels.value = [];
|
||||
selectedGameVersions.value = [];
|
||||
selectedPlatforms.value = [];
|
||||
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
type: undefined,
|
||||
gameVersion: undefined,
|
||||
platform: undefined,
|
||||
c: undefined,
|
||||
g: undefined,
|
||||
l: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user