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

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>