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:
Prospector
2024-08-26 16:53:27 -07:00
committed by GitHub
parent 656c5b61cc
commit 2dd8d5a119
22 changed files with 965 additions and 779 deletions

View File

@@ -1147,7 +1147,6 @@ svg.inline-svg {
} }
// START STUFF FOR OMORPHIA // START STUFF FOR OMORPHIA
.experimental-styles-within { .experimental-styles-within {
.tag-list { .tag-list {
display: flex; display: flex;
@@ -1156,7 +1155,7 @@ svg.inline-svg {
} }
.tag-list__item { .tag-list__item {
background-color: var(--color-button-bg); background-color: var(--_bg-color, var(--color-button-bg));
padding: var(--gap-4) var(--gap-8); padding: var(--gap-4) var(--gap-8);
border-radius: var(--radius-max); border-radius: var(--radius-max);
font-weight: var(--weight-bold); font-weight: var(--weight-bold);

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="accordion-wrapper"> <div class="accordion-wrapper" :class="{ 'has-content': hasContent }">
<div class="accordion-content"> <div class="accordion-content">
<div> <div>
<div class="content-container" v-bind="$attrs"> <div v-bind="$attrs" ref="slotContainer" class="content-container">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -14,11 +14,39 @@
defineOptions({ defineOptions({
inheritAttrs: false, 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> </script>
<style scoped> <style scoped>
.accordion-content { .accordion-content {
display: grid; display: grid;
grid-template-rows: 1fr; grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out; transition: grid-template-rows 0.3s ease-in-out;
} }
@@ -28,15 +56,15 @@ defineOptions({
} }
} }
.accordion-content:has(* .content-container:empty) { .has-content .accordion-content {
grid-template-rows: 0fr; grid-template-rows: 1fr;
} }
.accordion-content > div { .accordion-content > div {
overflow: hidden; overflow: hidden;
} }
.accordion-wrapper:has(* .content-container:empty) { .accordion-wrapper.has-content {
display: contents; display: contents;
} }
</style> </style>

View File

@@ -1,164 +1,153 @@
<template> <template>
<div class="card flex-card experimental-styles-within"> <div class="experimental-styles-within flex flex-col gap-3">
<span class="text-lg font-bold text-contrast">Filter</span> <div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2"> <ManySelect
<div class="iconified-input w-full"> v-model="selectedPlatforms"
<label class="hidden" for="search">Search</label> :options="filterOptions.platform"
<SearchIcon aria-hidden="true" /> @change="updateFilters"
<input >
id="search" <FilterIcon class="h-5 w-5 text-secondary" />
v-model="queryFilter" Platform
name="search" <template #option="{ option }">
type="search" {{ formatCategory(option) }}
placeholder="Search filters..." </template>
autocomplete="off" </ManySelect>
/> <ManySelect
</div> 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 <button
v-if="Object.keys(selectedFilters).length !== 0" v-if="selectedChannels.length + selectedGameVersions.length + selectedPlatforms.length > 1"
class="btn icon-only" class="tag-list__item text-contrast transition-transform active:scale-[0.95]"
@click="clearFilters" @click="clearFilters"
> >
<FilterXIcon /> <XCircleIcon />
Clear all filters
</button> </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 <button
class="flex !w-full bg-transparent px-0 py-2 font-extrabold text-contrast transition-all active:scale-[0.98]" v-for="channel in selectedChannels"
@click=" :key="`remove-filter-${channel}`"
() => { class="tag-list__item transition-transform active:scale-[0.95]"
filterAccordions[index].isOpen :style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
? filterAccordions[index].close() @click="toggleFilter('channel', channel)"
: filterAccordions[index].open();
}
"
> >
<template v-if="key === 'gameVersion'"> Game versions </template> <XIcon />
<template v-else> {{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
{{ $capitalizeString(key) }} </button>
</template> <button
<DropdownIcon v-for="version in selectedGameVersions"
class="ml-auto h-5 w-5 transition-transform" :key="`remove-filter-${version}`"
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }" 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> </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>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { DropdownIcon, FilterXIcon, SearchIcon } from "@modrinth/assets"; import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets";
import { ScrollablePanel, Checkbox } from "@modrinth/ui"; import { ManySelect, Checkbox } from "@modrinth/ui";
import Accordion from "~/components/ui/Accordion.vue"; 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 emit = defineEmits(["switch-page"]);
const allChannels = ref(["release", "beta", "alpha"]);
const route = useNativeRoute(); const route = useNativeRoute();
const router = useNativeRouter(); const router = useNativeRouter();
const tags = useTags(); const tags = useTags();
const filterAccordions = ref([]);
const queryFilter = ref("");
const showSnapshots = ref(false); const showSnapshots = ref(false);
const filters = computed(() => {
const filters = {};
const tempLoaders = new Set(); type FilterType = "channel" | "gameVersion" | "platform";
const tempVersions = new Set(); type Filter = string;
const tempReleaseChannels = new Set();
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 version of props.versions) {
for (const loader of version.loaders) { for (const loader of version.loaders) {
tempLoaders.add(loader); platformSet.add(loader);
} }
for (const gameVersion of version.game_versions) { 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) { if (channelSet.size > 0) {
filters.type = Array.from(tempReleaseChannels); filters.channel = Array.from(channelSet) as Filter[];
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b));
} }
if (tempVersions.size > 0) { if (gameVersionSet.size > 0) {
const gameVersions = tags.value.gameVersions.filter((x) => tempVersions.has(x.version)); const gameVersions = tags.value.gameVersions.filter((x) => gameVersionSet.has(x.version));
filters.gameVersion = gameVersions filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release")) .filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => x.version); .map((x) => x.version);
} }
if (tempLoaders.size > 0) { if (platformSet.size > 0) {
filters.platform = Array.from(tempLoaders); filters.platform = Array.from(platformSet) as Filter[];
} }
const filteredObj = {}; return filters;
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;
}); });
const selectedFilters = ref({}); const selectedChannels = ref<string[]>([]);
const selectedGameVersions = ref<string[]>([]);
const selectedPlatforms = ref<string[]>([]);
if (route.query.type) { selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : [];
selectedFilters.value.type = getArrayOrString(route.query.type); selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
} selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
if (route.query.gameVersion) {
selectedFilters.value.gameVersion = getArrayOrString(route.query.gameVersion);
}
if (route.query.platform) {
selectedFilters.value.platform = getArrayOrString(route.query.platform);
}
async function toggleFilters(type, filters) { async function toggleFilters(type: FilterType, filters: Filter[]) {
for (const filter of filters) { for (const filter of filters) {
await toggleFilter(type, filter); await toggleFilter(type, filter);
} }
@@ -166,54 +155,58 @@ async function toggleFilters(type, filters) {
await router.replace({ await router.replace({
query: { query: {
...route.query, ...route.query,
type: selectedFilters.value.type, c: selectedChannels.value,
gameVersion: selectedFilters.value.gameVersion, g: selectedGameVersions.value,
platform: selectedFilters.value.platform, l: selectedPlatforms.value,
}, },
}); });
emit("switch-page", 1); emit("switch-page", 1);
} }
async function toggleFilter(type, filter, skipRouter) { async function toggleFilter(type: FilterType, filter: Filter, skipRouter = false) {
if (!selectedFilters.value[type]) { if (type === "channel") {
selectedFilters.value[type] = []; 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) { if (!skipRouter) {
await router.replace({ await updateFilters();
query: {
...route.query,
type: selectedFilters.value.type,
gameVersion: selectedFilters.value.gameVersion,
platform: selectedFilters.value.platform,
},
});
emit("switch-page", 1);
} }
} }
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() { async function clearFilters() {
selectedFilters.value = {}; selectedChannels.value = [];
selectedGameVersions.value = [];
selectedPlatforms.value = [];
await router.replace({ await router.replace({
query: { query: {
...route.query, ...route.query,
type: undefined, c: undefined,
gameVersion: undefined, g: undefined,
platform: undefined, l: undefined,
}, },
}); });

View File

@@ -19,17 +19,8 @@ const validateValues = <K extends PropertyKey>(flags: Record<K, FlagValue>) => f
export const DEFAULT_FEATURE_FLAGS = validateValues({ export const DEFAULT_FEATURE_FLAGS = validateValues({
// Developer flags // Developer flags
developerMode: false, developerMode: false,
showVersionFilesInTable: false,
// In-development features, flags will be removed over time showAdsWithPlus: false,
newProjectLinks: true,
newProjectMembers: false,
newProjectDetails: true,
projectCompatibility: false,
removeFeaturedVersions: false,
// Alt layouts
// searchSidebarRight: false,
// projectSidebarRight: false,
// Feature toggles // Feature toggles
// advancedRendering: true, // advancedRendering: true,

View File

@@ -611,8 +611,8 @@
"project.about.links.wiki": { "project.about.links.wiki": {
"message": "Visit wiki" "message": "Visit wiki"
}, },
"project.about.title": { "project.description.title": {
"message": "About" "message": "Description"
}, },
"project.gallery.title": { "project.gallery.title": {
"message": "Gallery" "message": "Gallery"
@@ -1028,9 +1028,6 @@
"settings.display.project-list.layouts.collection": { "settings.display.project-list.layouts.collection": {
"message": "Collection" "message": "Collection"
}, },
"settings.display.sidebar.Left-aligned-search-sidebar.title": {
"message": "Left-aligned search sidebar"
},
"settings.display.sidebar.advanced-rendering.description": { "settings.display.sidebar.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering." "message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
}, },
@@ -1049,14 +1046,17 @@
"settings.display.sidebar.hide-app-promos.title": { "settings.display.sidebar.hide-app-promos.title": {
"message": "Hide Modrinth App promotions" "message": "Hide Modrinth App promotions"
}, },
"settings.display.sidebar.left-aligned-project-sidebar.description": { "settings.display.sidebar.left-aligned-content-sidebar.title": {
"message": "Aligns the project details sidebar to the left of the page's content." "message": "Left-aligned sidebar on content pages"
}, },
"settings.display.sidebar.left-aligned-project-sidebar.title": { "settings.display.sidebar.right-aligned-content-sidebar.description": {
"message": "Left-aligned project sidebar" "message": "Aligns the sidebar to the left of the page's content."
}, },
"settings.display.sidebar.left-aligned-search-sidebar.description": { "settings.display.sidebar.right-aligned-filters-sidebar.description": {
"message": "Aligns the search filters sidebar to the left of the search results." "message": "Aligns the filters sidebar to the right of the search results."
},
"settings.display.sidebar.right-aligned-filters-sidebar.title": {
"message": "Right-aligned filters sidebar on search pages"
}, },
"settings.display.theme.dark": { "settings.display.theme.dark": {
"message": "Dark" "message": "Dark"

View File

@@ -149,6 +149,20 @@
<span class="text-lg font-extrabold text-contrast"> Settings </span> <span class="text-lg font-extrabold text-contrast"> Settings </span>
</template> </template>
</NewModal> </NewModal>
<NewModal ref="modalLicense" :header="project.license.name ? project.license.name : 'License'">
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" no-shadow />
<span class="text-lg font-extrabold text-contrast">
{{ project.license.name ? project.license.name : "License" }}
</span>
</template>
<div
class="markdown-body"
v-html="
renderString(licenseText).isEmpty ? 'Loading license text...' : renderString(licenseText)
"
/>
</NewModal>
<div <div
class="over-the-top-download-animation" class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }" :class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
@@ -232,7 +246,6 @@
class="accordion-with-bg" class="accordion-with-bg"
@on-open=" @on-open="
() => { () => {
gameVersionFilterInput.focus();
if (platformAccordion) { if (platformAccordion) {
platformAccordion.close(); platformAccordion.close();
} }
@@ -402,7 +415,8 @@
!filteredAlpha !filteredAlpha
" "
> >
No versions available for {{ currentGameVersion }} and {{ currentPlatform }}. No versions available for {{ currentGameVersion }} and
{{ formatCategory(currentPlatform) }}.
</p> </p>
</AutomaticAccordion> </AutomaticAccordion>
</div> </div>
@@ -410,10 +424,9 @@
</NewModal> </NewModal>
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" /> <CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<div <div
class="new-page" class="new-page sidebar"
:class="{ :class="{
sidebar: !route.name.endsWith('gallery') && !route.name.endsWith('moderation'), 'alt-layout': cosmetics.leftContentLayout,
'alt-layout': cosmetics.projectLayout,
}" }"
> >
<div class="normal-page__header relative my-4"> <div class="normal-page__header relative my-4">
@@ -438,7 +451,7 @@
</p> </p>
<div class="mt-auto flex flex-wrap gap-4"> <div class="mt-auto flex flex-wrap gap-4">
<div <div
class="flex items-center gap-3 border-0 border-r border-solid border-button-bg pr-4" class="flex items-center gap-2 border-0 border-r border-solid border-button-bg pr-4"
> >
<DownloadIcon class="h-6 w-6 text-secondary" /> <DownloadIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold"> <span class="font-semibold">
@@ -446,14 +459,14 @@
</span> </span>
</div> </div>
<div <div
class="flex items-center gap-3 border-0 border-solid border-button-bg pr-4 md:border-r" class="flex items-center gap-2 border-0 border-solid border-button-bg pr-4 md:border-r"
> >
<HeartIcon class="h-6 w-6 text-secondary" /> <HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold"> <span class="font-semibold">
{{ $formatNumber(project.followers) }} {{ $formatNumber(project.followers) }}
</span> </span>
</div> </div>
<div class="hidden items-center gap-3 md:flex"> <div class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" /> <TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div <div
@@ -650,25 +663,327 @@
{{ project.title }} has been archived. {{ project.title }} will not receive any further {{ project.title }} has been archived. {{ project.title }} will not receive any further
updates unless the author decides to unarchive the project. updates unless the author decides to unarchive the project.
</MessageBanner> </MessageBanner>
<div class="overflow-x-auto"> </div>
<NavTabs :links="navLinks" class="mt-4" /> <div class="normal-page__sidebar">
<div v-if="versions.length > 0" class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(compatibilityMessages.title) }}</h2>
<section>
<h3>{{ formatMessage(compatibilityMessages.minecraftJava) }}</h3>
<div class="tag-list">
<div
v-for="version in getVersionsToDisplay(project)"
:key="`version-tag-${version}`"
class="tag-list__item"
>
{{ version }}
</div>
</div>
</section>
<section v-if="project.project_type !== 'resourcepack'">
<h3>{{ formatMessage(compatibilityMessages.platforms) }}</h3>
<div class="tag-list">
<div
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:class="`tag-list__item`"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</div>
</div>
</section>
<section
v-if="
(project.actualProjectType === 'mod' || project.project_type === 'modpack') &&
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
!(project.client_side === 'unknown' && project.server_side === 'unknown')
"
>
<h3>{{ formatMessage(compatibilityMessages.environments) }}</h3>
<div class="status-list">
<div
v-if="
(project.client_side === 'required' && project.server_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
class="status-list__item"
>
<ClientIcon aria-hidden="true" />
Client-side
</div>
<div
v-if="
(project.server_side === 'required' && project.client_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
class="status-list__item"
>
<ServerIcon aria-hidden="true" />
Server-side
</div>
<div v-if="false" class="status-list__item">
<UserIcon aria-hidden="true" />
Singleplayer
</div>
<div
v-if="project.client_side === 'required' && project.server_side === 'required'"
class="status-list__item"
>
<MonitorSmartphoneIcon aria-hidden="true" />
Client and server
</div>
<div
v-else-if="
project.client_side === 'optional' ||
(project.client_side === 'required' && project.server_side === 'optional') ||
project.server_side === 'optional' ||
(project.server_side === 'required' && project.client_side === 'optional')
"
class="status-list__item"
>
<MonitorSmartphoneIcon aria-hidden="true" />
Client and server <span class="text-sm">(optional)</span>
</div>
</div>
</section>
</div>
<AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
tags.approvedStatuses.includes(project.status)
"
/>
<div
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0
"
class="card flex-card experimental-styles-within"
>
<h2>{{ formatMessage(linksMessages.title) }}</h2>
<div class="links-list">
<a
v-if="project.issues_url"
:href="project.issues_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<IssuesIcon aria-hidden="true" />
{{ formatMessage(linksMessages.issues) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.source_url"
:href="project.source_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<CodeIcon aria-hidden="true" />
{{ formatMessage(linksMessages.source) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<WikiIcon aria-hidden="true" />
{{ formatMessage(linksMessages.wiki) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<DiscordIcon class="shrink" aria-hidden="true" />
{{ formatMessage(linksMessages.discord) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<hr
v-if="
(project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url) &&
project.donation_urls.length > 0
"
/>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
:target="$external()"
rel="noopener nofollow ugc"
>
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
<OpenCollectiveIcon
v-else-if="donation.id === 'open-collective'"
aria-hidden="true"
/>
<HeartIcon v-else-if="donation.id === 'github'" />
<CurrencyIcon v-else />
<span v-if="donation.id === 'bmac'">{{
formatMessage(linksMessages.donateBmac)
}}</span>
<span v-else-if="donation.id === 'patreon'">{{
formatMessage(linksMessages.donatePatreon)
}}</span>
<span v-else-if="donation.id === 'paypal'">{{
formatMessage(linksMessages.donatePayPal)
}}</span>
<span v-else-if="donation.id === 'ko-fi'">{{
formatMessage(linksMessages.donateKoFi)
}}</span>
<span v-else-if="donation.id === 'github'">{{
formatMessage(linksMessages.donateGithub)
}}</span>
<span v-else>{{ formatMessage(linksMessages.donateGeneric) }}</span>
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(creatorsMessages.title) }}</h2>
<div class="details-list">
<template v-if="organization">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/organization/${organization.slug}`"
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="rows">
<span>
{{ organization.name }}
</span>
<span class="details-list__item__text--style-secondary">Organization</span>
</div>
</nuxt-link>
<hr v-if="members.length > 0" />
</template>
<nuxt-link
v-for="member in members"
:key="`member-${member.id}`"
class="details-list__item details-list__item--type-large"
:to="'/user/' + member.user.username"
>
<Avatar :src="member.avatar_url" :alt="member.name" size="32px" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member.name }}
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(creatorsMessages.owner)"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">{{ member.role }}</span>
</div>
</nuxt-link>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
Licensed
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="$external()"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
class="text-link hover:underline"
@click="(event) => getLicenseData(event)"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
</div>
<div
v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
</div>
</div>
</div> </div>
</div> </div>
<NuxtPage <div class="normal-page__content">
v-model:project="project" <div class="overflow-x-auto">
v-model:versions="versions" <NavTabs :links="navLinks" class="mb-4" />
v-model:featured-versions="featuredVersions" </div>
v-model:members="members" <NuxtPage
v-model:all-members="allMembers" v-model:project="project"
v-model:dependencies="dependencies" v-model:versions="versions"
v-model:organization="organization" v-model:featured-versions="featuredVersions"
:current-member="currentMember" v-model:members="members"
:reset-project="resetProject" v-model:all-members="allMembers"
:reset-organization="resetOrganization" v-model:dependencies="dependencies"
:reset-members="resetMembers" v-model:organization="organization"
:route="route" :current-member="currentMember"
@on-download="triggerDownloadAnimation" :reset-project="resetProject"
/> :reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@on-download="triggerDownloadAnimation"
/>
</div>
</div> </div>
<ModerationChecklist <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
@@ -703,6 +1018,23 @@ import {
UsersIcon, UsersIcon,
VersionIcon, VersionIcon,
WrenchIcon, WrenchIcon,
ClientIcon,
BookTextIcon,
MonitorSmartphoneIcon,
WikiIcon,
DiscordIcon,
CalendarIcon,
KoFiIcon,
BuyMeACoffeeIcon,
IssuesIcon,
UserIcon,
PayPalIcon,
ServerIcon,
PatreonIcon,
CrownIcon,
OpenCollectiveIcon,
CodeIcon,
CurrencyIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
Avatar, Avatar,
@@ -713,7 +1045,7 @@ import {
PopoutMenu, PopoutMenu,
ScrollablePanel, ScrollablePanel,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { formatCategory, isRejected, isStaff, isUnderReview } from "@modrinth/utils"; import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Badge from "~/components/ui/Badge.vue"; import Badge from "~/components/ui/Badge.vue";
import NavTabs from "~/components/ui/NavTabs.vue"; import NavTabs from "~/components/ui/NavTabs.vue";
@@ -730,6 +1062,8 @@ import Accordion from "~/components/ui/Accordion.vue";
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component"; import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
import VersionSummary from "~/components/ui/VersionSummary.vue"; import VersionSummary from "~/components/ui/VersionSummary.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue"; import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import { getVersionsToDisplay } from "~/helpers/projects.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const data = useNuxtApp(); const data = useNuxtApp();
const route = useNativeRoute(); const route = useNativeRoute();
@@ -738,6 +1072,7 @@ const auth = await useAuth();
const user = await useUser(); const user = await useUser();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
@@ -789,6 +1124,152 @@ const gameVersionAccordion = ref();
const platformAccordion = ref(); const platformAccordion = ref();
const getModrinthAppAccordion = ref(); const getModrinthAppAccordion = ref();
const formatRelativeTime = useRelativeTime();
const compatibilityMessages = defineMessages({
title: {
id: "project.about.compatibility.title",
defaultMessage: "Compatibility",
},
minecraftJava: {
id: "project.about.compatibility.game.minecraftJava",
defaultMessage: "Minecraft: Java Edition",
},
platforms: {
id: "project.about.compatibility.platforms",
defaultMessage: "Platforms",
},
environments: {
id: "project.about.compatibility.environments",
defaultMessage: "Supported environments",
},
});
const linksMessages = defineMessages({
title: {
id: "project.about.links.title",
defaultMessage: "Links",
},
issues: {
id: "project.about.links.issues",
defaultMessage: "Report issues",
},
source: {
id: "project.about.links.source",
defaultMessage: "View source",
},
wiki: {
id: "project.about.links.wiki",
defaultMessage: "Visit wiki",
},
discord: {
id: "project.about.links.discord",
defaultMessage: "Join Discord server",
},
donateGeneric: {
id: "project.about.links.donate.generic",
defaultMessage: "Donate",
},
donateGitHub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
donateBmac: {
id: "project.about.links.donate.bmac",
defaultMessage: "Buy Me a Coffee",
},
donatePatreon: {
id: "project.about.links.donate.patreon",
defaultMessage: "Donate on Patreon",
},
donatePayPal: {
id: "project.about.links.donate.paypal",
defaultMessage: "Donate on PayPal",
},
donateKoFi: {
id: "project.about.links.donate.kofi",
defaultMessage: "Donate on Ko-fi",
},
donateGithub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
});
const creatorsMessages = defineMessages({
title: {
id: "project.about.creators.title",
defaultMessage: "Creators",
},
owner: {
id: "project.about.creators.owner",
defaultMessage: "Project owner",
},
});
const detailsMessages = defineMessages({
title: {
id: "project.about.details.title",
defaultMessage: "Details",
},
licensed: {
id: "project.about.details.licensed",
defaultMessage: "Licensed {license}",
},
created: {
id: "project.about.details.created",
defaultMessage: "Created {date}",
},
submitted: {
id: "project.about.details.submitted",
defaultMessage: "Submitted {date}",
},
published: {
id: "project.about.details.published",
defaultMessage: "Published {date}",
},
updated: {
id: "project.about.details.updated",
defaultMessage: "Updated {date}",
},
});
const modalLicense = ref(null);
const licenseText = ref("");
const createdDate = computed(() =>
project.value.published ? formatRelativeTime(project.value.published) : "unknown",
);
const submittedDate = computed(() =>
project.value.queued ? formatRelativeTime(project.value.queued) : "unknown",
);
const publishedDate = computed(() =>
project.value.approved ? formatRelativeTime(project.value.approved) : "unknown",
);
const updatedDate = computed(() =>
project.value.updated ? formatRelativeTime(project.value.updated) : "unknown",
);
const licenseIdDisplay = computed(() => {
const id = project.value.license.id;
if (id === "LicenseRef-All-Rights-Reserved") {
return "ARR";
} else if (id.includes("LicenseRef")) {
return id.replaceAll("LicenseRef-", "").replaceAll("-", " ");
} else {
return id;
}
});
async function getLicenseData(event) {
modalLicense.value.show(event);
try {
const text = await useBaseFetch(`tag/license/${project.value.license.id}`);
licenseText.value = text.body || "License text could not be retrieved.";
} catch {
licenseText.value = "License text could not be retrieved.";
}
}
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
return versions.value.filter( return versions.value.filter(
(x) => (x) =>
@@ -830,9 +1311,9 @@ const messages = defineMessages({
id: "project.stats.followers-label", id: "project.stats.followers-label",
defaultMessage: "follower{count, plural, one {} other {s}}", defaultMessage: "follower{count, plural, one {} other {s}}",
}, },
aboutTab: { descriptionTab: {
id: "project.about.title", id: "project.description.title",
defaultMessage: "About", defaultMessage: "Description",
}, },
galleryTab: { galleryTab: {
id: "project.gallery.title", id: "project.gallery.title",
@@ -1223,7 +1704,7 @@ const navLinks = computed(() => {
return [ return [
{ {
label: formatMessage(messages.aboutTab), label: formatMessage(messages.descriptionTab),
href: projectUrl, href: projectUrl,
}, },
{ {

View File

@@ -1,5 +1,15 @@
<template> <template>
<div class="content"> <div class="content">
<div class="mb-3 flex">
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="ml-auto mt-auto"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="card changelog-wrapper"> <div class="card changelog-wrapper">
<div <div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)" v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
@@ -57,15 +67,6 @@
@switch-page="switchPage" @switch-page="switchPage"
/> />
</div> </div>
<div class="normal-page__sidebar">
<AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0)) &&
tags.approvedStatuses.includes(project.status)
"
/>
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
</div>
</template> </template>
<script setup> <script setup>
import { Pagination } from "@modrinth/ui"; import { Pagination } from "@modrinth/ui";
@@ -73,7 +74,6 @@ import { DownloadIcon } from "@modrinth/assets";
import { renderHighlightedString } from "~/helpers/highlight.js"; import { renderHighlightedString } from "~/helpers/highlight.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue"; import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({ const props = defineProps({
project: { project: {
@@ -96,9 +96,6 @@ const props = defineProps({
}, },
}); });
const auth = await useAuth();
const tags = useTags();
const title = `${props.project.title} - Changelog`; const title = `${props.project.title} - Changelog`;
const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`; const description = `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`;
@@ -114,9 +111,9 @@ const route = useNativeRoute();
const currentPage = ref(Number(route.query.page ?? 1)); const currentPage = ref(Number(route.query.page ?? 1));
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? []; const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? []; const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? []; const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
return props.versions.filter( return props.versions.filter(
(projectVersion) => (projectVersion) =>

View File

@@ -1,18 +1,4 @@
<template> <template>
<NewModal ref="modalLicense" :header="project.license.name ? project.license.name : 'License'">
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" no-shadow />
<span class="text-lg font-extrabold text-contrast">
{{ project.license.name ? project.license.name : "License" }}
</span>
</template>
<div
class="markdown-body"
v-html="
renderString(licenseText).isEmpty ? 'Loading license text...' : renderString(licenseText)
"
/>
</NewModal>
<section class="normal-page__content"> <section class="normal-page__content">
<div <div
v-if="project.body" v-if="project.body"
@@ -20,333 +6,12 @@
v-html="renderHighlightedString(project.body || '')" v-html="renderHighlightedString(project.body || '')"
/> />
</section> </section>
<div class="normal-page__sidebar">
<AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0)) &&
tags.approvedStatuses.includes(project.status)
"
/>
<div v-if="versions.length > 0" class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(compatibilityMessages.title) }}</h2>
<section>
<h3>{{ formatMessage(compatibilityMessages.minecraftJava) }}</h3>
<div class="tag-list">
<div
v-for="version in getVersionsToDisplay(project)"
:key="`version-tag-${version}`"
class="tag-list__item"
>
{{ version }}
</div>
</div>
</section>
<section v-if="project.project_type !== 'resourcepack'">
<h3>{{ formatMessage(compatibilityMessages.platforms) }}</h3>
<div class="tag-list">
<div
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:class="`tag-list__item`"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</div>
</div>
</section>
<section
v-if="
(project.actualProjectType === 'mod' || project.project_type === 'modpack') &&
!(project.client_side === 'unsupported' && project.server_side === 'unsupported') &&
!(project.client_side === 'unknown' && project.server_side === 'unknown')
"
>
<h3>{{ formatMessage(compatibilityMessages.environments) }}</h3>
<div class="status-list">
<div
v-if="
(project.client_side === 'required' && project.server_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
class="status-list__item"
>
<ClientIcon aria-hidden="true" />
Client-side
</div>
<div
v-if="
(project.server_side === 'required' && project.client_side !== 'required') ||
(project.client_side === 'optional' && project.server_side === 'optional')
"
class="status-list__item"
>
<ServerIcon aria-hidden="true" />
Server-side
</div>
<div v-if="false" class="status-list__item">
<UserIcon aria-hidden="true" />
Singleplayer
</div>
<div
v-if="project.client_side === 'required' && project.server_side === 'required'"
class="status-list__item"
>
<MonitorSmartphoneIcon aria-hidden="true" />
Client and server
</div>
<div
v-else-if="
project.client_side === 'optional' ||
(project.client_side === 'required' && project.server_side === 'optional') ||
project.server_side === 'optional' ||
(project.server_side === 'required' && project.client_side === 'optional')
"
class="status-list__item"
>
<MonitorSmartphoneIcon aria-hidden="true" />
Client and server <span class="text-sm">(optional)</span>
</div>
</div>
</section>
</div>
<div
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0
"
class="card flex-card experimental-styles-within"
>
<h2>{{ formatMessage(linksMessages.title) }}</h2>
<div class="links-list">
<a
v-if="project.issues_url"
:href="project.issues_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<IssuesIcon aria-hidden="true" />
{{ formatMessage(linksMessages.issues) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.source_url"
:href="project.source_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<CodeIcon aria-hidden="true" />
{{ formatMessage(linksMessages.source) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<WikiIcon aria-hidden="true" />
{{ formatMessage(linksMessages.wiki) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<DiscordIcon class="shrink" aria-hidden="true" />
{{ formatMessage(linksMessages.discord) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<hr
v-if="
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
project.donation_urls.length > 0
"
/>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
:target="$external()"
rel="noopener nofollow ugc"
>
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
<HeartIcon v-else-if="donation.id === 'github'" />
<CurrencyIcon v-else />
<span v-if="donation.id === 'bmac'">{{ formatMessage(linksMessages.donateBmac) }}</span>
<span v-else-if="donation.id === 'patreon'">{{
formatMessage(linksMessages.donatePatreon)
}}</span>
<span v-else-if="donation.id === 'paypal'">{{
formatMessage(linksMessages.donatePayPal)
}}</span>
<span v-else-if="donation.id === 'ko-fi'">{{
formatMessage(linksMessages.donateKoFi)
}}</span>
<span v-else-if="donation.id === 'github'">{{
formatMessage(linksMessages.donateGithub)
}}</span>
<span v-else>{{ formatMessage(linksMessages.donateGeneric) }}</span>
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(creatorsMessages.title) }}</h2>
<div class="details-list">
<template v-if="organization">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/organization/${organization.slug}`"
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="rows">
<span>
{{ organization.name }}
</span>
<span class="details-list__item__text--style-secondary">Organization</span>
</div>
</nuxt-link>
<hr v-if="members.length > 0" />
</template>
<nuxt-link
v-for="member in members"
:key="`member-${member.id}`"
class="details-list__item details-list__item--type-large"
:to="'/user/' + member.user.username"
>
<Avatar :src="member.avatar_url" :alt="member.name" size="32px" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member.name }}
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(creatorsMessages.owner)"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">{{ member.role }}</span>
</div>
</nuxt-link>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
Licensed
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="$external()"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
class="text-link hover:underline"
@click="(event) => getLicenseData(event)"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
</div>
<div
v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script setup> <script setup>
import {
CalendarIcon,
IssuesIcon,
WikiIcon,
OpenCollectiveIcon,
DiscordIcon,
ScaleIcon,
KoFiIcon,
BookTextIcon,
PayPalIcon,
CrownIcon,
BuyMeACoffeeIcon,
CurrencyIcon,
PatreonIcon,
HeartIcon,
VersionIcon,
ExternalIcon,
CodeIcon,
UserIcon,
ServerIcon,
ClientIcon,
MonitorSmartphoneIcon,
} from "@modrinth/assets";
import { NewModal, Avatar } from "@modrinth/ui";
import { formatCategory, renderString } from "@modrinth/utils";
import { renderHighlightedString } from "~/helpers/highlight.js"; import { renderHighlightedString } from "~/helpers/highlight.js";
import { getVersionsToDisplay } from "~/helpers/projects.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({ defineProps({
project: { project: {
type: Object, type: Object,
default() { default() {
@@ -372,153 +37,4 @@ const props = defineProps({
}, },
}, },
}); });
const auth = await useAuth();
const tags = useTags();
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const compatibilityMessages = defineMessages({
title: {
id: "project.about.compatibility.title",
defaultMessage: "Compatibility",
},
minecraftJava: {
id: "project.about.compatibility.game.minecraftJava",
defaultMessage: "Minecraft: Java Edition",
},
platforms: {
id: "project.about.compatibility.platforms",
defaultMessage: "Platforms",
},
environments: {
id: "project.about.compatibility.environments",
defaultMessage: "Supported environments",
},
});
const linksMessages = defineMessages({
title: {
id: "project.about.links.title",
defaultMessage: "Links",
},
issues: {
id: "project.about.links.issues",
defaultMessage: "Report issues",
},
source: {
id: "project.about.links.source",
defaultMessage: "View source",
},
wiki: {
id: "project.about.links.wiki",
defaultMessage: "Visit wiki",
},
discord: {
id: "project.about.links.discord",
defaultMessage: "Join Discord server",
},
donateGeneric: {
id: "project.about.links.donate.generic",
defaultMessage: "Donate",
},
donateGitHub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
donateBmac: {
id: "project.about.links.donate.bmac",
defaultMessage: "Buy Me a Coffee",
},
donatePatreon: {
id: "project.about.links.donate.patreon",
defaultMessage: "Donate on Patreon",
},
donatePayPal: {
id: "project.about.links.donate.paypal",
defaultMessage: "Donate on PayPal",
},
donateKoFi: {
id: "project.about.links.donate.kofi",
defaultMessage: "Donate on Ko-fi",
},
donateGithub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
});
const creatorsMessages = defineMessages({
title: {
id: "project.about.creators.title",
defaultMessage: "Creators",
},
owner: {
id: "project.about.creators.owner",
defaultMessage: "Project owner",
},
});
const detailsMessages = defineMessages({
title: {
id: "project.about.details.title",
defaultMessage: "Details",
},
licensed: {
id: "project.about.details.licensed",
defaultMessage: "Licensed {license}",
},
created: {
id: "project.about.details.created",
defaultMessage: "Created {date}",
},
submitted: {
id: "project.about.details.submitted",
defaultMessage: "Submitted {date}",
},
published: {
id: "project.about.details.published",
defaultMessage: "Published {date}",
},
updated: {
id: "project.about.details.updated",
defaultMessage: "Updated {date}",
},
});
const modalLicense = ref(null);
const licenseText = ref("");
const createdDate = computed(() =>
props.project.published ? formatRelativeTime(props.project.published) : "unknown",
);
const submittedDate = computed(() =>
props.project.queued ? formatRelativeTime(props.project.queued) : "unknown",
);
const publishedDate = computed(() =>
props.project.approved ? formatRelativeTime(props.project.approved) : "unknown",
);
const updatedDate = computed(() =>
props.project.updated ? formatRelativeTime(props.project.updated) : "unknown",
);
const licenseIdDisplay = computed(() => {
const id = props.project.license.id;
if (id === "LicenseRef-All-Rights-Reserved") {
return "ARR";
} else if (id.includes("LicenseRef")) {
return id.replaceAll("LicenseRef-", "").replaceAll("-", " ");
} else {
return id;
}
});
async function getLicenseData(event) {
modalLicense.value.show(event);
try {
const text = await useBaseFetch(`tag/license/${props.project.license.id}`);
licenseText.value = text.body || "License text could not be retrieved.";
} catch {
licenseText.value = "License text could not be retrieved.";
}
}
</script> </script>

View File

@@ -471,7 +471,7 @@
<div class="normal-page__sidebar version-page__metadata"> <div class="normal-page__sidebar version-page__metadata">
<AdPlaceholder <AdPlaceholder
v-if=" v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0)) && (!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
tags.approvedStatuses.includes(project.status) tags.approvedStatuses.includes(project.status)
" "
/> />
@@ -749,6 +749,7 @@ export default defineNuxtComponent({
const auth = await useAuth(); const auth = await useAuth();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const path = route.name.split("-"); const path = route.name.split("-");
const mode = path[path.length - 1]; const mode = path[path.length - 1];
@@ -896,6 +897,7 @@ export default defineNuxtComponent({
return { return {
auth, auth,
tags, tags,
flags,
fileTypes: ref(fileTypes), fileTypes: ref(fileTypes),
oldFileTypes: ref(oldFileTypes), oldFileTypes: ref(oldFileTypes),
isCreating: ref(isCreating), isCreating: ref(isCreating),

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="normal-page__content experimental-styles-within overflow-visible"> <section class="experimental-styles-within overflow-visible">
<div <div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)" v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4" class="card flex items-center gap-4"
@@ -19,6 +19,20 @@
</span> </span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" /> <DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div> </div>
<div class="mb-3 flex flex-wrap gap-2">
<VersionFilterControl
ref="versionFilters"
:versions="props.versions"
@switch-page="switchPage"
/>
<Pagination
:page="currentPage"
class="ml-auto mt-auto"
:count="Math.ceil(filteredVersions.length / 20)"
:link-function="(page) => `?page=${currentPage}`"
@switch-page="switchPage"
/>
</div>
<div <div
v-if="versions.length > 0" v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]" class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
@@ -69,8 +83,12 @@
<div class="flex flex-col justify-center gap-2 sm:contents"> <div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row items-center gap-2 sm:contents"> <div class="flex flex-row items-center gap-2 sm:contents">
<div class="self-center"> <div class="self-center">
<div class="pointer-events-none relative z-[1]"> <div class="relative z-[1] cursor-pointer">
<VersionChannelIndicator :channel="version.version_type" /> <VersionChannelIndicator
v-tooltip="`Toggle filter for ${version.version_type}`"
:channel="version.version_type"
@click="versionFilters.toggleFilter('channel', version.version_type)"
/>
</div> </div>
</div> </div>
<div <div
@@ -115,7 +133,13 @@
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents" class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
> >
<div <div
class="pointer-events-none z-[1] flex items-center gap-1 text-nowrap font-medium xl:self-center" v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(version.date_published),
time: new Date(version.date_published),
})
"
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
> >
<CalendarIcon class="xl:hidden" /> <CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(version.date_published) }} {{ formatRelativeTime(version.date_published) }}
@@ -232,7 +256,10 @@
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full"> <div
v-if="flags.showVersionFilesInTable"
class="tag-list pointer-events-none relative z-[1] col-span-full"
>
<div <div
v-for="(file, fileIdx) in version.files" v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`" :key="`platform-tag-${fileIdx}`"
@@ -254,19 +281,6 @@
/> />
</div> </div>
</section> </section>
<div class="normal-page__sidebar">
<AdPlaceholder
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0)) &&
tags.approvedStatuses.includes(project.status)
"
/>
<VersionFilterControl
ref="versionFilters"
:versions="props.versions"
@switch-page="switchPage"
/>
</div>
</template> </template>
<script setup> <script setup>
@@ -296,9 +310,9 @@ import { formatVersionsForDisplay } from "~/helpers/projects.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue"; import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import DropArea from "~/components/ui/DropArea.vue"; import DropArea from "~/components/ui/DropArea.vue";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js"; import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const formatCompactNumber = useCompactNumber(); const formatCompactNumber = useCompactNumber();
const { formatMessage } = useVIntl();
const props = defineProps({ const props = defineProps({
project: { project: {
@@ -321,8 +335,8 @@ const props = defineProps({
}, },
}); });
const auth = await useAuth();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const formatRelativeTime = useRelativeTime(); const formatRelativeTime = useRelativeTime();
const emits = defineEmits(["onDownload"]); const emits = defineEmits(["onDownload"]);
@@ -332,8 +346,6 @@ const router = useNativeRouter();
const currentPage = ref(route.query.page ?? 1); const currentPage = ref(route.query.page ?? 1);
const showFiles = ref(false);
function switchPage(page) { function switchPage(page) {
currentPage.value = page; currentPage.value = page;
@@ -349,22 +361,30 @@ function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]; return version.files.find((x) => x.primary) || version.files[0];
} }
const selectedGameVersions = computed(() => {
return getArrayOrString(route.query.g) ?? [];
});
const selectedPlatforms = computed(() => {
return getArrayOrString(route.query.l) ?? [];
});
const selectedVersionChannels = computed(() => {
return getArrayOrString(route.query.c) ?? [];
});
const versionFilters = ref(null); const versionFilters = ref(null);
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? [];
return props.versions.filter( return props.versions.filter(
(projectVersion) => (projectVersion) =>
(selectedGameVersions.length === 0 || (selectedGameVersions.value.length === 0 ||
selectedGameVersions.some((gameVersion) => selectedGameVersions.value.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion), projectVersion.game_versions.includes(gameVersion),
)) && )) &&
(selectedLoaders.length === 0 || (selectedPlatforms.value.length === 0 ||
selectedLoaders.some((loader) => projectVersion.loaders.includes(loader))) && selectedPlatforms.value.some((loader) => projectVersion.loaders.includes(loader))) &&
(selectedVersionTypes.length === 0 || (selectedVersionChannels.value.length === 0 ||
selectedVersionTypes.includes(projectVersion.version_type)), selectedVersionChannels.value.includes(projectVersion.version_type)),
); );
}); });

View File

@@ -248,7 +248,9 @@
</div> </div>
</template> </template>
</div> </div>
<AdPlaceholder v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" /> <AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<nav class="navigation-card"> <nav class="navigation-card">
@@ -480,6 +482,7 @@ const route = useNativeRoute();
const auth = await useAuth(); const auth = await useAuth();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const isEditing = ref(false); const isEditing = ref(false);

View File

@@ -109,7 +109,9 @@
</div> </div>
</div> </div>
<AdPlaceholder v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" /> <AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/>
<div class="creator-list universal-card"> <div class="creator-list universal-card">
<div class="title-and-link"> <div class="title-and-link">
@@ -253,6 +255,7 @@ const user = await useUser();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const route = useNativeRoute(); const route = useNativeRoute();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
let orgId = useRouteId(); let orgId = useRouteId();

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
class="new-page sidebar experimental-styles-within" class="new-page sidebar experimental-styles-within"
:class="{ 'alt-layout': cosmetics.searchLayout }" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }"
> >
<Head> <Head>
<Title>Search {{ projectType.display }}s - Modrinth</Title> <Title>Search {{ projectType.display }}s - Modrinth</Title>
@@ -12,7 +12,9 @@
}" }"
aria-label="Filters" aria-label="Filters"
> >
<AdPlaceholder v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" /> <AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/>
<section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }"> <section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="iconified-input w-full"> <div class="iconified-input w-full">
@@ -202,6 +204,14 @@
</button> </button>
</div> </div>
</div> </div>
<pagination
v-if="false"
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="mb-3 justify-end"
@switch-page="onSearchChangeToTop"
/>
<LogoAnimated v-if="searchLoading && !noLoad" /> <LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results"> <div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p> <p>No results found for your query!</p>
@@ -279,6 +289,7 @@ const route = useNativeRoute();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const auth = await useAuth(); const auth = await useAuth();
const query = ref(""); const query = ref("");

View File

@@ -190,15 +190,15 @@
<div class="adjacent-input small"> <div class="adjacent-input small">
<label for="search-layout-toggle"> <label for="search-layout-toggle">
<span class="label__title"> <span class="label__title">
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarTitle) }} {{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
</span> </span>
<span class="label__description"> <span class="label__description">
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarDescription) }} {{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
</span> </span>
</label> </label>
<input <input
id="search-layout-toggle" id="search-layout-toggle"
v-model="cosmetics.searchLayout" v-model="cosmetics.rightSearchLayout"
class="switch stylized-toggle" class="switch stylized-toggle"
type="checkbox" type="checkbox"
/> />
@@ -206,15 +206,15 @@
<div class="adjacent-input small"> <div class="adjacent-input small">
<label for="project-layout-toggle"> <label for="project-layout-toggle">
<span class="label__title"> <span class="label__title">
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarTitle) }} {{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
</span> </span>
<span class="label__description"> <span class="label__description">
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarDescription) }} {{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
</span> </span>
</label> </label>
<input <input
id="project-layout-toggle" id="project-layout-toggle"
v-model="cosmetics.projectLayout" v-model="cosmetics.leftContentLayout"
class="switch stylized-toggle" class="switch stylized-toggle"
type="checkbox" type="checkbox"
/> />
@@ -368,21 +368,21 @@ const toggleFeatures = defineMessages({
defaultMessage: defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.', 'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
}, },
leftAlignedSearchSidebarTitle: { rightAlignedFiltersSidebarTitle: {
id: "settings.display.sidebar.Left-aligned-search-sidebar.title", id: "settings.display.sidebar.right-aligned-filters-sidebar.title",
defaultMessage: "Left-aligned search sidebar", defaultMessage: "Right-aligned filters sidebar on search pages",
}, },
leftAlignedSearchSidebarDescription: { rightAlignedFiltersSidebarDescription: {
id: "settings.display.sidebar.left-aligned-search-sidebar.description", id: "settings.display.sidebar.right-aligned-filters-sidebar.description",
defaultMessage: "Aligns the search filters sidebar to the left of the search results.", defaultMessage: "Aligns the filters sidebar to the right of the search results.",
}, },
leftAlignedProjectSidebarTitle: { leftAlignedContentSidebarTitle: {
id: "settings.display.sidebar.left-aligned-project-sidebar.title", id: "settings.display.sidebar.left-aligned-content-sidebar.title",
defaultMessage: "Left-aligned project sidebar", defaultMessage: "Left-aligned sidebar on content pages",
}, },
leftAlignedProjectSidebarDescription: { leftAlignedContentSidebarDescription: {
id: "settings.display.sidebar.left-aligned-project-sidebar.description", id: "settings.display.sidebar.right-aligned-content-sidebar.description",
defaultMessage: "Aligns the project details sidebar to the left of the page's content.", defaultMessage: "Aligns the sidebar to the left of the page's content.",
}, },
}); });

View File

@@ -2,7 +2,7 @@
<div v-if="user" class="experimental-styles-within"> <div v-if="user" class="experimental-styles-within">
<ModalCreation ref="modal_creation" /> <ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" /> <CollectionCreateModal ref="modal_collection_creation" />
<div class="new-page sidebar"> <div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
<div class="normal-page__header pt-4"> <div class="normal-page__header pt-4">
<div <div
class="mb-4 grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]" class="mb-4 grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
@@ -72,11 +72,11 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="normal-page__content">
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto"> <div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" /> <NavTabs :links="navLinks" />
</div> </div>
</div>
<div class="normal-page__content">
<div v-if="projects.length > 0"> <div v-if="projects.length > 0">
<div <div
v-if="route.params.projectType !== 'collections'" v-if="route.params.projectType !== 'collections'"
@@ -194,7 +194,6 @@
</div> </div>
</div> </div>
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<AdPlaceholder v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" />
<div class="card flex-card"> <div class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileDetails) }}</h2> <h2 class="text-lg text-contrast">{{ formatMessage(messages.profileDetails) }}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -258,6 +257,9 @@
</div> </div>
</div> </div>
</div> </div>
<AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
/>
<div v-if="organizations.length > 0" class="card flex-card"> <div v-if="organizations.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileOrganizations) }}</h2> <h2 class="text-lg text-contrast">{{ formatMessage(messages.profileOrganizations) }}</h2>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@@ -328,6 +330,7 @@ const route = useNativeRoute();
const auth = await useAuth(); const auth = await useAuth();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const tags = useTags(); const tags = useTags();
const flags = useFeatureFlags();
const vintl = useVIntl(); const vintl = useVIntl();
const { formatMessage } = vintl; const { formatMessage } = vintl;

View File

@@ -13,8 +13,8 @@ export type DisplayLocation =
| "collection"; | "collection";
export interface Cosmetics { export interface Cosmetics {
searchLayout: boolean; rightSearchLayout: boolean;
projectLayout: boolean; leftContentLayout: boolean;
advancedRendering: boolean; advancedRendering: boolean;
externalLinksNewTab: boolean; externalLinksNewTab: boolean;
notUsingBlockers: boolean; notUsingBlockers: boolean;
@@ -34,8 +34,8 @@ export default defineNuxtPlugin({
httpOnly: false, httpOnly: false,
path: "/", path: "/",
default: () => ({ default: () => ({
searchLayout: false, rightSearchLayout: false,
projectLayout: false, leftContentLayout: false,
advancedRendering: true, advancedRendering: true,
externalLinksNewTab: true, externalLinksNewTab: true,
notUsingBlockers: false, notUsingBlockers: false,

View File

@@ -24,6 +24,8 @@ module.exports = {
heading: "var(--color-heading)", heading: "var(--color-heading)",
red: "var(--color-red)", red: "var(--color-red)",
orange: "var(--color-orange)", orange: "var(--color-orange)",
green: "var(--color-green)",
blue: "var(--color-blue)",
purple: "var(--color-purple)", purple: "var(--color-purple)",
bg: { bg: {
DEFAULT: "var(--color-bg)", DEFAULT: "var(--color-bg)",

View File

@@ -0,0 +1,116 @@
<template>
<ButtonStyled>
<PopoutMenu
v-if="options.length > 1"
v-bind="$attrs"
:disabled="disabled"
:position="position"
:direction="direction"
@open="() => {
searchQuery = ''
}"
>
<slot />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #menu>
<div class="iconified-input mb-2 w-full" v-if="search">
<label for="search-input" hidden>Search...</label>
<SearchIcon aria-hidden="true" />
<input id="search-input" v-model="searchQuery" placeholder="Search..." type="text" ref="searchInput" @keydown.enter="() => {
toggleOption(filteredOptions[0])
}" />
</div>
<ScrollablePanel
v-if="search" class="h-[17rem]">
<Button
v-for="(option, index) in filteredOptions"
:key="`option-${index}`"
:transparent="!manyValues.includes(option)"
:action="() => toggleOption(option)"
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<CheckIcon class="h-5 w-5 text-contrast ml-auto transition-opacity" :class="{ 'opacity-0': !manyValues.includes(option) }" />
</Button>
</ScrollablePanel>
<div
v-else class="flex flex-col gap-1">
<Button
v-for="(option, index) in filteredOptions"
:key="`option-${index}`"
:transparent="!manyValues.includes(option)"
:action="() => toggleOption(option)"
class="!w-full"
:color="manyValues.includes(option) ? 'secondary' : 'default'"
>
<slot name="option" :option="option">{{ displayName(option) }}</slot>
<CheckIcon class="h-5 w-5 text-contrast ml-auto transition-opacity" :class="{ 'opacity-0': !manyValues.includes(option) }" />
</Button>
</div>
<slot name="footer" />
</template>
</PopoutMenu>
</ButtonStyled>
</template>
<script setup lang="ts">
import { CheckIcon, DropdownIcon, SearchIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, PopoutMenu, Button } from '../index'
import { computed, ref } from 'vue'
import ScrollablePanel from './ScrollablePanel.vue'
type Option = string | number | object
const props = withDefaults(
defineProps<{
modelValue: Option[],
options: Option[]
disabled?: boolean
position?: string
direction?: string,
displayName?: (option: Option) => string,
search?: boolean
}>(),
{
disabled: false,
position: 'auto',
direction: 'auto',
displayName: (option: Option) => option as string,
search: false,
},
)
const emit = defineEmits(['update:modelValue', 'change']);
const selectedValues = ref(props.modelValue || [])
const searchInput = ref();
const searchQuery = ref('')
const manyValues = computed({
get() {
return props.modelValue || selectedValues.value
},
set(newValue) {
emit('update:modelValue', newValue)
emit('change', newValue)
selectedValues.value = newValue
},
})
const filteredOptions = computed(() => {
return props.options.filter((x) => !searchQuery.value || props.displayName(x).toLowerCase().includes(searchQuery.value.toLowerCase()))
})
defineOptions({
inheritAttrs: false,
})
function toggleOption(id: Option) {
if (manyValues.value.includes(id)) {
manyValues.value = manyValues.value.filter((x) => x !== id)
} else {
manyValues.value = [...manyValues.value, id]
}
}
</script>

View File

@@ -4,7 +4,7 @@
v-bind="$attrs" v-bind="$attrs"
ref="dropdownButton" ref="dropdownButton"
:class="{ 'popout-open': dropdownVisible }" :class="{ 'popout-open': dropdownVisible }"
tabindex="-1" :tabindex="tabInto ? -1 : 0"
@click="toggleDropdown" @click="toggleDropdown"
> >
<slot></slot> <slot></slot>
@@ -12,6 +12,7 @@
<div <div
class="popup-menu" class="popup-menu"
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`" :class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
:inert="!tabInto && !dropdownVisible"
> >
<slot name="menu"> </slot> <slot name="menu"> </slot>
</div> </div>
@@ -34,11 +35,17 @@ const props = defineProps({
type: String, type: String,
default: 'auto', default: 'auto',
}, },
tabInto: {
type: Boolean,
default: false,
},
}) })
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const emit = defineEmits(['open', 'close'])
const dropdownVisible = ref(false) const dropdownVisible = ref(false)
const dropdown = ref(null) const dropdown = ref(null)
const dropdownButton = ref(null) const dropdownButton = ref(null)
@@ -71,8 +78,11 @@ function updateDirection() {
const toggleDropdown = () => { const toggleDropdown = () => {
if (!props.disabled) { if (!props.disabled) {
dropdownVisible.value = !dropdownVisible.value dropdownVisible.value = !dropdownVisible.value
if (!dropdownVisible.value) { if (dropdownVisible.value) {
emit('open')
} else {
dropdownButton.value.focus() dropdownButton.value.focus()
emit('close')
} }
} }
} }
@@ -80,10 +90,12 @@ const toggleDropdown = () => {
const hide = () => { const hide = () => {
dropdownVisible.value = false dropdownVisible.value = false
dropdownButton.value.focus() dropdownButton.value.focus()
emit('close')
} }
const show = () => { const show = () => {
dropdownVisible.value = true dropdownVisible.value = true
emit('open')
} }
defineExpose({ defineExpose({
@@ -99,6 +111,7 @@ const handleClickOutside = (event) => {
!dropdown.value.contains(event.target) !dropdown.value.contains(event.target)
) { ) {
dropdownVisible.value = false dropdownVisible.value = false
emit('close')
} }
} }
@@ -106,6 +119,7 @@ onMounted(() => {
window.addEventListener('click', handleClickOutside) window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', updateDirection) window.addEventListener('resize', updateDirection)
window.addEventListener('scroll', updateDirection) window.addEventListener('scroll', updateDirection)
window.addEventListener('keydown', handleKeyDown)
updateDirection() updateDirection()
}) })
@@ -113,7 +127,14 @@ onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside) window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateDirection) window.removeEventListener('resize', updateDirection)
window.removeEventListener('scroll', updateDirection) window.removeEventListener('scroll', updateDirection)
window.removeEventListener('keydown', handleKeyDown)
}) })
function handleKeyDown(event) {
if (event.key === 'Escape') {
hide()
}
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -107,7 +107,7 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
.scrollable-pane { .scrollable-pane {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.25rem;
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;

View File

@@ -13,6 +13,7 @@ export { default as DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue' export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue' export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
export { default as FileInput } from './base/FileInput.vue' export { default as FileInput } from './base/FileInput.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue' export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
export { default as Notifications } from './base/Notifications.vue' export { default as Notifications } from './base/Notifications.vue'
export { default as OverflowMenu } from './base/OverflowMenu.vue' export { default as OverflowMenu } from './base/OverflowMenu.vue'

View File

@@ -32,7 +32,6 @@ const messages = defineMessages({
<template> <template>
<div <div
:style="`--_size: ${size};`"
:class="`flex ${large ? 'text-lg w-[2.625rem] h-[2.625rem]' : 'text-sm w-9 h-9'} font-bold justify-center items-center rounded-full ${channel === 'release' ? 'bg-bg-green text-brand-green' : channel === 'beta' ? 'bg-bg-orange text-brand-orange' : 'bg-bg-red text-brand-red'}`" :class="`flex ${large ? 'text-lg w-[2.625rem] h-[2.625rem]' : 'text-sm w-9 h-9'} font-bold justify-center items-center rounded-full ${channel === 'release' ? 'bg-bg-green text-brand-green' : channel === 'beta' ? 'bg-bg-orange text-brand-orange' : 'bg-bg-red text-brand-red'}`"
> >
{{ channel ? formatMessage(messages[`${channel}Symbol`]) : '?' }} {{ channel ? formatMessage(messages[`${channel}Symbol`]) : '?' }}