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

@@ -0,0 +1,80 @@
<template>
<div>
<ButtonStyled v-if="!!slots.title" :type="type">
<button class="!w-full" @click="() => (isOpen ? close() : open())">
<slot name="title" /><DropdownIcon
class="ml-auto size-5 transition-transform duration-300"
:class="{ 'rotate-180': isOpen }"
/>
</button>
</ButtonStyled>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="{ 'mt-2': !!slots.title }" v-bind="$attrs" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = withDefaults(
defineProps<{
openByDefault?: boolean;
type?: "standard" | "outlined" | "transparent";
}>(),
{
type: "standard",
openByDefault: false,
},
);
const isOpen = ref(props.openByDefault);
const emit = defineEmits(["onOpen", "onClose"]);
const slots = useSlots();
function open() {
isOpen.value = true;
emit("onOpen");
}
function close() {
isOpen.value = false;
emit("onClose");
}
defineExpose({
open,
close,
isOpen,
});
defineOptions({
inheritAttrs: false,
});
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div class="ad-parent mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">90% of ad revenue goes to creators</p>
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</nuxt-link>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from "@modrinth/assets";
</script>
<style lang="scss" scoped>
@media (max-width: 1024px) {
.ad-parent {
display: none;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<div class="accordion-wrapper">
<div class="accordion-content">
<div>
<div class="content-container" v-bind="$attrs">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content:has(* .content-container:empty) {
grid-template-rows: 0fr;
}
.accordion-content > div {
overflow: hidden;
}
.accordion-wrapper:has(* .content-container:empty) {
display: contents;
}
</style>

View File

@@ -1,44 +1,19 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
<OmorphiaAvatar
:src="src"
:alt="alt"
:size="size"
:circle="circle"
:no-shadow="noShadow"
:loading="loading"
@load="updatePixelated"
:raised="raised"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 104 104"
aria-hidden="true"
>
<path fill="none" d="M0 0h103.4v103.4H0z" />
<path
fill="none"
stroke="#9a9a9a"
stroke-width="5"
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
/>
</svg>
</template>
<script setup>
const pixelated = ref(false);
const img = ref(null);
import { Avatar as OmorphiaAvatar } from "@modrinth/ui";
defineProps({
const props = defineProps({
src: {
type: String,
default: null,
@@ -49,10 +24,7 @@ defineProps({
},
size: {
type: String,
default: "sm",
validator(value) {
return ["xxs", "xs", "sm", "md", "lg"].includes(value);
},
default: "2rem",
},
circle: {
type: Boolean,
@@ -71,69 +43,4 @@ defineProps({
default: false,
},
});
function updatePixelated() {
if (img.value && img.value.naturalWidth && img.value.naturalWidth <= 96) {
pixelated.value = true;
} else {
pixelated.value = false;
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--size-rounded-icon);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
min-height: var(--size);
min-width: var(--size);
max-height: var(--size);
max-width: var(--size);
background-color: var(--color-button-bg);
object-fit: contain;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-md {
--size: 6rem;
border-radius: var(--size-rounded-lg);
}
&.size-lg {
--size: 9rem;
border-radius: var(--size-rounded-lg);
}
&.circle {
border-radius: 50%;
}
&.no-shadow {
box-shadow: none;
}
&.pixelated {
image-rendering: pixelated;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
</style>

View File

@@ -1,5 +1,9 @@
<template>
<span :class="'badge flex items-center gap-1 ' + color + ' type--' + type">
<span
:class="
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
"
>
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<!-- User roles -->
@@ -9,10 +13,11 @@
<template v-else-if="type === 'plus'"><PlusIcon /> Modrinth Plus</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'approved'"><GlobeIcon /> Public</template>
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</template>
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'unlisted' || type === 'withheld'"
><LinkIcon /> Unlisted</template
>
<template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
@@ -36,12 +41,12 @@
</template>
<script setup>
import { GlobeIcon, LinkIcon } from "@modrinth/assets";
import ModrinthIcon from "~/assets/images/logo.svg?component";
import PlusIcon from "~/assets/images/utils/plus.svg?component";
import ModeratorIcon from "~/assets/images/sidebar/admin.svg?component";
import CreatorIcon from "~/assets/images/utils/box.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import EyeOffIcon from "~/assets/images/utils/eye-off.svg?component";
import DraftIcon from "~/assets/images/utils/file-text.svg?component";
import CrossIcon from "~/assets/images/utils/x.svg?component";
import ArchiveIcon from "~/assets/images/utils/archive.svg?component";
@@ -65,12 +70,6 @@ defineProps({
<style lang="scss" scoped>
.badge {
font-weight: bold;
width: fit-content;
--badge-color: var(--color-gray);
color: var(--badge-color);
white-space: nowrap;
.circle {
width: 0.5rem;
height: 0.5rem;
@@ -110,7 +109,6 @@ defineProps({
}
&.type--creator,
&.type--approved,
&.blue {
--badge-color: var(--color-blue);
}
@@ -122,8 +120,9 @@ defineProps({
}
&.type--private,
&.type--approved,
&.gray {
--badge-color: var(--color-gray);
--badge-color: var(--color-secondary);
}
}
</style>

View File

@@ -36,23 +36,28 @@ export default {
props: {
label: {
type: String,
required: false,
default: "",
},
disabled: {
type: Boolean,
required: false,
default: false,
},
description: {
type: String,
required: false,
default: null,
},
modelValue: Boolean,
clickEvent: {
type: Function,
required: false,
default: () => {},
},
collapsingToggleStyle: {
type: Boolean,
required: false,
default: false,
},
},

View File

@@ -1,47 +1,59 @@
<template>
<Modal ref="modal" header="Create a collection">
<div class="universal-modal modal-creation universal-labels">
<div class="markdown-body">
<p>
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
</p>
<NewModal ref="modal" header="Creating a collection">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description">This appears on your collection's page.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="create">
<CheckIcon />
Continue
</Button>
<p class="m-0 max-w-[30rem]">
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="create">
<PlusIcon />
Create collection
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from "@modrinth/assets";
import { Modal, Button } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
import { NewModal, ButtonStyled } from "@modrinth/ui";
const router = useNativeRouter();
@@ -86,10 +98,10 @@ async function create() {
}
stopLoading();
}
function show() {
function show(event) {
name.value = "";
description.value = "";
modal.value.show();
modal.value.show(event);
}
defineExpose({

View File

@@ -1,241 +1,215 @@
<template>
<Modal ref="modal" header="Create a project">
<div class="modal-creation universal-labels">
<div class="markdown-body">
<p>New projects are created as drafts and can be found under your profile page.</p>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="updatedName()"
/>
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<NewModal ref="modal" header="Creating a project">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="slug"
v-model="slug"
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="manualSlug = true"
@input="updatedName()"
/>
</div>
<label for="visibility">
<span class="label__title">Visibility<span class="required">*</span></span>
<span class="label__description">
The visibility of your project after it has been approved.
</span>
</label>
<multiselect
id="visibility"
v-model="visibility"
:options="visibilities"
track-by="actual"
label="display"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose visibility.."
open-direction="bottom"
/>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description"
>This appears in search and on the sidebar of your project's page.</span
>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="push-right input-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="createProject">
<CheckIcon />
Continue
</button>
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Visibility
<span class="text-brand-red">*</span>
</span>
<span> The visibility of your project after it has been approved. </span>
</label>
<DropdownSelect
id="visibility"
v-model="visibility"
:options="visibilities"
:display-name="(x) => x.display"
name="Visibility"
/>
</div>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span> A sentence or two that describes your project. </span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createProject">
<PlusIcon />
Create project
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="cancel">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script>
import { Multiselect } from "vue-multiselect";
import CrossIcon from "~/assets/images/utils/x.svg?component";
import CheckIcon from "~/assets/images/utils/right-arrow.svg?component";
import Modal from "~/components/ui/Modal.vue";
<script setup>
import { NewModal, ButtonStyled, DropdownSelect } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
export default {
components: {
CrossIcon,
CheckIcon,
Modal,
Multiselect,
const router = useRouter();
const app = useNuxtApp();
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
},
props: {
organizationId: {
type: String,
required: false,
default: null,
},
});
const modal = ref();
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
const visibilities = ref([
{
actual: "approved",
display: "Public",
},
setup() {
const tags = useTags();
return { tags };
{
actual: "unlisted",
display: "Unlisted",
},
data() {
return {
name: "",
slug: "",
description: "",
manualSlug: false,
visibilities: [
{
actual: "approved",
display: "Public",
},
{
actual: "private",
display: "Private",
},
{
actual: "unlisted",
display: "Unlisted",
},
],
visibility: {
actual: "approved",
display: "Public",
},
};
{
actual: "private",
display: "Private",
},
methods: {
cancel() {
this.$refs.modal.hide();
},
async createProject() {
startLoading();
]);
const visibility = ref({
actual: "approved",
display: "Public",
});
const formData = new FormData();
const auth = await useAuth();
const projectData = {
title: this.name.trim(),
project_type: "mod",
slug: this.slug,
description: this.description.trim(),
body: "",
requested_status: this.visibility.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
is_draft: true,
};
if (this.organizationId) {
projectData.organization_id = this.organizationId;
}
formData.append("data", JSON.stringify(projectData));
try {
await useBaseFetch("project", {
method: "POST",
body: formData,
headers: {
"Content-Disposition": formData,
},
});
this.$refs.modal.hide();
await this.$router.push({
name: "type-id",
params: {
type: "project",
id: this.slug,
},
});
} catch (err) {
this.$notify({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
},
show() {
this.projectType = this.tags.projectTypes[0].display;
this.name = "";
this.slug = "";
this.description = "";
this.manualSlug = false;
this.$refs.modal.show();
},
updatedName() {
if (!this.manualSlug) {
this.slug = this.name
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
},
},
const cancel = () => {
modal.value.hide();
};
</script>
<style scoped lang="scss">
.modal-creation {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
async function createProject() {
startLoading();
.markdown-body {
margin-bottom: 0.5rem;
const formData = new FormData();
const auth = await useAuth();
const projectData = {
title: name.value.trim(),
project_type: "mod",
slug: slug.value,
description: description.value.trim(),
body: "",
requested_status: visibility.value.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
is_draft: true,
};
if (props.organizationId) {
projectData.organization_id = props.organizationId;
}
input {
width: 20rem;
max-width: 100%;
}
formData.append("data", JSON.stringify(projectData));
.text-input-wrapper {
width: 100%;
}
try {
await useBaseFetch("project", {
method: "POST",
body: formData,
headers: {
"Content-Disposition": formData,
},
});
textarea {
min-height: 5rem;
modal.value.hide();
await router.push({
name: "type-id",
params: {
type: "project",
id: slug.value,
},
});
} catch (err) {
app.$notify({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
}
.input-group {
margin-top: var(--spacing-card-md);
function show(event) {
name.value = "";
slug.value = "";
description.value = "";
manualSlug.value = false;
modal.value.show(event);
}
defineExpose({
show,
});
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
}
</style>
</script>

View File

@@ -306,8 +306,6 @@
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
position="top"
direction="left"
:options="[
{
id: 'withhold',

View File

@@ -4,7 +4,7 @@
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
@@ -51,8 +51,6 @@ const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
function pickLink() {
console.log("link is picking");
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === "" ? undefined : x.href) === route.path[props.query],
@@ -68,10 +66,12 @@ function pickLink() {
}
}
const linkElements = ref();
const rowLinkElements = ref();
function startAnimation() {
const el = linkElements.value[activeIndex.value].$el;
const el = rowLinkElements.value[activeIndex.value].$el;
if (!el || !el.offsetParent) return;
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop + el.offsetHeight;

View File

@@ -1,63 +1,84 @@
<template>
<nav class="relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold">
<nav
class="experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{ 'text-brand': activeIndex === index }"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-brand': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span>{{ link.label }}</span>
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<div
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-brand p-1 transition-all"
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'}`"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 0.25,
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
<script setup lang="ts">
const route = useNativeRoute();
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
});
interface Tab {
label: string;
href: string;
shown?: boolean;
icon?: string;
subpages?: string[];
}
const sliderPositionX = ref(0);
const sliderPositionY = ref(0);
const selectedElementWidth = ref(0);
const props = defineProps<{
links: Tab[];
query?: string;
}>();
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const activeIndex = ref(-1);
const oldIndex = ref(-1);
const subpageSelected = ref(false);
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const positionToMoveX = computed(() => `${sliderPositionX.value}px`);
const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
function pickLink() {
let index = -1;
subpageSelected.value = false;
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
if (decodeURIComponent(route.path).includes(filteredLinks.value[i].href)) {
const link = filteredLinks.value[i];
if (decodeURIComponent(route.path) === link.href) {
index = i;
break;
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i;
subpageSelected.value = true;
break;
}
}
activeIndex.value = index;
@@ -66,19 +87,57 @@ function pickLink() {
startAnimation();
} else {
oldIndex.value = -1;
sliderPositionX.value = 0;
selectedElementWidth.value = 0;
sliderLeft.value = 0;
sliderRight.value = 0;
}
}
const linkElements = ref();
const tabLinkElements = ref();
function startAnimation() {
const el = linkElements.value[activeIndex.value].$el;
const el = tabLinkElements.value[activeIndex.value].$el;
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop;
selectedElementWidth.value = el.offsetWidth;
if (!el || !el.offsetParent) return;
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
}
onMounted(() => {
@@ -92,3 +151,11 @@ onUnmounted(() => {
watch(route, () => pickLink());
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@@ -1,61 +1,78 @@
<template>
<Modal ref="modal" header="Create an organization">
<div class="universal-modal modal-creation universal-labels">
<div class="markdown-body">
<p>
Organizations can be found under your profile page. You will be set as its owner, but you
can invite other members and transfer ownership at any time.
</p>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="updateSlug()"
/>
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<NewModal ref="modal" header="Creating an organization">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="slug"
v-model="slug"
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="manualSlug = true"
@input="updateSlug()"
/>
</div>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description">This will appear on your organization's page.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="createProject">
<CheckIcon />
Continue
</Button>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span>A sentence or two that describes your organization.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
You will be the owner of this organization, but you can invite other members and transfer
ownership at any time.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createOrganization">
<PlusIcon />
Create organization
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from "@modrinth/assets";
import { Modal, Button } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter();
@@ -66,7 +83,7 @@ const manualSlug = ref(false);
const modal = ref();
async function createProject() {
async function createOrganization() {
startLoading();
try {
const value = {
@@ -95,10 +112,10 @@ async function createProject() {
}
stopLoading();
}
function show() {
function show(event) {
name.value = "";
description.value = "";
modal.value.show();
modal.value.show(event);
}
function updateSlug() {

View File

@@ -118,6 +118,7 @@ export default {
<style scoped lang="scss">
a {
position: relative;
color: var(--color-button-text);
box-shadow: var(--shadow-raised), var(--shadow-inset);

View File

@@ -19,7 +19,7 @@
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="author-actions universal-card"
class="author-actions universal-card mb-4"
>
<div class="header__row">
<div class="header__title">
@@ -93,7 +93,7 @@
</NuxtLink>
<button
v-else-if="nag.action"
class="iconified-button moderation-button"
class="btn btn-orange"
:disabled="nag.action.disabled()"
@click="nag.action.onClick"
>

View File

@@ -1,92 +1,85 @@
<template>
<div
v-if="
loaderFilters.length > 1 || gameVersionFilters.length > 1 || versionTypeFilters.length > 1
"
class="card search-controls"
>
<Multiselect
v-if="loaderFilters.length > 1"
v-model="selectedLoaders"
:options="loaderFilters"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="true"
:clear-search-on-select="false"
:show-labels="false"
:allow-empty="true"
placeholder="Filter loader..."
@update:model-value="updateQuery"
/>
<Multiselect
v-if="gameVersionFilters.length > 1"
v-model="selectedGameVersions"
:options="
includeSnapshots
? gameVersionFilters.map((x) => x.version)
: gameVersionFilters.filter((it) => it.version_type === 'release').map((x) => x.version)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:show-labels="false"
:hide-selected="true"
:selectable="() => selectedGameVersions.length <= 6"
placeholder="Filter versions..."
@update:model-value="updateQuery"
/>
<Multiselect
v-if="versionTypeFilters.length > 1"
v-model="selectedVersionTypes"
:options="versionTypeFilters"
:custom-label="(x) => $capitalizeString(x)"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="true"
:clear-search-on-select="false"
:show-labels="false"
:allow-empty="true"
placeholder="Filter channels..."
@update:model-value="updateQuery"
/>
<Checkbox
v-if="
gameVersionFilters.length > 1 &&
gameVersionFilters.some((v) => v.version_type !== 'release')
"
v-model="includeSnapshots"
label="Show all versions"
description="Show all versions"
:border="false"
@update:model-value="updateQuery"
/>
<button
title="Clear filters"
:disabled="selectedLoaders.length === 0 && selectedGameVersions.length === 0"
class="iconified-button"
@click="
() => {
selectedLoaders = [];
selectedGameVersions = [];
selectedVersionTypes = [];
updateQuery();
}
"
<div class="card flex-card experimental-styles-within">
<span class="text-lg font-bold text-contrast">Filter</span>
<div class="flex items-center gap-2">
<div class="iconified-input w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
placeholder="Search filters..."
autocomplete="off"
/>
</div>
<button
v-if="Object.keys(selectedFilters).length !== 0"
class="btn icon-only"
@click="clearFilters"
>
<FilterXIcon />
</button>
</div>
<div
v-for="(value, key, index) in filters"
:key="key"
:class="`border-0 border-b border-solid border-button-bg py-2 last:border-b-0`"
>
<ClearIcon />
Clear filters
</button>
<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();
}
"
>
<template v-if="key === 'gameVersion'"> Game versions </template>
<template v-else>
{{ $capitalizeString(key) }}
</template>
<DropdownIcon
class="ml-auto h-5 w-5 transition-transform"
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }"
/>
</button>
<Accordion ref="filterAccordions" :open-by-default="true">
<ScrollablePanel
:class="{ 'h-[18rem]': value.length >= 8 && key === 'gameVersion' }"
:no-max-height="key !== 'gameVersion'"
>
<div class="mr-1 flex flex-col gap-1">
<div v-for="filter in value" :key="filter" class="group flex gap-1">
<button
:class="`flex !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all active:scale-[0.98] ${selectedFilters[key]?.includes(filter) ? 'bg-brand-highlight text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg'}`"
@click="toggleFilter(key, filter)"
>
<span v-if="filter === 'release'" class="h-2 w-2 rounded-full bg-brand" />
<span v-else-if="filter === 'beta'" class="h-2 w-2 rounded-full bg-orange" />
<span v-else-if="filter === 'alpha'" class="h-2 w-2 rounded-full bg-red" />
<span class="truncate text-sm">{{ $formatCategory(filter) }}</span>
</button>
</div>
</div>
</ScrollablePanel>
<Checkbox
v-if="key === 'gameVersion'"
v-model="showSnapshots"
class="mx-2"
:label="`Show all versions`"
/>
</Accordion>
</div>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import Checkbox from "~/components/ui/Checkbox.vue";
import ClearIcon from "~/assets/images/utils/clear.svg?component";
import { DropdownIcon, FilterXIcon, SearchIcon } from "@modrinth/assets";
import { ScrollablePanel, Checkbox } from "@modrinth/ui";
import Accordion from "~/components/ui/Accordion.vue";
const props = defineProps({
versions: {
@@ -98,64 +91,137 @@ const props = defineProps({
});
const emit = defineEmits(["switch-page"]);
const router = useNativeRouter();
const route = useNativeRoute();
const router = useNativeRouter();
const tags = useTags();
const tempLoaders = new Set();
let tempVersions = new Set();
const tempReleaseChannels = new Set();
const filterAccordions = ref([]);
for (const version of props.versions) {
for (const loader of version.loaders) {
tempLoaders.add(loader);
const queryFilter = ref("");
const showSnapshots = ref(false);
const filters = computed(() => {
const filters = {};
const tempLoaders = new Set();
const tempVersions = new Set();
const tempReleaseChannels = new Set();
for (const version of props.versions) {
for (const loader of version.loaders) {
tempLoaders.add(loader);
}
for (const gameVersion of version.game_versions) {
tempVersions.add(gameVersion);
}
tempReleaseChannels.add(version.version_type);
}
for (const gameVersion of version.game_versions) {
tempVersions.add(gameVersion);
if (tempReleaseChannels.size > 0) {
filters.type = Array.from(tempReleaseChannels);
}
tempReleaseChannels.add(version.version_type);
if (tempVersions.size > 0) {
const gameVersions = tags.value.gameVersions.filter((x) => tempVersions.has(x.version));
filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => x.version);
}
if (tempLoaders.size > 0) {
filters.platform = Array.from(tempLoaders);
}
const filteredObj = {};
for (const [key, value] of Object.entries(filters)) {
const filters = queryFilter.value
? value.filter((x) => x.toLowerCase().includes(queryFilter.value.toLowerCase()))
: value;
if (filters.length > 0) {
filteredObj[key] = filters;
}
}
return filteredObj;
});
const selectedFilters = ref({});
if (route.query.type) {
selectedFilters.value.type = getArrayOrString(route.query.type);
}
if (route.query.gameVersion) {
selectedFilters.value.gameVersion = getArrayOrString(route.query.gameVersion);
}
if (route.query.platform) {
selectedFilters.value.platform = getArrayOrString(route.query.platform);
}
tempVersions = Array.from(tempVersions);
async function toggleFilters(type, filters) {
for (const filter of filters) {
await toggleFilter(type, filter);
}
const loaderFilters = shallowRef(Array.from(tempLoaders));
const gameVersionFilters = shallowRef(
tags.value.gameVersions.filter((gameVer) => tempVersions.includes(gameVer.version)),
);
const versionTypeFilters = shallowRef(Array.from(tempReleaseChannels));
const includeSnapshots = ref(route.query.s === "true");
const selectedGameVersions = shallowRef(getArrayOrString(route.query.g) ?? []);
const selectedLoaders = shallowRef(getArrayOrString(route.query.l) ?? []);
const selectedVersionTypes = shallowRef(getArrayOrString(route.query.c) ?? []);
async function updateQuery() {
await router.replace({
query: {
...route.query,
l: selectedLoaders.value.length === 0 ? undefined : selectedLoaders.value,
g: selectedGameVersions.value.length === 0 ? undefined : selectedGameVersions.value,
c: selectedVersionTypes.value.length === 0 ? undefined : selectedVersionTypes.value,
s: includeSnapshots.value ? true : undefined,
type: selectedFilters.value.type,
gameVersion: selectedFilters.value.gameVersion,
platform: selectedFilters.value.platform,
},
});
emit("switch-page", 1);
}
</script>
<style lang="scss" scoped>
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
async function toggleFilter(type, filter, skipRouter) {
if (!selectedFilters.value[type]) {
selectedFilters.value[type] = [];
}
.checkbox-outer {
min-width: fit-content;
const index = selectedFilters.value[type].indexOf(filter);
if (index !== -1) {
selectedFilters.value[type].splice(index, 1);
} else {
selectedFilters.value[type].push(filter);
}
if (selectedFilters.value[type].length === 0) {
delete selectedFilters.value[type];
}
if (!skipRouter) {
await router.replace({
query: {
...route.query,
type: selectedFilters.value.type,
gameVersion: selectedFilters.value.gameVersion,
platform: selectedFilters.value.platform,
},
});
emit("switch-page", 1);
}
}
</style>
async function clearFilters() {
selectedFilters.value = {};
await router.replace({
query: {
...route.query,
type: undefined,
gameVersion: undefined,
platform: undefined,
},
});
emit("switch-page", 1);
}
defineExpose({
toggleFilter,
toggleFilters,
});
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-button-bg bg-bg p-2"
>
<VersionChannelIndicator :channel="version.version_type" />
<div class="flex min-w-0 flex-col gap-1">
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
{{ version.name }}
</p>
</div>
<ButtonStyled color="brand">
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
<DownloadIcon /> Download
</a>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
class="min-w-0"
@click="emit('onNavigate')"
>
<ExternalIcon />
</nuxt-link>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
const props = defineProps<{
version: Version;
}>();
const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
return primary.url;
});
const emit = defineEmits(["onDownload", "onNavigate"]);
</script>

View File

@@ -19,7 +19,7 @@ export default defineComponent({
color: {
type: [String, Boolean],
default:
"repeating-linear-gradient(to right, var(--color-brand-green) 0%, var(--landing-green-label) 100%)",
"repeating-linear-gradient(to right, var(--color-green) 0%, var(--landing-green-label) 100%)",
},
},
setup(props, { slots }) {

View File

@@ -154,8 +154,6 @@
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
position="top"
direction="left"
:options="
replyBody
? [

View File

@@ -292,7 +292,7 @@ role-moderator {
}
.role-admin {
color: var(--color-brand-green);
color: var(--color-green);
}
.reporter-icon {