Project, Search, User redesign (#1281)

* New project page

* fix silly icon tailwind classes

* Start new versions page, add new ButtonStyled component

* Pagination and finish mocking up versions page functionality

* green download button

* hover animation

* New Modal, Avatar refactor, subpages in NavTabs

* lint

* Download modal

* New user page + fix lint

* fix ui lint

* Download animation fix

* Versions filter + finish project page

* Improve consistency of buttons on home page

* Fix ButtonStyled breaking

* Fix margin on version summary

* finish search, new modals, user + project page mobile

* fix gallery image pages

* New project header

* Fix gallery tab showing improperly

* Use auto direction + position for all popouts

* Preliminary user page

* test to see if this fixes login stuff

* remove extra slash

* Add version actions, move download button on versions page

* Listed -> public

* Shorten download modal selector height

* Fix user menu open direction

* Change breakpoint for header collapse

* Only underline title

* Tighten padding on stats a little

* New nav

* Make mobile breakpoint more consistent

* fix header breakpoint regression

* Add sign in button

* Fix edit icon color

* Fix margin at top of screen

* Fix user bios and ad width

* Fix user nav showing when there's only one type of project

* Fix plural projects on user page & extract i18n

* Remove ads on mobile for now

* Fix overflow menu showing hidden items

* NavTabs on mobile

* Fix navbar z index

* Search filter overhaul + negative filters

* fix no-max-height

* port version filters, fix following/collections, lint

* hide promos

* ui lint

* Disable modal background animation to reduce reported motion sickness

* Hide install with modrinth app button on mobile

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
Geometrically
2024-08-20 23:03:16 -07:00
committed by GitHub
parent a19ce0458a
commit 2d416d491c
101 changed files with 5361 additions and 4488 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,5 @@
<template>
<div class="content">
<VersionFilterControl :versions="props.versions" />
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div class="card changelog-wrapper">
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
@@ -60,17 +52,23 @@
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder />
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
</div>
</template>
<script setup>
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import { Pagination } from "@modrinth/ui";
import { DownloadIcon } from "@modrinth/assets";
import { renderHighlightedString } from "~/helpers/highlight.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import Pagination from "~/components/ui/Pagination.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({
project: {
@@ -106,11 +104,11 @@ useSeoMeta({
const router = useNativeRouter();
const route = useNativeRoute();
const currentPage = ref(Number(route.query.p ?? 1));
const currentPage = ref(Number(route.query.page ?? 1));
const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? [];
return props.versions.filter(
(projectVersion) =>
@@ -131,7 +129,7 @@ function switchPage(page) {
router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
@@ -250,4 +248,8 @@ function switchPage(page) {
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@@ -794,4 +794,8 @@ export default defineNuxtComponent({
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@@ -1,23 +1,526 @@
<template>
<div
v-if="project.body"
class="markdown-body card"
v-html="renderHighlightedString(project.body || '')"
/>
<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">
<div
v-if="project.body"
class="markdown-body card"
v-html="renderHighlightedString(project.body || '')"
/>
</section>
<div class="normal-page__sidebar">
<AdPlaceholder />
<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>
<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>
<h3>{{ formatMessage(compatibilityMessages.environments) }}</h3>
<div class="status-list">
<div class="status-list__item status-list__item--color-green">
<CheckIcon />
Singleplayer
</div>
<div
v-if="project.client_side !== 'unsupported' && project.server_side !== 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Client and server
</div>
<div
v-if="project.client_side === 'required' && project.server_side === 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Client
</div>
<div
v-if="project.server_side === 'required' && project.client_side === 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Server
</div>
<div
v-if="
project.client_side === 'optional' ||
(project.client_side === 'required' && project.server_side === 'optional')
"
class="status-list__item status-list__item--color-orange"
>
<CheckIcon />
Client <span class="text-sm">(Limited functionality)</span>
</div>
<div
v-if="
project.server_side === 'optional' ||
(project.server_side === 'required' && project.client_side === 'optional')
"
class="status-list__item status-list__item--color-orange"
>
<CheckIcon />
Server <span class="text-sm">(Limited functionality)</span>
</div>
<div
v-if="project.client_side === 'unsupported'"
class="status-list__item status-list__item--color-red"
>
<XIcon />
Client
</div>
<div
v-if="project.server_side === 'unsupported'"
class="status-list__item status-list__item--color-red"
>
<XIcon />
Server
</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>
<script>
import { renderHighlightedString } from "~/helpers/highlight.js";
<script setup>
import {
CheckIcon,
XIcon,
CalendarIcon,
IssuesIcon,
WikiIcon,
OpenCollectiveIcon,
DiscordIcon,
ScaleIcon,
KoFiIcon,
BookTextIcon,
PayPalIcon,
CrownIcon,
BuyMeACoffeeIcon,
CurrencyIcon,
PatreonIcon,
HeartIcon,
VersionIcon,
ExternalIcon,
CodeIcon,
} from "@modrinth/assets";
export default defineNuxtComponent({
props: {
project: {
type: Object,
default() {
return {};
},
import { NewModal, Avatar } from "@modrinth/ui";
import { formatCategory, renderString } from "@modrinth/utils";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { getVersionsToDisplay } from "~/helpers/projects.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
versions: {
type: Array,
default() {
return {};
},
},
members: {
type: Array,
default() {
return {};
},
},
organization: {
type: Object,
default() {
return {};
},
},
methods: { renderHighlightedString },
});
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: "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>

View File

@@ -201,7 +201,7 @@ async function setStatus(status) {
svg {
&.good {
color: var(--color-brand-green);
color: var(--color-green);
}
&.bad {

View File

@@ -151,7 +151,7 @@
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Listed and archived projects are visible in search. Unlisted projects are published, but
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
@@ -196,7 +196,7 @@
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => $formatProjectStatus(value)"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@@ -243,6 +243,7 @@
<script setup>
import { Multiselect } from "vue-multiselect";
import { formatProjectStatus } from "@modrinth/utils";
import Avatar from "~/components/ui/Avatar.vue";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import FileInput from "~/components/ui/FileInput.vue";
@@ -423,7 +424,7 @@ const deleteIcon = async () => {
svg {
&.good {
color: var(--color-brand-green);
color: var(--color-green);
}
&.bad {

View File

@@ -0,0 +1,164 @@
<template>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? "Primary file" : "Supplementary resource"
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from "@modrinth/assets";
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
import { formatBytes, renderHighlightedString } from "@modrinth/utils";
const router = useRouter();
const props = defineProps<{
project: Project;
versions: Version[];
featuredVersions: Version[];
members: User[];
currentMember: User;
dependencies: Dependency[];
resetProject: Function;
}>();
const version = computed(() => {
let version: Version | undefined;
if (route.params.version === "latest") {
let versionList = props.versions;
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader));
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
);
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: "Version not found",
});
}
return version;
});
// const data = useNuxtApp();
const route = useNativeRoute();
// const auth = await useAuth();
// const tags = useTags();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {
if (router.options.history.state.back.includes("/versions")) {
return router.options.history.state.back;
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`;
});
const hasBackLink = computed(
() =>
router.options.history.state.back && router.options.history.state.back.endsWith("/versions"),
);
const { formatMessage } = useVIntl();
const messages = defineMessages({
backToVersions: {
id: "project.version.back-to-versions",
defaultMessage: "Back to versions",
},
allVersions: {
id: "project.version.all-versions",
defaultMessage: "All versions",
},
});
</script>
<style lang="scss"></style>

View File

@@ -1,6 +1,6 @@
<template>
<div v-if="version" class="version-page">
<ModalConfirm
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this version?"
@@ -37,14 +37,18 @@
open-direction="top"
/>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_package_mod.hide()">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="createDataPackVersion">
<RightArrowIcon />
Begin packaging data pack
</button>
<ButtonStyled>
<button @click="$refs.modal_package_mod.hide()">
<CrossIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="createDataPackVersion">
<RightArrowIcon />
Begin packaging data pack
</button>
</ButtonStyled>
</div>
</div>
</Modal>
@@ -94,96 +98,102 @@
</ul>
</div>
<div v-if="isCreating" class="input-group">
<button
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="createVersion"
>
<PlusIcon aria-hidden="true" />
Create
</button>
<nuxt-link
v-if="auth.user"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
class="iconified-button"
>
<CrossIcon aria-hidden="true" />
Cancel
</nuxt-link>
<ButtonStyled color="brand">
<button :disabled="shouldPreventActions" @click="createVersion">
<PlusIcon aria-hidden="true" />
Create
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="auth.user"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
>
<CrossIcon aria-hidden="true" />
Cancel
</nuxt-link>
</ButtonStyled>
</div>
<div v-else-if="isEditing" class="input-group">
<button
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="saveEditedVersion"
>
<SaveIcon aria-hidden="true" />
Save
</button>
<button class="iconified-button" @click="version.featured = !version.featured">
<StarIcon aria-hidden="true" />
<template v-if="!version.featured"> Feature version</template>
<template v-else> Unfeature version</template>
</button>
<nuxt-link
v-if="currentMember"
class="action iconified-button"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
<CrossIcon aria-hidden="true" />
Discard changes
</nuxt-link>
<ButtonStyled color="brand">
<button :disabled="shouldPreventActions" @click="saveEditedVersion">
<SaveIcon aria-hidden="true" />
Save
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="version.featured = !version.featured">
<StarIcon aria-hidden="true" />
<template v-if="!version.featured"> Feature version</template>
<template v-else> Unfeature version</template>
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
<CrossIcon aria-hidden="true" />
Discard changes
</nuxt-link>
</ButtonStyled>
</div>
<div v-else class="input-group">
<a
v-if="primaryFile"
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
class="iconified-button brand-button"
:aria-label="`Download ${primaryFile.filename}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
<nuxt-link v-if="!auth.user" class="iconified-button" to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
<button v-else class="iconified-button" @click="() => reportVersion(version.id)">
<ReportIcon aria-hidden="true" />
Report
</button>
<nuxt-link
v-if="currentMember"
class="action iconified-button"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
<button
v-if="
currentMember &&
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="iconified-button"
@click="$refs.modal_package_mod.show()"
>
<BoxIcon aria-hidden="true" />
Package as mod
</button>
<button
v-if="currentMember"
class="iconified-button danger-button"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete
</button>
<ButtonStyled v-if="primaryFile" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</ButtonStyled>
<ButtonStyled v-if="!auth.user">
<nuxt-link to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="() => reportVersion(version.id)">
<ReportIcon aria-hidden="true" />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
</ButtonStyled>
<ButtonStyled>
<button
v-if="
currentMember &&
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
@click="$refs.modal_package_mod.show()"
>
<BoxIcon aria-hidden="true" />
Package as mod
</button>
</ButtonStyled>
<ButtonStyled>
<button v-if="currentMember" @click="$refs.modal_confirm.show()">
<TrashIcon aria-hidden="true" />
Delete
</button>
</ButtonStyled>
</div>
</div>
<div class="version-page__changelog universal-card">
@@ -242,14 +252,12 @@
{{ dependency.dependency_type }}
</span>
</div>
<button
v-if="isEditing && project.project_type !== 'modpack'"
class="iconified-button"
@click="version.dependencies.splice(index, 1)"
>
<TrashIcon />
Remove
</button>
<ButtonStyled v-if="isEditing && project.project_type !== 'modpack'">
<button @click="version.dependencies.splice(index, 1)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</div>
<div
v-for="(dependency, index) in deps.filter((x) => x.file_name)"
@@ -297,13 +305,12 @@
/>
</div>
<div class="input-group">
<button
class="iconified-button brand-button"
@click="addDependency(dependencyAddMode, newDependencyId, newDependencyType)"
>
<PlusIcon />
Add dependency
</button>
<ButtonStyled color="brand">
<button @click="addDependency(dependencyAddMode, newDependencyId, newDependencyType)">
<PlusIcon />
Add dependency
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -371,31 +378,32 @@
:show-labels="false"
:allow-empty="false"
/>
<button
v-if="isEditing"
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
class="iconified-button raised-button"
@click="
() => {
deleteFiles.push(file.hashes.sha1);
version.files.splice(index, 1);
oldFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
<a
v-else
:href="file.url"
class="iconified-button raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
>
<DownloadIcon />
Download
</a>
<ButtonStyled v-if="isEditing">
<button
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
@click="
() => {
deleteFiles.push(file.hashes.sha1);
version.files.splice(index, 1);
oldFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
</ButtonStyled>
<ButtonStyled v-else>
<a
:href="file.url"
class="raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
>
<DownloadIcon />
Download
</a>
</ButtonStyled>
</div>
<template v-if="isEditing">
<div v-for="(file, index) in newFiles" :key="index" class="file">
@@ -417,18 +425,20 @@
:show-labels="false"
:allow-empty="false"
/>
<button
class="iconified-button raised-button"
@click="
() => {
newFiles.splice(index, 1);
newFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
<ButtonStyled>
<button
class="raised-button"
@click="
() => {
newFiles.splice(index, 1);
newFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
</ButtonStyled>
</div>
<div class="additional-files">
<h4>Upload additional files</h4>
@@ -455,73 +465,100 @@
</div>
</template>
</div>
<div class="version-page__metadata">
<div class="universal-card full-width-inputs">
<h3>Metadata</h3>
<div>
<h4>Release channel</h4>
<Multiselect
v-if="isEditing"
v-model="version.version_type"
class="input"
placeholder="Select one"
:options="['release', 'beta', 'alpha']"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
</div>
<div class="normal-page__sidebar version-page__metadata">
<AdPlaceholder />
<div class="universal-card full-width-inputs">
<h3>Metadata</h3>
<div>
<h4>Release channel</h4>
<Multiselect
v-if="isEditing"
v-model="version.version_type"
class="input"
placeholder="Select one"
:options="['release', 'beta', 'alpha']"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<template v-else>
<Badge
v-if="version.version_type === 'release'"
class="value"
type="release"
color="green"
/>
<Badge
v-else-if="version.version_type === 'beta'"
class="value"
type="beta"
color="orange"
/>
<Badge
v-else-if="version.version_type === 'alpha'"
class="value"
type="alpha"
color="red"
/>
</template>
</div>
<div>
<h4>Version number</h4>
<div v-if="isEditing" class="iconified-input">
<label class="hidden" for="version-number">Version number</label>
<HashIcon aria-hidden="true" />
<input
id="version-number"
v-model="version.version_number"
type="text"
autocomplete="off"
maxlength="54"
/>
<template v-else>
<Badge
v-if="version.version_type === 'release'"
class="value"
type="release"
color="green"
/>
<Badge
v-else-if="version.version_type === 'beta'"
class="value"
type="beta"
color="orange"
/>
<Badge
v-else-if="version.version_type === 'alpha'"
class="value"
type="alpha"
color="red"
/>
</template>
</div>
<div>
<h4>Version number</h4>
<div v-if="isEditing" class="iconified-input">
<label class="hidden" for="version-number">Version number</label>
<HashIcon aria-hidden="true" />
<input
id="version-number"
v-model="version.version_number"
type="text"
autocomplete="off"
maxlength="54"
/>
</div>
<span v-else>{{ version.version_number }}</span>
</div>
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Multiselect
v-if="isEditing"
v-model="version.loaders"
<span v-else>{{ version.version_number }}</span>
</div>
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Multiselect
v-if="isEditing"
v-model="version.loaders"
:options="
tags.loaders
.filter((x) =>
x.supported_project_types.includes(project.actualProjectType.toLowerCase()),
)
.map((it) => it.name)
"
:custom-label="(value) => $formatCategory(value)"
:loading="tags.loaders.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
/>
<Categories v-else :categories="version.loaders" :type="project.actualProjectType" />
</div>
<div>
<h4>Game versions</h4>
<template v-if="isEditing">
<multiselect
v-model="version.game_versions"
:options="
tags.loaders
.filter((x) =>
x.supported_project_types.includes(project.actualProjectType.toLowerCase()),
)
.map((it) => it.name)
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:custom-label="(value) => $formatCategory(value)"
:loading="tags.loaders.length === 0"
:loading="tags.gameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
@@ -530,89 +567,63 @@
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
placeholder="Choose versions..."
/>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-top: 0.5rem"
:border="false"
/>
</template>
<span v-else>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div v-if="!isEditing">
<h4>Downloads</h4>
<span>{{ version.downloads }}</span>
</div>
<div v-if="!isEditing">
<h4>Publication date</h4>
<span>
{{ $dayjs(version.date_published).format("MMMM D, YYYY [at] h:mm A") }}
</span>
</div>
<div v-if="!isEditing && version.author">
<h4>Publisher</h4>
<div
class="team-member columns button-transparent"
@click="$router.push('/user/' + version.author.user.username)"
>
<Avatar
:src="version.author.avatar_url"
:alt="version.author.user.username"
size="sm"
circle
/>
<Categories v-else :categories="version.loaders" :type="project.actualProjectType" />
</div>
<div>
<h4>Game versions</h4>
<template v-if="isEditing">
<multiselect
v-model="version.game_versions"
:options="
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:loading="tags.gameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose versions..."
/>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-top: 0.5rem"
:border="false"
/>
</template>
<span v-else>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div v-if="!isEditing">
<h4>Downloads</h4>
<span>{{ version.downloads }}</span>
</div>
<div v-if="!isEditing">
<h4>Publication date</h4>
<span>
{{ $dayjs(version.date_published).format("MMMM D, YYYY [at] h:mm A") }}
</span>
</div>
<div v-if="!isEditing && version.author">
<h4>Publisher</h4>
<div
class="team-member columns button-transparent"
@click="$router.push('/user/' + version.author.user.username)"
>
<Avatar
:src="version.author.avatar_url"
:alt="version.author.user.username"
size="sm"
circle
/>
<div class="member-info">
<nuxt-link :to="'/user/' + version.author.user.username" class="name">
<p>
{{ version.author.name }}
</p>
</nuxt-link>
<p v-if="version.author.role" class="role">
{{ version.author.role }}
<div class="member-info">
<nuxt-link :to="'/user/' + version.author.user.username" class="name">
<p>
{{ version.author.name }}
</p>
<p v-else-if="version.author_id === 'GVFjtWTf'" class="role">Archivist</p>
</div>
</nuxt-link>
<p v-if="version.author.role" class="role">
{{ version.author.role }}
</p>
<p v-else-if="version.author_id === 'GVFjtWTf'" class="role">Archivist</p>
</div>
</div>
<div v-if="!isEditing">
<h4>Version ID</h4>
<CopyCode :text="version.id" />
</div>
</div>
<div v-if="!isEditing">
<h4>Version ID</h4>
<CopyCode :text="version.id" />
</div>
</div>
</div>
</template>
<script>
import { MarkdownEditor } from "@modrinth/ui";
import { ButtonStyled, ConfirmModal, MarkdownEditor } from "@modrinth/ui";
import { Multiselect } from "vue-multiselect";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import { inferVersionInfo } from "~/helpers/infer.js";
@@ -626,7 +637,6 @@ import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import Chips from "~/components/ui/Chips.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import FileInput from "~/components/ui/FileInput.vue";
@@ -649,6 +659,7 @@ import RightArrowIcon from "~/assets/images/utils/right-arrow.svg?component";
import Modal from "~/components/ui/Modal.vue";
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
export default defineNuxtComponent({
components: {
MarkdownEditor,
@@ -675,10 +686,12 @@ export default defineNuxtComponent({
Badge,
Breadcrumbs,
CopyCode,
ModalConfirm,
Multiselect,
BoxIcon,
RightArrowIcon,
ConfirmModal,
ButtonStyled,
AdPlaceholder,
},
props: {
project: {
@@ -942,10 +955,7 @@ export default defineNuxtComponent({
},
getPreviousLink() {
if (this.$router.options.history.state.back) {
if (
this.$router.options.history.state.back.includes("/changelog") ||
this.$router.options.history.state.back.includes("/versions")
) {
if (this.$router.options.history.state.back.includes("/versions")) {
return this.$router.options.history.state.back;
}
}
@@ -955,9 +965,9 @@ export default defineNuxtComponent({
},
getPreviousLabel() {
return this.$router.options.history.state.back &&
this.$router.options.history.state.back.endsWith("/changelog")
? "Changelog"
: "Versions";
this.$router.options.history.state.back.endsWith("/versions")
? "Back to versions"
: "All versions";
},
acceptFileFromProjectType,
renderHighlightedString,
@@ -1315,8 +1325,8 @@ export default defineNuxtComponent({
"title" auto
"changelog" auto
"dependencies" auto
"metadata" auto
"files" auto
"dummy" 1fr
/ 1fr;
column-gap: var(--spacing-card-md);
@@ -1480,8 +1490,9 @@ export default defineNuxtComponent({
min-width: 235px;
}
.iconified-button {
.raised-button {
margin-left: auto;
background-color: var(--color-raised-bg);
}
&:not(:nth-child(2)) {
@@ -1506,44 +1517,30 @@ export default defineNuxtComponent({
}
}
}
.version-page__metadata {
grid-area: metadata;
h4 {
margin: 1rem 0 0.25rem 0;
}
.team-member {
align-items: center;
padding: 0.25rem 0.5rem;
.member-info {
overflow: hidden;
margin: auto 0 auto 0.75rem;
.name {
font-weight: bold;
}
p {
font-size: var(--font-size-sm);
margin: 0.2rem 0;
}
}
}
}
}
@media (min-width: 1200px) {
.version-page {
grid-template:
"title title" auto
"changelog metadata" auto
"dependencies metadata" auto
"files metadata" auto
"dummy metadata" 1fr
/ 1fr 20rem;
.version-page__metadata {
h4 {
margin: 1rem 0 0.25rem 0;
}
.team-member {
align-items: center;
padding: 0.25rem 0.5rem;
.member-info {
overflow: hidden;
margin: auto 0 auto 0.75rem;
.name {
font-weight: bold;
}
p {
font-size: var(--font-size-sm);
margin: 0.2rem 0;
}
}
}
}

View File

@@ -1,112 +1,296 @@
<template>
<div class="content">
<div v-if="currentMember" class="card header-buttons">
<section class="normal-page__content experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="iconified-button brand-button"
:disabled="!isPermission(currentMember?.permissions, 1 << 0)"
class="btn btn-primary"
@change="handleFiles"
>
<UploadIcon />
</FileInput>
<span class="indicator">
<span class="flex items-center gap-2">
<InfoIcon /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div v-if="filteredVersions.length > 0" id="all-versions" class="universal-card all-versions">
<div class="header">
<div />
<div>Version</div>
<div>Supports</div>
<div>Stats</div>
<div
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]"
>
<div class="versions-grid-row">
<div class="w-9 max-sm:hidden"></div>
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Game version
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Platforms
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Published
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Downloads
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
Compatibility
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
<div class="w-9 max-sm:hidden"></div>
</div>
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="version-button button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
)
"
<template
v-for="(version, index) in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="index"
>
<a
v-tooltip="
version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
"
:href="version.primaryFile.url"
class="download-button square-button brand-button"
:class="version.version_type"
:aria-label="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
class="version__title"
>
{{ version.name }}
</nuxt-link>
<div class="version__metadata">
<VersionBadge v-if="version.version_type === 'release'" type="release" color="green" />
<VersionBadge v-else-if="version.version_type === 'beta'" type="beta" color="orange" />
<VersionBadge v-else-if="version.version_type === 'alpha'" type="alpha" color="red" />
<span class="divider" />
<span class="version_number">{{ version.version_number }}</span>
<div
:class="`versions-grid-row h-px w-full bg-button-bg ${index === 0 ? `max-sm:!hidden` : ``}`"
></div>
<div class="versions-grid-row group relative">
<nuxt-link
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
></nuxt-link>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row items-center gap-2 sm:contents">
<div class="self-center">
<div class="pointer-events-none relative z-[1]">
<VersionChannelIndicator :channel="version.version_type" />
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center group-hover:underline"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
<div class="flex items-center">
<div class="tag-list">
<div
v-for="gameVersion in formatVersionsForDisplay(version.game_versions)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="tag-list__item z-[1] cursor-pointer hover:underline"
@click="versionFilters.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</div>
</div>
</div>
<div class="flex items-center">
<div class="tag-list">
<div
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
:class="`tag-list__item z-[1] cursor-pointer hover:underline`"
:style="`--_color: var(--color-platform-${platform})`"
@click="versionFilters.toggleFilter('platform', platform)"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</div>
</div>
</div>
</div>
<div
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
>
<div
class="pointer-events-none z-[1] flex items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(version.date_published) }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
>
<DownloadIcon class="xl:hidden" />
{{ formatCompactNumber(version.downloads) }}
</div>
</div>
</div>
</div>
<div class="flex items-start justify-end gap-1 sm:items-center">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="z-[1] group-hover:!bg-brand group-hover:!text-brand-inverted"
@click="emits('onDownload')"
>
<DownloadIcon />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emits('onDownload');
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => reportVersion(version.id),
},
{ divider: true, shown: currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {},
shown: currentMember && false,
},
]"
>
<MoreVerticalIcon />
<template #download>
<DownloadIcon />
Download
</template>
<template #new-tab>
<ExternalIcon />
Open in new tab
</template>
<template #copy-link>
<LinkIcon />
Copy link
</template>
<template #share>
<ShareIcon />
Share
</template>
<template #report>
<ReportIcon />
Report
</template>
<template #edit>
<EditIcon />
Edit
</template>
<template #delete>
<TrashIcon />
Delete
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full">
<div
v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`"
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
>
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
{{ file.filename }} - {{ formatBytes(file.size) }}
</div>
</div>
</div>
<div class="version__supports">
<span>
{{ version.loaders.map((x) => $formatCategory(x)).join(", ") }}
</span>
<span>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div class="version__stats">
<span>
<strong>{{ $formatNumber(version.downloads) }}</strong>
download<span v-if="version.downloads !== 1">s</span>
</span>
<span>
Published on
<strong>{{ $dayjs(version.date_published).format("MMM D, YYYY") }}</strong>
</span>
</div>
</div>
</template>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
<div class="my-3 flex justify-end">
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
:link-function="(page) => `?page=${currentPage}`"
@switch-page="switchPage"
/>
</div>
</section>
<div class="normal-page__sidebar">
<AdPlaceholder />
<VersionFilterControl
ref="versionFilters"
:versions="props.versions"
@switch-page="switchPage"
/>
</div>
</template>
<script setup>
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import UploadIcon from "~/assets/images/utils/upload.svg?component";
import InfoIcon from "~/assets/images/utils/info.svg?component";
import VersionBadge from "~/components/ui/Badge.vue";
import FileInput from "~/components/ui/FileInput.vue";
import DropArea from "~/components/ui/DropArea.vue";
import Pagination from "~/components/ui/Pagination.vue";
import {
ButtonStyled,
OverflowMenu,
Pagination,
VersionChannelIndicator,
FileInput,
} from "@modrinth/ui";
import {
StarIcon,
CalendarIcon,
DownloadIcon,
MoreVerticalIcon,
TrashIcon,
ExternalIcon,
LinkIcon,
ShareIcon,
EditIcon,
ReportIcon,
UploadIcon,
InfoIcon,
} from "@modrinth/assets";
import { formatBytes, formatCategory } from "@modrinth/utils";
import { formatVersionsForDisplay } from "~/helpers/projects.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import DropArea from "~/components/ui/DropArea.vue";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const formatCompactNumber = useCompactNumber();
const props = defineProps({
project: {
@@ -121,12 +305,6 @@ const props = defineProps({
return [];
},
},
members: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
@@ -135,30 +313,38 @@ const props = defineProps({
},
});
const data = useNuxtApp();
const tags = useTags();
const formatRelativeTime = useRelativeTime();
const title = `${props.project.title} - Versions`;
const description = `Download and browse ${props.versions.length} ${
props.project.title
} versions. ${data.$formatNumber(props.project.downloads)} total downloads. Last updated ${data
.$dayjs(props.project.updated)
.format("MMM D, YYYY")}.`;
const emits = defineEmits(["onDownload"]);
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
});
const router = useNativeRouter();
const route = useNativeRoute();
const router = useNativeRouter();
const currentPage = ref(Number(route.query.p ?? 1));
const currentPage = ref(route.query.page ?? 1);
const showFiles = ref(false);
function switchPage(page) {
currentPage.value = page;
router.replace({
query: {
...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0];
}
const versionFilters = ref(null);
const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? [];
return props.versions.filter(
(projectVersion) =>
@@ -173,17 +359,6 @@ const filteredVersions = computed(() => {
);
});
function switchPage(page) {
currentPage.value = page;
router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
async function handleFiles(files) {
await router.push({
name: "type-id-version-version",
@@ -197,144 +372,13 @@ async function handleFiles(files) {
},
});
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
}
</script>
<style lang="scss" scoped>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.all-versions {
display: flex;
flex-direction: column;
.header {
display: grid;
grid-template: "download title supports stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
color: var(--color-text-dark);
font-size: var(--font-size-md);
font-weight: bold;
justify-content: left;
margin-inline: var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
column-gap: var(--spacing-card-sm);
div:first-child {
grid-area: download;
}
div:nth-child(2) {
grid-area: title;
}
div:nth-child(3) {
grid-area: supports;
}
div:nth-child(4) {
grid-area: stats;
}
}
.version-button {
display: grid;
grid-template:
"download title supports stats"
"download metadata supports stats"
"download dummy supports stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
column-gap: var(--spacing-card-sm);
justify-content: left;
padding: var(--spacing-card-md);
.download-button {
grid-area: download;
}
.version__title {
grid-area: title;
font-weight: bold;
svg {
vertical-align: top;
}
}
.version__metadata {
grid-area: metadata;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-xs);
}
.version__supports {
grid-area: supports;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
.version__stats {
grid-area: stats;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
}
}
@media screen and (max-width: 1024px) {
.all-versions {
.header {
grid-template: "download title";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
div:nth-child(3) {
display: none;
}
div:nth-child(4) {
display: none;
}
}
.version-button {
grid-template: "download title" "download metadata" "download supports" "download stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
row-gap: var(--spacing-card-xs);
.version__supports {
display: flex;
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--spacing-card-xs);
}
.version__metadata {
margin: 0;
}
}
}
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
}
.checkbox-outer {
min-width: fit-content;
}
<style scoped>
.versions-grid-row {
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
}
</style>

View File

@@ -1000,7 +1000,7 @@ useSeoMeta({
center 4rem;
background-size: cover;
padding: 6rem 1rem 12rem 1rem;
margin-top: -4rem;
margin-top: -5rem;
display: flex;
justify-content: center;
align-items: center;

View File

@@ -24,7 +24,7 @@
</Button>
</template>
<template v-else-if="canEdit && isEditing === true">
<PopoutMenu class="btn" position="bottom" direction="right">
<PopoutMenu class="btn">
<EditIcon /> {{ formatMessage(messages.editIconButton) }}
<template #menu>
<span class="icon-edit-menu">

View File

@@ -11,7 +11,7 @@
<XIcon />
</Button>
</div>
<Button color="primary" @click="$refs.modal_creation.show()">
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
<PlusIcon /> {{ formatMessage(messages.createNewButton) }}
</Button>
</div>

View File

@@ -76,8 +76,8 @@ if (error.value) {
});
}
const openCreateOrgModal = () => {
createOrgModal.value?.show();
const openCreateOrgModal = (event) => {
createOrgModal.value?.show(event);
};
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div>
<div class="landing-hero">
<ModrinthIcon />
<ModrinthIcon class="modrinth-icon" />
<h1 class="main-header">
The place for Minecraft
<div class="animate-strong">
@@ -22,18 +22,19 @@
community.
</h2>
<div class="button-group">
<nuxt-link to="/mods" class="iconified-button brand-button"> Discover mods </nuxt-link>
<nuxt-link
v-if="!auth.user"
to="/auth/sign-up"
class="iconified-button outline-button"
rel="noopener nofollow"
>
Sign up
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects" class="iconified-button outline-button">
Go to dashboard
</nuxt-link>
<ButtonStyled color="brand" size="large">
<nuxt-link to="/mods"> <CompassIcon /> Discover mods </nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" type="outlined">
<nuxt-link v-if="!auth.user" to="/auth/sign-up" rel="noopener nofollow">
<LogInIcon />
Sign up
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects">
<DashboardIcon />
Go to dashboard
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div class="users-section-outer">
@@ -411,7 +412,7 @@
viewBox="0 0 865 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="light-height"
class="light-height modrinth-icon"
>
<g clip-path="url(#clip0_419_237)">
<rect x="176" width="512" height="512" fill="url(#paint0_linear_419_237)" />
@@ -455,7 +456,13 @@
</clipPath>
</defs>
</svg>
<svg v-else viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
v-else
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="modrinth-icon"
>
<g clip-path="url(#clip0_127_331)">
<rect width="512" height="512" fill="url(#paint0_linear_127_331)" />
<g style="mix-blend-mode: overlay">
@@ -493,18 +500,22 @@
Read more about <br />
<strong class="main-header-strong">Modrinth</strong>
</h2>
<a
href="https://blog.modrinth.com/?utm_source=website&utm_source=homepage&utm_campaign=newsletter"
class="iconified-button brand-button"
>
Visit the blog
</a>
<ButtonStyled color="brand">
<a
href="https://blog.modrinth.com/?utm_source=website&utm_source=homepage&utm_campaign=newsletter"
>
<NewspaperIcon />
Visit the blog
</a>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { ButtonStyled } from "@modrinth/ui";
import { CompassIcon, LogInIcon, DashboardIcon, NewspaperIcon } from "@modrinth/assets";
import SearchIcon from "~/assets/images/utils/search.svg?component";
import CalendarIcon from "~/assets/images/utils/calendar.svg?component";
import ModrinthIcon from "~/assets/images/logo.svg?component";
@@ -554,7 +565,7 @@ async function updateSearchProjects() {
text-align: center;
flex-direction: column;
svg {
.modrinth-icon {
width: 13rem;
height: 13rem;
margin-bottom: 2.5rem;
@@ -575,12 +586,6 @@ async function updateSearchProjects() {
gap: 1.25rem;
margin: 0 auto 5rem;
justify-content: center;
.outline-button {
color: var(--landing-color-heading);
background: none;
border: 1px var(--landing-color-heading) solid;
}
}
}
@@ -1044,7 +1049,7 @@ async function updateSearchProjects() {
padding: 1rem 1rem 2rem 1rem;
overflow: hidden;
svg {
.modrinth-icon {
z-index: 2;
width: auto;
height: 32rem;
@@ -1201,11 +1206,6 @@ async function updateSearchProjects() {
}
}
.iconified-button {
font-weight: 600;
min-height: 3rem;
}
@media screen and (min-width: 560px) {
.landing-hero {
h2 {
@@ -1276,7 +1276,7 @@ async function updateSearchProjects() {
font-size: 1.625rem;
}
margin-top: -4rem;
margin-top: -5rem;
padding: 11.25rem 1rem 12rem;
}

View File

@@ -141,7 +141,7 @@ onMounted(() => {
.main-hero {
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -4rem;
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;
display: flex;

View File

@@ -1,10 +1,7 @@
<template>
<div
:class="{
'search-page': true,
'normal-page': true,
'alt-layout': cosmetics.searchLayout,
}"
class="new-page sidebar experimental-styles-within"
:class="{ 'alt-layout': cosmetics.searchLayout }"
>
<Head>
<Title>Search {{ projectType.display }}s - Modrinth</Title>
@@ -12,206 +9,126 @@
<aside
:class="{
'normal-page__sidebar': true,
open: sidebarMenuOpen,
}"
aria-label="Filters"
>
<section class="card filters-card" role="presentation">
<div class="sidebar-menu" :class="{ 'sidebar-menu_open': sidebarMenuOpen }">
<AdPlaceholder />
<section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }">
<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>
<button
:disabled="
onlyOpenSource === false &&
selectedEnvironments.length === 0 &&
selectedVersions.length === 0 &&
facets.length === 0 &&
orFacets.length === 0
v-if="
!(
onlyOpenSource === false &&
selectedEnvironments.length === 0 &&
selectedVersions.length === 0 &&
facets.length === 0 &&
orFacets.length === 0 &&
negativeFacets.length === 0
)
"
class="iconified-button"
v-tooltip="`Reset all filters`"
class="btn icon-only"
@click="clearFilters"
>
<ClearIcon aria-hidden="true" />
Clear filters
<FilterXIcon />
</button>
<section aria-label="Category filters">
<div v-for="(categories, header) in categoriesMap" :key="header">
<h3
v-if="categories.filter((x) => x.project_type === projectType.actual).length > 0"
class="sidebar-menu-heading"
>
{{ $formatCategoryHeader(header) }}
</h3>
<template v-if="header === 'resolutions'">
<SearchFilter
v-for="category in categories.filter(
(x) => x.project_type === projectType.actual,
)"
:key="category.name"
:active-filters="orFacets"
:display-name="$formatCategory(category.name)"
:facet-name="`categories:'${encodeURIComponent(category.name)}'`"
:icon="null"
@toggle="toggleOrFacet"
/>
</template>
<template v-else>
<SearchFilter
v-for="category in categories.filter(
(x) => x.project_type === projectType.actual,
)"
:key="category.name"
:active-filters="facets"
:display-name="$formatCategory(category.name)"
:facet-name="`categories:'${encodeURIComponent(category.name)}'`"
:icon="category.icon"
@toggle="toggleFacet"
/>
</template>
</div>
</section>
<section
v-if="projectType.id !== 'resourcepack' && projectType.id !== 'datapack'"
aria-label="Loader filters"
>
<h3
v-if="
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
>
Loaders
</h3>
<SearchFilter
v-for="loader in tags.loaders.filter((x) => {
if (projectType.id === 'mod') {
return (
tags.loaderData.modLoaders.includes(x.name) &&
!tags.loaderData.hiddenModLoaders.includes(x.name)
);
} else if (projectType.id === 'plugin') {
return tags.loaderData.pluginLoaders.includes(x.name);
} else if (projectType.id === 'datapack') {
return tags.loaderData.dataPackLoaders.includes(x.name);
} else {
return x.supported_project_types.includes(projectType.actual);
}
})"
:key="loader.name"
ref="loaderFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
<template v-if="projectType.id === 'mod' && showAllLoaders">
<SearchFilter
v-for="loader in tags.loaders.filter((x) => {
return (
tags.loaderData.modLoaders.includes(x.name) &&
tags.loaderData.hiddenModLoaders.includes(x.name)
);
})"
:key="loader.name"
ref="loaderFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
</template>
<Checkbox
v-if="projectType.id === 'mod'"
v-model="showAllLoaders"
:label="showAllLoaders ? 'Less' : 'More'"
description="Show all loaders"
style="margin-bottom: 0.5rem"
:border="false"
:collapsing-toggle-style="true"
/>
</section>
<section v-if="projectType.id === 'plugin'" aria-label="Platform loader filters">
<h3
v-if="
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
>
Proxies
</h3>
<SearchFilter
v-for="loader in tags.loaders.filter((x) =>
tags.loaderData.pluginPlatformLoaders.includes(x.name),
)"
:key="loader.name"
ref="platformFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
</section>
<section
v-if="!['resourcepack', 'plugin', 'shader', 'datapack'].includes(projectType.id)"
aria-label="Environment filters"
>
<h3 class="sidebar-menu-heading">Environments</h3>
<SearchFilter
:active-filters="selectedEnvironments"
display-name="Client"
facet-name="client"
@toggle="toggleEnv"
>
<ClientIcon aria-hidden="true" />
</SearchFilter>
<SearchFilter
:active-filters="selectedEnvironments"
display-name="Server"
facet-name="server"
@toggle="toggleEnv"
>
<ServerIcon aria-hidden="true" />
</SearchFilter>
</section>
<h3 class="sidebar-menu-heading">Minecraft versions</h3>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-bottom: 0.5rem"
:border="false"
/>
<multiselect
v-model="selectedVersions"
:options="
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
</div>
<div
v-for="(categories, header, index) in filters"
:key="header"
: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();
}
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:selectable="() => selectedVersions.length <= 6"
placeholder="Choose versions..."
@update:model-value="onSearchChange(1)"
/>
<h3 class="sidebar-menu-heading">Open source</h3>
<Checkbox
v-model="onlyOpenSource"
label="Open source only"
style="margin-bottom: 0.5rem"
:border="false"
@update:model-value="onSearchChange(1)"
/>
>
<template v-if="header === 'gameVersion'"> Game versions </template>
<template v-else>
{{ $formatCategoryHeader(header) }}
</template>
<DropdownIcon
class="ml-auto h-5 w-5 transition-transform"
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }"
/>
</button>
<Accordion ref="filterAccordions" :open-by-default="true">
<ScrollablePanel
:class="{ 'h-[18rem]': categories.length >= 8 && header === 'gameVersion' }"
:no-max-height="header !== 'gameVersion'"
>
<div class="mr-1 flex flex-col gap-1">
<div v-for="category in categories" :key="category.name" 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] ${filterSelected(category) ? 'bg-brand-highlight text-contrast hover:brightness-125' : negativeFilterSelected(category) ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg'}`"
@click="
negativeFilterSelected(category)
? toggleNegativeFilter(category)
: toggleFilter(category)
"
>
<ClientIcon v-if="category.name === 'client'" class="h-4 w-4" />
<ServerIcon v-else-if="category.name === 'server'" class="h-4 w-4" />
<div v-if="category.icon" class="h-4" v-html="category.icon" />
<span class="truncate text-sm">{{ $formatCategory(category.name) }}</span>
<BanIcon
v-if="negativeFilterSelected(category)"
:class="`ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${negativeFilterSelected(category) ? '' : 'opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
v-else
:class="`ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${filterSelected(category) ? '' : 'opacity-0'}`"
aria-hidden="true"
/>
</button>
<button
v-if="
(category.type === 'or' || category.type === 'normal') &&
!negativeFilterSelected(category)
"
v-tooltip="negativeFilterSelected(category) ? 'Include' : 'Exclude'"
class="hidden items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary transition-all hover:bg-button-bg hover:text-red active:scale-[0.96] group-hover:flex"
@click="toggleNegativeFilter(category)"
>
<BanIcon
class="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</button>
</div>
</div>
</ScrollablePanel>
<Checkbox
v-if="header === 'gameVersion'"
v-model="showSnapshots"
class="mx-2"
:label="`Show all versions`"
/>
<Checkbox
v-if="header === 'loaders' && projectType.id === 'mod'"
v-model="showAllLoaders"
class="mx-2"
:label="`Show all loaders`"
/>
</Accordion>
</div>
</section>
</aside>
@@ -288,13 +205,6 @@
</button>
</div>
</div>
<Pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-before"
@switch-page="onSearchChange"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
@@ -332,38 +242,41 @@
/>
</div>
</div>
<pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-after"
@switch-page="onSearchChangeToTop"
/>
<div class="pagination-after">
<pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="justify-end"
@switch-page="onSearchChangeToTop"
/>
</div>
</section>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { Promotion } from "@modrinth/ui";
import { Promotion, Pagination, ScrollablePanel, Checkbox } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon } from "@modrinth/assets";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import Pagination from "~/components/ui/Pagination.vue";
import SearchFilter from "~/components/ui/search/SearchFilter.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
import ClientIcon from "~/assets/images/categories/client.svg?component";
import ServerIcon from "~/assets/images/categories/server.svg?component";
import SearchIcon from "~/assets/images/utils/search.svg?component";
import ClearIcon from "~/assets/images/utils/clear.svg?component";
import FilterIcon from "~/assets/images/utils/filter.svg?component";
import GridIcon from "~/assets/images/utils/grid.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import ImageIcon from "~/assets/images/utils/image.svg?component";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const sidebarMenuOpen = ref(false);
const showAllLoaders = ref(false);
const filterAccordions = ref([]);
const data = useNuxtApp();
const route = useNativeRoute();
@@ -374,6 +287,7 @@ const auth = await useAuth();
const query = ref("");
const facets = ref([]);
const orFacets = ref([]);
const negativeFacets = ref([]);
const selectedVersions = ref([]);
const onlyOpenSource = ref(false);
const showSnapshots = ref(false);
@@ -413,6 +327,9 @@ if (route.query.f) {
if (route.query.g) {
orFacets.value = getArrayOrString(route.query.g);
}
if (route.query.nf) {
negativeFacets.value = getArrayOrString(route.query.nf);
}
if (route.query.v) {
selectedVersions.value = getArrayOrString(route.query.v);
}
@@ -477,6 +394,7 @@ const {
if (
facets.value.length > 0 ||
orFacets.value.length > 0 ||
negativeFacets.value.length > 0 ||
selectedVersions.value.length > 0 ||
selectedEnvironments.value.length > 0 ||
projectType.value
@@ -486,6 +404,10 @@ const {
formattedFacets.push([facet]);
}
for (const facet of negativeFacets.value) {
formattedFacets.push([facet.replace(":", "!=")]);
}
// loaders specifier
if (orFacets.value.length > 0) {
formattedFacets.push(orFacets.value);
@@ -616,6 +538,10 @@ function getSearchUrl(offset, useObj) {
queryItems.push(`g=${encodeURIComponent(orFacets.value)}`);
obj.g = orFacets.value;
}
if (negativeFacets.value.length > 0) {
queryItems.push(`nf=${encodeURIComponent(negativeFacets.value)}`);
obj.nf = negativeFacets.value;
}
if (selectedVersions.value.length > 0) {
queryItems.push(`v=${encodeURIComponent(selectedVersions.value)}`);
obj.v = selectedVersions.value;
@@ -654,103 +580,16 @@ function getSearchUrl(offset, useObj) {
return useObj ? obj : url;
}
const categoriesMap = computed(() => {
const categories = {};
for (const category of data.$sortedCategories()) {
if (categories[category.header]) {
categories[category.header].push(category);
} else {
categories[category.header] = [category];
}
}
return Object.keys(categories).reduce((obj, key) => {
obj[key] = categories[key];
return obj;
}, {});
});
function clearFilters() {
for (const facet of [...facets.value]) {
toggleFacet(facet, true);
}
for (const facet of [...orFacets.value]) {
toggleOrFacet(facet, true);
}
facets.value = [];
orFacets.value = [];
negativeFacets.value = [];
onlyOpenSource.value = false;
selectedVersions.value = [];
selectedEnvironments.value = [];
onSearchChange(1);
}
function toggleFacet(elementName, doNotSendRequest = false) {
const index = facets.value.indexOf(elementName);
if (index !== -1) {
facets.value.splice(index, 1);
} else {
facets.value.push(elementName);
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
function toggleOrFacet(elementName, doNotSendRequest) {
const index = orFacets.value.indexOf(elementName);
if (index !== -1) {
orFacets.value.splice(index, 1);
} else {
if (elementName === "categories:purpur") {
if (!orFacets.value.includes("categories:paper")) {
orFacets.value.push("categories:paper");
}
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:paper") {
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:spigot") {
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:waterfall") {
if (!orFacets.value.includes("categories:bungeecord")) {
orFacets.value.push("categories:bungeecord");
}
}
orFacets.value.push(elementName);
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
function toggleEnv(environment, sendRequest) {
const index = selectedEnvironments.value.indexOf(environment);
if (index !== -1) {
selectedEnvironments.value.splice(index, 1);
} else {
selectedEnvironments.value.push(environment);
}
if (!sendRequest) {
onSearchChange(1);
}
}
function onSearchChangeToTop(newPageNumber) {
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: "smooth" });
@@ -796,6 +635,225 @@ function setClosestMaxResults() {
});
}
}
const queryFilter = ref("");
const filters = computed(() => {
const filters = {};
if (projectType.value.id !== "resourcepack" && projectType.value.id !== "datapack") {
const loaders = tags.value.loaders
.filter((x) => {
if (projectType.value.id === "mod" && !showAllLoaders.value) {
return (
tags.value.loaderData.modLoaders.includes(x.name) &&
!tags.value.loaderData.hiddenModLoaders.includes(x.name)
);
} else if (projectType.value.id === "mod" && showAllLoaders.value) {
return tags.value.loaderData.modLoaders.includes(x.name);
} else if (projectType.value.id === "plugin") {
return tags.value.loaderData.pluginLoaders.includes(x.name);
} else if (projectType.value.id === "datapack") {
return tags.value.loaderData.dataPackLoaders.includes(x.name);
} else {
return x.supported_project_types.includes(projectType.value.actual);
}
})
.slice();
loaders.sort((a, b) => {
const isAHidden = tags.value.loaderData.hiddenModLoaders.includes(a.name);
const isBHidden = tags.value.loaderData.hiddenModLoaders.includes(b.name);
// Sort hidden mod loaders (true) after visible ones (false)
if (isAHidden && !isBHidden) return 1;
if (!isAHidden && isBHidden) return -1;
return 0; // No sorting if both are hidden or both are visible
});
if (loaders.length > 0) {
filters.loaders = loaders.map((x) => ({
icon: x.icon,
name: x.name,
type: "or",
facet: `categories:${x.name}`,
}));
}
if (projectType.value.id === "plugin") {
const platforms = tags.value.loaders.filter((x) =>
tags.value.loaderData.pluginPlatformLoaders.includes(x.name),
);
filters.platforms = platforms.map((x) => ({
icon: x.icon,
name: x.name,
type: "or",
facet: `categories:${x.name}`,
}));
}
}
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
if (!["resourcepack", "plugin", "shader", "datapack"].includes(projectType.value.id)) {
filters.environment = [
{ name: "client", type: "env" },
{ name: "server", type: "env" },
];
}
for (const category of data.$sortedCategories()) {
if (category.project_type === projectType.value.actual) {
const parsedCategory = {
name: category.name,
icon: category.icon,
facet: `categories:${category.name}`,
type: category.header === "resolutions" ? "or" : "normal",
};
if (filters[category.header]) {
filters[category.header].push(parsedCategory);
} else {
filters[category.header] = [parsedCategory];
}
}
}
filters.license = [{ name: "Open source only", type: "license" }];
const filteredObj = {};
for (const [key, value] of Object.entries(filters)) {
const filters = queryFilter.value
? value.filter((x) => x.name.toLowerCase().includes(queryFilter.value.toLowerCase()))
: value;
if (filters.length > 0) {
filteredObj[key] = filters;
}
}
return filteredObj;
});
function filterSelected(filter) {
if (filter.type === "or") {
return orFacets.value.includes(filter.facet);
} else if (filter.type === "normal") {
return facets.value.includes(filter.facet);
} else if (filter.type === "env") {
return selectedEnvironments.value.includes(filter.name);
} else if (filter.type === "gameVersion") {
return selectedVersions.value.includes(filter.name);
} else if (filter.type === "license") {
return onlyOpenSource.value;
}
}
function negativeFilterSelected(filter) {
if (filter.type === "or" || filter.type === "normal") {
return negativeFacets.value.includes(filter.facet);
}
}
function toggleNegativeFilter(filter) {
const elementName = filter.facet;
if (filterSelected(filter)) {
if (filter.type === "or") {
const index = orFacets.value.indexOf(elementName);
orFacets.value.splice(index, 1);
} else if (filter.type === "normal") {
const index = facets.value.indexOf(elementName);
facets.value.splice(index, 1);
}
}
if (filter.type === "or" || filter.type === "normal") {
const index = negativeFacets.value.indexOf(elementName);
if (index !== -1) {
negativeFacets.value.splice(index, 1);
} else {
negativeFacets.value.push(elementName);
}
}
onSearchChange(1);
}
function toggleFilter(filter, doNotSendRequest) {
const elementName = filter.facet;
if (negativeFilterSelected(filter)) {
const index = negativeFacets.value.indexOf(elementName);
negativeFacets.value.splice(index, 1);
}
if (filter.type === "or") {
const index = orFacets.value.indexOf(elementName);
if (index !== -1) {
orFacets.value.splice(index, 1);
} else {
if (elementName === "categories:purpur") {
if (!orFacets.value.includes("categories:paper")) {
orFacets.value.push("categories:paper");
}
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:paper") {
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:spigot") {
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:waterfall") {
if (!orFacets.value.includes("categories:bungeecord")) {
orFacets.value.push("categories:bungeecord");
}
}
orFacets.value.push(elementName);
}
} else if (filter.type === "normal") {
const index = facets.value.indexOf(elementName);
if (index !== -1) {
facets.value.splice(index, 1);
} else {
facets.value.push(elementName);
}
} else if (filter.type === "env") {
const index = selectedEnvironments.value.indexOf(filter.name);
if (index !== -1) {
selectedEnvironments.value.splice(index, 1);
} else {
selectedEnvironments.value.push(filter.name);
}
} else if (filter.type === "gameVersion") {
const index = selectedVersions.value.indexOf(filter.name);
if (index !== -1) {
selectedVersions.value.splice(index, 1);
} else {
selectedVersions.value.push(filter.name);
}
} else if (filter.type === "license") {
onlyOpenSource.value = !onlyOpenSource.value;
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
</script>
<style lang="scss" scoped>
@@ -816,12 +874,6 @@ function setClosestMaxResults() {
@media screen and (min-width: 1024px) {
display: block;
}
// Hide on mobile unless open
display: none;
&.open {
display: block;
}
}
.filters-card {

View File

@@ -171,7 +171,7 @@
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<div v-if="false" class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
@@ -190,10 +190,10 @@
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarTitle) }}
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarDescription) }}
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarDescription) }}
</span>
</label>
<input
@@ -206,10 +206,10 @@
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarTitle) }}
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarDescription) }}
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarDescription) }}
</span>
</label>
<input
@@ -368,21 +368,21 @@ const toggleFeatures = defineMessages({
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.',
},
rightAlignedSearchSidebarTitle: {
id: "settings.display.sidebar.right-aligned-search-sidebar.title",
defaultMessage: "Right-aligned search sidebar",
leftAlignedSearchSidebarTitle: {
id: "settings.display.sidebar.Left-aligned-search-sidebar.title",
defaultMessage: "Left-aligned search sidebar",
},
rightAlignedSearchSidebarDescription: {
id: "settings.display.sidebar.right-aligned-search-sidebar.description",
defaultMessage: "Aligns the search filters sidebar to the right of the search results.",
leftAlignedSearchSidebarDescription: {
id: "settings.display.sidebar.left-aligned-search-sidebar.description",
defaultMessage: "Aligns the search filters sidebar to the left of the search results.",
},
rightAlignedProjectSidebarTitle: {
id: "settings.display.sidebar.right-aligned-project-sidebar.title",
defaultMessage: "Right-aligned project sidebar",
leftAlignedProjectSidebarTitle: {
id: "settings.display.sidebar.left-aligned-project-sidebar.title",
defaultMessage: "Left-aligned project sidebar",
},
rightAlignedProjectSidebarDescription: {
id: "settings.display.sidebar.right-aligned-project-sidebar.description",
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
leftAlignedProjectSidebarDescription: {
id: "settings.display.sidebar.left-aligned-project-sidebar.description",
defaultMessage: "Aligns the project details sidebar to the left of the page's content.",
},
});

View File

@@ -1,169 +1,81 @@
<template>
<div v-if="user">
<div v-if="user" class="experimental-styles-within">
<ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar :src="user.avatar_url" size="md" circle :alt="user.username" />
<h1 class="username">
{{ user.username }}
</h1>
</div>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<div class="card sidebar">
<h1 class="mobile-username">
{{ user.username }}
</h1>
<div class="card__overlay">
<NuxtLink
v-if="auth.user && auth.user.id === user.id"
to="/settings/profile"
class="iconified-button"
>
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
<button
v-else-if="auth.user"
class="iconified-button"
@click="() => reportUser(user.id)"
>
<ReportIcon aria-hidden="true" />
{{ formatMessage(messages.profileReportButton) }}
</button>
<nuxt-link v-else class="iconified-button" to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
{{ formatMessage(messages.profileReportButton) }}
</nuxt-link>
</div>
<div class="sidebar__item">
<Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" />
<Badge v-else-if="isPermission(user.badges, 1 << 0)" type="plus" />
<Badge v-else-if="projects.length > 0" type="creator" />
</div>
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
<hr class="card-divider" />
<div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileDownloadsStats"
:values="{ count: formatCompactNumber(sumDownloads) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileProjectsFollowersStats"
:values="{ count: formatCompactNumber(sumFollows) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(user.created),
time: new Date(user.created),
})
"
class="secondary-stat__text date"
>
{{
formatMessage(messages.profileJoinedAt, { ago: formatRelativeTime(user.created) })
}}
</span>
</div>
<hr class="card-divider" />
<div class="stats-block__item secondary-stat">
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
<span class="secondary-stat__text">
<IntlFormatted :message-id="messages.profileUserId">
<template #~id>
<CopyCode :text="user.id" />
</template>
</IntlFormatted>
</span>
</div>
<template v-if="organizations.length > 0">
<hr class="card-divider" />
<div class="stats-block__item">
<IntlFormatted :message-id="messages.profileOrganizations" />
<div class="organizations-grid">
<nuxt-link
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="xs" />
</nuxt-link>
<div class="new-page sidebar">
<div class="normal-page__header pt-4">
<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]"
>
<div class="flex gap-4">
<Avatar :src="user.avatar_url" :alt="user.username" size="96px" circle />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
{{ user.username }}
</h1>
</div>
<p class="m-0 line-clamp-2 max-w-[40rem]">
{{
user.bio
? user.bio
: projects.length === 0
? "A Modrinth user."
: "A Modrinth creator."
}}
</p>
</div>
</template>
</div>
<div class="flex flex-col justify-center gap-4">
<div class="flex flex-wrap gap-2">
<ButtonStyled size="large">
<NuxtLink v-if="auth.user && auth.user.id === user.id" to="/settings/profile">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () => navigateTo('/dashboard/projects'),
hoverOnly: true,
shown: auth.user && auth.user.id === user.id,
},
{ divider: true, shown: auth.user && auth.user.id === user.id },
{
id: 'report',
action: () => reportUser(user.id),
color: 'red',
hoverOnly: true,
},
{ id: 'copy-id', action: () => copyId() },
]"
>
<MoreVerticalIcon />
<template #manage-projects>
<BoxIcon />
{{ formatMessage(messages.profileManageProjectsButton) }}
</template>
<template #report>
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" />
</div>
</div>
<div class="normal-page__content">
<Promotion v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" :external="false" />
<nav class="navigation-card">
<NavRow
:links="[
{
label: formatMessage(commonMessages.allProjectType),
href: `/user/${user.username}`,
},
...projectTypes.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/user/${user.username}/${x}s`,
};
}),
]"
/>
<div class="input-group">
<NuxtLink
v-if="auth.user && auth.user.id === user.id"
class="iconified-button"
to="/dashboard/projects"
>
<SettingsIcon />
{{ formatMessage(messages.profileManageProjectsButton) }}
</NuxtLink>
<button
v-if="route.params.projectType !== 'collections'"
v-tooltip="
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
"
:aria-label="
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
"
class="square-button"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="cosmetics.searchDisplayMode.user === 'grid'" />
<ImageIcon v-else-if="cosmetics.searchDisplayMode.user === 'gallery'" />
<ListIcon v-else />
</button>
</div>
</nav>
<div v-if="projects.length > 0">
<div
v-if="route.params.projectType !== 'collections'"
@@ -268,7 +180,10 @@
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$refs.modal_collection_creation.show()">
<a
class="link"
@click.prevent="(event) => $refs.modal_collection_creation.show(event)"
>
<component :is="() => children" />
</a>
</template>
@@ -277,33 +192,135 @@
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
</div>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder />
<div class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileDetails) }}</h2>
<div class="flex items-center gap-2">
<BoxIcon aria-hidden="true" class="stroke-[3] text-secondary" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileProjectsStats"
:values="{ count: formatCompactNumber(projects.length) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<DownloadIcon aria-hidden="true" class="stroke-[3] text-secondary" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileDownloadsStats"
:values="{ count: formatCompactNumber(sumDownloads) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<HeartIcon aria-hidden="true" class="text-secondary *:stroke-[3]" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileProjectsFollowersStats"
:values="{ count: formatCompactNumber(sumFollows) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<CalendarIcon aria-hidden="true" class="text-secondary *:stroke-[3]" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileJoinedAt"
:values="{ ago: formatRelativeTime(user.created) }"
>
<template #date="{ children }">
<span class="font-bold text-primary">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
</div>
</div>
<div v-if="organizations.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileOrganizations) }}</h2>
<div class="flex flex-wrap gap-2">
<nuxt-link
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="3rem" />
</nuxt-link>
</div>
</div>
<div v-if="badges.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileBadges) }}</h2>
<div class="flex flex-wrap gap-2">
<div v-for="badge in badges" :key="badge">
<StaffBadge v-if="badge === 'staff'" class="h-14 w-14" />
<ModBadge v-else-if="badge === 'mod'" class="h-14 w-14" />
<nuxt-link v-else-if="badge === 'plus'" to="/plus">
<PlusBadge class="h-14 w-14" />
</nuxt-link>
<TenMClubBadge v-else-if="badge === '10m-club'" class="h-14 w-14" />
<EarlyAdopterBadge v-else-if="badge === 'early-adopter'" class="h-14 w-14" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from "@modrinth/assets";
import { Promotion } from "@modrinth/ui";
import {
LibraryIcon,
BoxIcon,
LinkIcon,
LockIcon,
XIcon,
CalendarIcon,
DownloadIcon,
ClipboardCopyIcon,
MoreVerticalIcon,
} from "@modrinth/assets";
import { OverflowMenu, ButtonStyled } from "@modrinth/ui";
import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import Badge from "~/components/ui/Badge.vue";
import { reportUser } from "~/utils/report-helpers.ts";
import StaffBadge from "~/assets/images/badges/staff.svg?component";
import ModBadge from "~/assets/images/badges/mod.svg?component";
import PlusBadge from "~/assets/images/badges/plus.svg?component";
import TenMClubBadge from "~/assets/images/badges/10m-club.svg?component";
import EarlyAdopterBadge from "~/assets/images/badges/early-adopter.svg?component";
import ReportIcon from "~/assets/images/utils/report.svg?component";
import SunriseIcon from "~/assets/images/utils/sunrise.svg?component";
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import SettingsIcon from "~/assets/images/utils/settings.svg?component";
import UpToDate from "~/assets/images/illustrations/up_to_date.svg?component";
import UserIcon from "~/assets/images/utils/user.svg?component";
import EditIcon from "~/assets/images/utils/edit.svg?component";
import HeartIcon from "~/assets/images/utils/heart.svg?component";
import GridIcon from "~/assets/images/utils/grid.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import ImageIcon from "~/assets/images/utils/image.svg?component";
import WorldIcon from "~/assets/images/utils/world.svg?component";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import NavRow from "~/components/ui/NavRow.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Avatar from "~/components/ui/Avatar.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const data = useNuxtApp();
const route = useNativeRoute();
@@ -319,28 +336,41 @@ const formatCompactNumber = useCompactNumber();
const formatRelativeTime = useRelativeTime();
const messages = defineMessages({
profileProjectsStats: {
id: "profile.stats.projects",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}",
},
profileDownloadsStats: {
id: "profile.stats.downloads",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> download} other {<stat>{count}</stat> downloads}}",
"{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}",
},
profileProjectsFollowersStats: {
id: "profile.stats.projects-followers",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> follower} other {<stat>{count}</stat> followers}} of projects",
"{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}",
},
profileJoinedAt: {
id: "profile.joined-at",
defaultMessage: "Joined {ago}",
defaultMessage: "Joined <date>{ago}</date>",
},
profileUserId: {
id: "profile.user-id",
defaultMessage: "User ID: {id}",
},
profileDetails: {
id: "profile.label.details",
defaultMessage: "Details",
},
profileOrganizations: {
id: "profile.label.organizations",
defaultMessage: "Organizations",
},
profileBadges: {
id: "profile.label.badges",
defaultMessage: "Badges",
},
profileManageProjectsButton: {
id: "profile.button.manage-projects",
defaultMessage: "Manage projects",
@@ -353,10 +383,6 @@ const messages = defineMessages({
id: "profile.meta.description-with-bio",
defaultMessage: "{bio} - Download {username}'s projects on Modrinth",
},
profileReportButton: {
id: "profile.button.report",
defaultMessage: "Report",
},
profileNoProjectsLabel: {
id: "profile.label.no-projects",
defaultMessage: "This user has no projects!",
@@ -485,12 +511,64 @@ const sumFollows = computed(() => {
return sum;
});
function cycleSearchDisplayMode() {
cosmetics.value.searchDisplayMode.user = data.$cycleValue(
cosmetics.value.searchDisplayMode.user,
tags.value.projectViewModes,
);
const badges = computed(() => {
const badges = [];
if (user.value.role === "admin") {
badges.push("staff");
}
if (user.value.role === "moderator") {
badges.push("mod");
}
if (isPermission(user.value.badges, 1 << 0)) {
badges.push("plus");
}
if (sumDownloads.value > 10000000) {
badges.push("10m-club");
}
if (
isPermission(user.value.badges, 1 << 1) ||
isPermission(user.value.badges, 1 << 2) ||
isPermission(user.value.badges, 1 << 3)
) {
badges.push("early-adopter");
}
if (isPermission(user.value.badges, 1 << 4)) {
badges.push("alpha-tester");
}
if (isPermission(user.value.badges, 1 << 5)) {
badges.push("contributor");
}
if (isPermission(user.value.badges, 1 << 6)) {
badges.push("translator");
}
return badges;
});
async function copyId() {
await navigator.clipboard.writeText(project.value.id);
}
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),
href: `/user/${user.value.username}`,
},
...projectTypes.value.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/user/${user.value.username}/${x}s`,
};
}),
]);
</script>
<script>
export default defineNuxtComponent({
@@ -499,16 +577,6 @@ export default defineNuxtComponent({
</script>
<style lang="scss" scoped>
.organizations-grid {
// 5 wide
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
grid-gap: var(--gap-sm);
margin-top: 0.5rem;
}
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -579,107 +647,7 @@ export default defineNuxtComponent({
}
}
.user-header-wrapper {
display: flex;
margin: 0 auto -1.5rem;
max-width: 80rem;
.user-header {
position: relative;
z-index: 4;
display: flex;
width: 100%;
padding: 0 1rem;
gap: 1rem;
align-items: center;
.username {
display: none;
font-size: 2rem;
margin-bottom: 2.5rem;
}
}
}
.mobile-username {
margin: 0.25rem 0;
}
@media screen and (min-width: 501px) {
.mobile-username {
display: none;
}
.user-header-wrapper .user-header .username {
display: block;
}
}
.sidebar {
padding-top: 2.5rem;
}
.sidebar__item:not(:last-child) {
margin: 0 0 0.75rem 0;
}
.profile-picture {
border-radius: var(--size-rounded-lg);
height: 8rem;
width: 8rem;
}
.username {
font-size: var(--font-size-xl);
}
.bio {
display: block;
overflow-wrap: break-word;
}
.secondary-stat {
align-items: center;
display: flex;
margin-bottom: 0.8rem;
}
.secondary-stat__icon {
height: 1rem;
width: 1rem;
}
.secondary-stat__text {
margin-left: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date {
cursor: default;
}
.inputs {
margin-bottom: 1rem;
input {
margin-top: 0.5rem;
width: 100%;
}
label {
margin-bottom: 0;
}
}
.textarea-wrapper {
height: 10rem;
}
@media (max-width: 400px) {
.sidebar {
padding-top: 3rem;
}
.normal-page__header {
grid-area: header;
}
</style>