Updated ad placeholder graphics, update Modrinth App sidebar to mockup designs (#4584)

* Update ad placeholders to new green graphic

* Remove rounded corners from app ad frame

* Improve web ad placeholder styling

* Revamp app sidebar to match mockups more closely, greatly improve friends UX, fix up context menus and typify shit

* only show overflow on hover

* lint

* intl:extract

* clean up the inline code in FriendsSection
This commit is contained in:
Prospector
2025-10-19 16:26:17 -07:00
committed by GitHub
parent e58456eed4
commit 6a70acef25
19 changed files with 745 additions and 340 deletions

View File

@@ -4,6 +4,7 @@ import {
ChangeSkinIcon, ChangeSkinIcon,
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
ExternalIcon,
HomeIcon, HomeIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
@@ -18,6 +19,7 @@ import {
RestoreIcon, RestoreIcon,
RightArrowIcon, RightArrowIcon,
SettingsIcon, SettingsIcon,
UserIcon,
WorldIcon, WorldIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@@ -69,7 +71,7 @@ import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/he
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js' import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { list } from '@/helpers/profile.js' import { list } from '@/helpers/profile.js'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -817,29 +819,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<SettingsIcon /> <SettingsIcon />
</NavButton> </NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular> <OverflowMenu
<OverflowMenu v-if="credentials"
:options="[ v-tooltip.right="`Modrinth account`"
{ class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
id: 'sign-out', :options="[
action: () => logOut(), {
color: 'danger', id: 'view-profile',
}, action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
]" },
direction="left" {
> id: 'sign-out',
<Avatar action: () => logOut(),
:src="credentials.user.avatar_url" color: 'danger',
:alt="credentials.user.username" },
size="32px" ]"
circle placement="right-end"
/> >
<template #sign-out> <LogOutIcon /> Sign out </template> <Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
</OverflowMenu> <template #view-profile>
</ButtonStyled> <UserIcon />
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()"> <span class="inline-flex items-center gap-1">
<LogInIcon /> Signed in as
<template #label>Sign in</template> <span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
{{ credentials.user.username }}
</span>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
<LogInIcon class="text-brand" />
</NavButton> </NavButton>
</div> </div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex"> <div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
@@ -979,19 +991,19 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div> <div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }"> <div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"> <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-lg m-0">Playing as</h3> <h3 class="text-base text-primary font-medium m-0">Playing as</h3>
<suspense> <suspense>
<AccountsCard ref="accounts" mode="small" /> <AccountsCard ref="accounts" mode="small" />
</suspense> </suspense>
</div> </div>
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"> <div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense> <suspense>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" /> <FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense> </suspense>
</div> </div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center"> <div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3> <h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full"> <div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard <NewsArticleCard
v-for="(item, index) in news" v-for="(item, index) in news"
:key="`news-${index}`" :key="`news-${index}`"

View File

@@ -24,7 +24,7 @@
</template> </template>
<script setup> <script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue' import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked']) const emit = defineEmits(['menu-closed', 'option-clicked'])
@@ -40,22 +40,27 @@ defineExpose({
item.value = passedItem item.value = passedItem
options.value = passedOptions options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth // show to get dimensions
const menuHeight = contextMenu.value.clientHeight
if (menuWidth + event.pageX >= window.innerWidth) {
left.value = event.pageX - menuWidth + 2 + 'px'
} else {
left.value = event.pageX - 2 + 'px'
}
if (menuHeight + event.pageY >= window.innerHeight) {
top.value = event.pageY - menuHeight + 2 + 'px'
} else {
top.value = event.pageY - 2 + 'px'
}
shown.value = true shown.value = true
// then, adjust position if overflowing
nextTick(() => {
const menuWidth = contextMenu.value?.clientWidth || 200
const menuHeight = contextMenu.value?.clientHeight || 100
const minFromEdge = 10
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
} else {
left.value = event.pageX + minFromEdge + 'px'
}
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
} else {
top.value = event.pageY + minFromEdge + 'px'
}
})
}, },
}) })

View File

@@ -39,12 +39,12 @@ function updateAdPosition() {
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]" class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
> >
<img <img
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-light.webp" src="https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp"
alt="Host your next server with Modrinth Servers" alt="Host your next server with Modrinth Servers"
class="hidden light-image rounded-[inherit]" class="hidden light-image rounded-[inherit]"
/> />
<img <img
src="https://cdn-raw.modrinth.com/medal-modrinth-servers-dark.webp" src="https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp"
alt="Host your next server with Modrinth Servers" alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]" class="dark-image rounded-[inherit]"
/> />

View File

@@ -1,41 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
MailIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
commonMessages,
injectNotificationManager, injectNotificationManager,
OverflowMenu,
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Dayjs } from 'dayjs' import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue' import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events' import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends' import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: ModrinthCredentials | null
signIn: () => void signIn: () => void
}>() }>()
const userCredentials = computed(() => props.credentials) const userCredentials = computed(() => props.credentials)
const search = ref('') const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref() const friendInvitesModal = ref()
const username = ref('') const username = ref('')
@@ -47,61 +46,64 @@ async function addFriendFromModal() {
await loadFriends() await loadFriends()
} }
const friendOptions = ref() async function addFriend(friend: FriendWithUserData) {
async function handleFriendOptions(args) { const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
switch (args.option) { if (id) {
case 'remove-friend': await add_friend(id).catch(handleError)
await removeFriend(args.item) await loadFriends()
break
} }
} }
async function addFriend(friend: Friend) { async function removeFriend(friend: FriendWithUserData) {
await add_friend( const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, if (id) {
).catch(handleError) await remove_friend(id).catch(handleError)
await loadFriends() await loadFriends()
}
} }
async function removeFriend(friend: Friend) { const userFriends = ref<FriendWithUserData[]>([])
await remove_friend( const sortedFriends = computed<FriendWithUserData[]>(() =>
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id, userFriends.value.slice().sort((a, b) => {
).catch(handleError) if (a.last_updated === null && b.last_updated === null) {
await loadFriends() return 0 // Both are null, equal in sorting
} }
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const filteredFriends = computed<FriendWithUserData[]>(() =>
sortedFriends.value.filter((x) =>
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
),
)
type Friend = { const activeFriends = computed<FriendWithUserData[]>(() =>
id: string filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
friend_id: string | null )
status: string | null const onlineFriends = computed<FriendWithUserData[]>(() =>
last_updated: Dayjs | null filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
created: Dayjs )
username: string const offlineFriends = computed<FriendWithUserData[]>(() =>
accepted: boolean filteredFriends.value.filter((x) => !x.online && x.accepted),
online: boolean
avatar: string
}
const userFriends = ref<Friend[]>([])
const acceptedFriends = computed(() =>
userFriends.value
.filter((x) => x.accepted)
.toSorted((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
) )
const pendingFriends = computed(() => const pendingFriends = computed(() =>
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)), filteredFriends.value
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const incomingRequests = computed(() =>
userFriends.value
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
) )
const loading = ref(true) const loading = ref(true)
@@ -110,34 +112,7 @@ async function loadFriends(timeout = false) {
try { try {
const friendsList = await friends() const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
if (friendsList.length === 0) {
userFriends.value = []
} else {
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
)
userFriends.value = friendsList.map((friend) => {
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url,
username: user?.username,
online: !!status,
accepted: friend.accepted,
}
})
}
loading.value = false loading.value = false
} catch (e) { } catch (e) {
console.error('Error loading friends', e) console.error('Error loading friends', e)
@@ -166,49 +141,78 @@ const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()
}) })
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
},
addingAFriend: {
id: 'friends.add-friend.title',
defaultMessage: 'Adding a friend',
},
usernameTitle: {
id: 'friends.add-friend.username.title',
defaultMessage: "What's your friend's Modrinth username?",
},
usernameDescription: {
id: 'friends.add-friend.username.description',
defaultMessage: 'It may be different from their Minecraft username!',
},
usernamePlaceholder: {
id: 'friends.add-friend.username.placeholder',
defaultMessage: 'Enter Modrinth username...',
},
sendFriendRequest: {
id: 'friends.add-friend.submit',
defaultMessage: 'Send friend request',
},
viewFriendRequests: {
id: 'friends.action.view-friend-requests',
defaultMessage: '{count} friend requests',
},
searchFriends: {
id: 'friends.search-friends-placeholder',
defaultMessage: 'Search friends...',
},
friends: {
id: 'friends.heading',
defaultMessage: 'Friends',
},
pending: {
id: 'friends.heading.pending',
defaultMessage: 'Pending',
},
active: {
id: 'friends.heading.active',
defaultMessage: 'Active',
},
online: {
id: 'friends.heading.online',
defaultMessage: 'Online',
},
offline: {
id: 'friends.heading.offline',
defaultMessage: 'Offline',
},
noFriendsMatch: {
id: 'friends.no-friends-match',
defaultMessage: `No friends matching ''{query}''`,
},
})
</script> </script>
<template> <template>
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
<div
v-for="friend in acceptedFriends.filter(
(x) => !search || x.username.toLowerCase().includes(search),
)"
:key="friend.username"
class="flex gap-2 items-center"
>
<div class="relative">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<span
v-if="friend.online"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div>{{ friend.username }}</div>
<div class="ml-auto">
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
Remove
</button>
</ButtonStyled>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="friendInvitesModal" header="View friend requests"> <ModalWrapper ref="friendInvitesModal" header="View friend requests">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p> <p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4"> <div v-else class="flex flex-col gap-4 min-w-[40rem]">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2"> <div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> <Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="flex flex-col gap-2"> <div class="grid grid-cols-[1fr_auto] w-full gap-4">
<div> <div>
<p class="m-0"> <p class="m-0">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">
<span class="font-bold">{{ friend.username }}</span> sent you a friend request <span class="text-contrast">{{ friend.username }}</span> sent you a friend request
</template> </template>
<template v-else> <template v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
@@ -246,77 +250,81 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend"> <ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="mb-4"> <div class="min-w-[30rem]">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2> <h2 class="m-0 text-base font-medium text-primary">
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p> {{ formatMessage(messages.usernameTitle) }}
<input </h2>
v-model="username" <p class="m-0 mt-1 text-sm text-secondary leading-tight">
class="mt-2 w-full" {{ formatMessage(messages.usernameDescription) }}
type="text" </p>
placeholder="Enter username..." <div class="flex items-center gap-2 mt-4">
@keyup.enter="addFriendFromModal" <div class="iconified-input flex-1">
/> <UserIcon aria-hidden="true" />
<input
v-model="username"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
@keyup.enter="addFriendFromModal"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
</div>
</div> </div>
<ButtonStyled color="brand"> </ModalWrapper>
<button :disabled="username.length === 0" @click="addFriendFromModal"> <div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-2">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon /> <UserPlusIcon />
Add friend
</button> </button>
</ButtonStyled> </ButtonStyled>
</ModalWrapper> <div class="iconified-input flex-1">
<div class="flex justify-between items-center"> <input
<h3 class="text-lg m-0">Friends</h3> v-model="search"
<ButtonStyled v-if="userCredentials" type="transparent" circular> type="text"
<OverflowMenu class="friends-search-bar flex w-full"
:options="[ :placeholder="formatMessage(messages.searchFriends)"
{ @keyup.esc="search = ''"
id: 'add-friend', />
action: () => addFriendModal.show(), <button
}, v-if="search"
{ v-tooltip="formatMessage(commonMessages.clearButton)"
id: 'manage-friends', class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
action: () => manageFriendsModal.show(), @click="search = ''"
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
> >
<MoreVerticalIcon aria-hidden="true" /> <XIcon />
<template #add-friend> </button>
<UserPlusIcon aria-hidden="true" /> </div>
Add friend <ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
</template> <button
<template #manage-friends> v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
<SettingsIcon aria-hidden="true" /> class="relative"
Manage friends :aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
<div @click="friendInvitesModal.show"
v-if="acceptedFriends.length > 0" >
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center" <MailIcon />
> <span
{{ acceptedFriends.length }} v-if="incomingRequests.length > 0"
</div> aria-hidden="true"
</template> class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
<template #view-requests> >
<MailIcon aria-hidden="true" /> {{ incomingRequests.length }}
View friend requests </span>
<div </button>
v-if="pendingFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ pendingFriends.length }}
</div>
</template>
</OverflowMenu>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="flex flex-col gap-2 mt-2"> <div class="flex flex-col gap-3">
<template v-if="loading"> <template v-if="loading">
<h3 class="text-base text-primary font-medium m-0">{{ formatMessage(messages.friends) }}</h3>
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse"> <div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div> <div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
@@ -325,10 +333,13 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="acceptedFriends.length === 0"> <template v-else-if="sortedFriends.length === 0">
<div class="text-sm"> <div class="text-sm mx-4">
<div v-if="!userCredentials"> <div v-if="!userCredentials">
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends! <span class="font-semibold text-brand cursor-pointer" @click="signIn"
>Sign in to a Modrinth account</span
>
to add friends and see what they're playing!
</div> </div>
<div v-else> <div v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span> <span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
@@ -337,38 +348,54 @@ onUnmounted(() => {
</div> </div>
</template> </template>
<template v-else> <template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions"> <FriendsSection
<template #remove-friend> <TrashIcon /> Remove friend </template> v-if="activeFriends.length > 0"
</ContextMenu> :is-searching="!!search"
<div open-by-default
v-for="friend in acceptedFriends.slice(0, 5)" :friends="activeFriends"
:key="friend.username" :heading="formatMessage(messages.pending)"
class="flex gap-2 items-center" :remove-friend="removeFriend"
:class="{ grayscale: !friend.online }" />
@contextmenu.prevent.stop=" <FriendsSection
(event) => v-if="onlineFriends.length > 0"
friendOptions.showMenu(event, friend, [ :is-searching="!!search"
{ open-by-default
name: 'remove-friend', :friends="onlineFriends"
color: 'danger', :heading="formatMessage(messages.online)"
}, :remove-friend="removeFriend"
]) />
" <FriendsSection
> v-if="offlineFriends.length > 0"
<div class="relative"> :is-searching="!!search"
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle /> :open-by-default="activeFriends.length + onlineFriends.length < 3"
<span :friends="offlineFriends"
v-if="friend.online" :heading="formatMessage(messages.offline)"
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full" :remove-friend="removeFriend"
/> />
</div> <FriendsSection
<div class="flex flex-col"> v-if="pendingFriends.length > 0"
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }"> :is-searching="!!search"
{{ friend.username }} open-by-default
</span> :friends="pendingFriends"
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span> :heading="formatMessage(messages.pending)"
</div> :remove-friend="removeFriend"
</div> />
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
</template> </template>
</div> </div>
</template> </template>
<style scoped>
.friends-search-bar {
background: none;
border: 2px solid var(--color-button-bg) !important;
padding: 8px;
border-radius: 12px;
height: 36px;
}
.friends-search-bar::placeholder {
@apply text-sm font-normal;
}
</style>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import { Accordion, Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
friends: FriendWithUserData[]
heading: string
removeFriend: (friend: FriendWithUserData) => Promise<void>
isSearching?: boolean
openByDefault?: boolean
}>(),
{
isSearching: false,
openByDefault: false,
},
)
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
}
const friendOptions = useTemplateRef('friendOptions')
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
switch (args.option) {
case 'remove-friend':
case 'cancel-request':
await props.removeFriend(args.item)
break
case 'view-profile':
openProfile(args.item.username)
}
}
const messages = defineMessages({
removeFriend: {
id: 'friends.friend.remove-friend',
defaultMessage: 'Remove friend',
},
heading: {
id: 'friends.section.heading',
defaultMessage: '{title} - {count}',
},
friendRequestSent: {
id: 'friends.friend.request-sent',
defaultMessage: 'Friend request sent',
},
cancelRequest: {
id: 'friends.friend.cancel-request',
defaultMessage: 'Cancel request',
},
viewProfile: {
id: 'friends.friend.view-profile',
defaultMessage: 'View profile',
},
})
</script>
<template>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
</ContextMenu>
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
:button-class="
'px-4 flex w-full items-center bg-transparent border-0 p-0' +
(isSearching
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
"
>
<template #title>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
</h3>
</template>
<template #default>
<div class="pt-3 flex flex-col gap-1">
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full ml-4 mr-2"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
color: 'red',
},
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else type="transparent" circular>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
</Accordion>
</template>

View File

@@ -1,17 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
export async function friends() {
return await invoke('plugin:friends|friends')
}
export async function friend_statuses() {
return await invoke('plugin:friends|friend_statuses')
}
export async function add_friend(userId) {
return await invoke('plugin:friends|add_friend', { userId })
}
export async function remove_friend(userId) {
return await invoke('plugin:friends|remove_friend', { userId })
}

View File

@@ -0,0 +1,79 @@
import type { User } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export type UserStatus = {
user_id: string
profile_name: string | null
last_update: string
}
export type UserFriend = {
id: string
friend_id: string
accepted: boolean
created: string
}
export async function friends(): Promise<UserFriend[]> {
return await invoke('plugin:friends|friends')
}
export async function friend_statuses(): Promise<UserStatus[]> {
return await invoke('plugin:friends|friend_statuses')
}
export async function add_friend(userId: string): Promise<void> {
return await invoke('plugin:friends|add_friend', { userId })
}
export async function remove_friend(userId: string): Promise<void> {
return await invoke('plugin:friends|remove_friend', { userId })
}
export type FriendWithUserData = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
online: boolean
avatar: string
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (friends.length === 0) {
return []
}
const friendStatuses = await friend_statuses()
const users = await get_user_many(
friends.map((x) => (x.id === credentials?.user_id ? x.friend_id : x.id)),
)
return friends.map((friend) => {
const user = users.find((x: User) => x.id === friend.id || x.id === friend.friend_id)
const status = friendStatuses.find(
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
)
return {
id: friend.id,
friend_id: friend.friend_id,
status: status?.profile_name ?? null,
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
created: dayjs(friend.created),
avatar: user?.avatar_url ?? '',
username: user?.username ?? '',
online: !!status,
accepted: friend.accepted,
}
})
}

View File

@@ -5,18 +5,25 @@
*/ */
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
export async function login() { export type ModrinthCredentials = {
session: string
expires: string
user_id: string
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login') return await invoke('plugin:mr-auth|modrinth_login')
} }
export async function logout() { export async function logout(): Promise<void> {
return await invoke('plugin:mr-auth|logout') return await invoke('plugin:mr-auth|logout')
} }
export async function get() { export async function get(): Promise<ModrinthCredentials | null> {
return await invoke('plugin:mr-auth|get') return await invoke('plugin:mr-auth|get')
} }
export async function cancelLogin() { export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login') return await invoke('plugin:mr-auth|cancel_modrinth_login')
} }

View File

@@ -65,6 +65,63 @@
"app.update.reload-to-update": { "app.update.reload-to-update": {
"message": "Reload to install update" "message": "Reload to install update"
}, },
"friends.action.add-friend": {
"message": "Add a friend"
},
"friends.action.view-friend-requests": {
"message": "{count} friend requests"
},
"friends.add-friend.submit": {
"message": "Send friend request"
},
"friends.add-friend.title": {
"message": "Adding a friend"
},
"friends.add-friend.username.description": {
"message": "It may be different from their Minecraft username!"
},
"friends.add-friend.username.placeholder": {
"message": "Enter Modrinth username..."
},
"friends.add-friend.username.title": {
"message": "What's your friend's Modrinth username?"
},
"friends.friend.cancel-request": {
"message": "Cancel request"
},
"friends.friend.remove-friend": {
"message": "Remove friend"
},
"friends.friend.request-sent": {
"message": "Friend request sent"
},
"friends.friend.view-profile": {
"message": "View profile"
},
"friends.heading": {
"message": "Friends"
},
"friends.heading.active": {
"message": "Active"
},
"friends.heading.offline": {
"message": "Offline"
},
"friends.heading.online": {
"message": "Online"
},
"friends.heading.pending": {
"message": "Pending"
},
"friends.no-friends-match": {
"message": "No friends matching ''{query}''"
},
"friends.search-friends-placeholder": {
"message": "Search friends..."
},
"friends.section.heading": {
"message": "{title} - {count}"
},
"instance.add-server.add-and-play": { "instance.add-server.add-and-play": {
"message": "Add and play" "message": "Add and play"
}, },

View File

@@ -1,20 +1,23 @@
<template> <template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised"> <div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
<nuxt-link <AutoLink
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'" :to="currentAd.link"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]" :aria-label="currentAd.description"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
> >
<img <img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`" :src="currentAd.light"
alt="Host your next server with Modrinth Servers" aria-hidden="true"
:alt="currentAd.description"
class="light-image hidden rounded-[inherit]" class="light-image hidden rounded-[inherit]"
/> />
<img <img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`" :src="currentAd.dark"
alt="Host your next server with Modrinth Servers" aria-hidden="true"
:alt="currentAd.description"
class="dark-image rounded-[inherit]" class="dark-image rounded-[inherit]"
/> />
</nuxt-link> </AutoLink>
<div <div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised" class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
> >
@@ -23,6 +26,8 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { AutoLink } from '@modrinth/ui'
const flags = useFeatureFlags() const flags = useFeatureFlags()
useHead({ useHead({
@@ -55,6 +60,25 @@ useHead({
], ],
}) })
const AD_PRESETS = {
medal: {
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers?plan&ref=medal',
},
'modrinth-servers': {
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers',
},
}
const currentAd = computed(() =>
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
)
onMounted(() => { onMounted(() => {
window.tude = window.tude || { cmd: [] } window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] } window.Raven = window.Raven || { cmd: [] }
@@ -137,10 +161,14 @@ iframe[id^='google_ads_iframe'] {
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.ad-parent { .wrapper {
display: none; display: none;
} }
} }
.wrapper > * {
box-shadow: var(--shadow-card);
}
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -33,7 +33,6 @@
} }
#modrinth-rail-1 { #modrinth-rail-1 {
border-radius: 1rem;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;

View File

@@ -188,6 +188,7 @@ import _UnplugIcon from './icons/unplug.svg?component'
import _UpdatedIcon from './icons/updated.svg?component' import _UpdatedIcon from './icons/updated.svg?component'
import _UploadIcon from './icons/upload.svg?component' import _UploadIcon from './icons/upload.svg?component'
import _UserIcon from './icons/user.svg?component' import _UserIcon from './icons/user.svg?component'
import _UserCogIcon from './icons/user-cog.svg?component'
import _UserPlusIcon from './icons/user-plus.svg?component' import _UserPlusIcon from './icons/user-plus.svg?component'
import _UserXIcon from './icons/user-x.svg?component' import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component' import _UsersIcon from './icons/users.svg?component'
@@ -387,6 +388,7 @@ export const UnlinkIcon = _UnlinkIcon
export const UnplugIcon = _UnplugIcon export const UnplugIcon = _UnplugIcon
export const UpdatedIcon = _UpdatedIcon export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon export const UploadIcon = _UploadIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon export const UserPlusIcon = _UserPlusIcon
export const UserXIcon = _UserXIcon export const UserXIcon = _UserXIcon
export const UserIcon = _UserIcon export const UserIcon = _UserIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-cog-icon lucide-user-cog"><path d="M10 15H6a4 4 0 0 0-4 4v2"/><path d="m14.305 16.53.923-.382"/><path d="m15.228 13.852-.923-.383"/><path d="m16.852 12.228-.383-.923"/><path d="m16.852 17.772-.383.924"/><path d="m19.148 12.228.383-.923"/><path d="m19.53 18.696-.382-.924"/><path d="m20.772 13.852.924-.383"/><path d="m20.772 16.148.924.383"/><circle cx="18" cy="15" r="3"/><circle cx="9" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@@ -1,36 +1,36 @@
// AUTO-GENERATED FILE - DO NOT EDIT // AUTO-GENERATED FILE - DO NOT EDIT
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure"; import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_for_modrinth_servers";
import { article as whats_modrinth } from "./whats_modrinth"; import { article as accelerating_development } from "./accelerating_development";
import { article as two_years_of_modrinth } from "./two_years_of_modrinth"; import { article as becoming_sustainable } from "./becoming_sustainable";
import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history"; import { article as capital_return } from "./capital_return";
import { article as carbon_ads } from "./carbon_ads";
import { article as creator_monetization } from "./creator_monetization";
import { article as creator_update } from "./creator_update";
import { article as creator_updates_july_2025 } from "./creator_updates_july_2025";
import { article as design_refresh } from "./design_refresh";
import { article as download_adjustment } from "./download_adjustment";
import { article as free_server_medal } from "./free_server_medal";
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
import { article as licensing_guide } from "./licensing_guide";
import { article as modpack_changes } from "./modpack_changes";
import { article as modpacks_alpha } from "./modpacks_alpha";
import { article as modrinth_app_beta } from "./modrinth_app_beta";
import { article as modrinth_beta } from "./modrinth_beta";
import { article as modrinth_servers_asia } from "./modrinth_servers_asia";
import { article as modrinth_servers_beta } from "./modrinth_servers_beta";
import { article as new_environments } from "./new_environments";
import { article as new_site_beta } from "./new_site_beta";
import { article as plugins_resource_packs } from "./plugins_resource_packs";
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
import { article as redesign } from "./redesign";
import { article as russian_censorship } from "./russian_censorship";
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
import { article as standing_by_our_values } from "./standing_by_our_values"; import { article as standing_by_our_values } from "./standing_by_our_values";
import { article as standing_by_our_values_russian } from "./standing_by_our_values_russian"; import { article as standing_by_our_values_russian } from "./standing_by_our_values_russian";
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app"; import { article as two_years_of_modrinth } from "./two_years_of_modrinth";
import { article as russian_censorship } from "./russian_censorship"; import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history";
import { article as redesign } from "./redesign"; import { article as whats_modrinth } from "./whats_modrinth";
import { article as pride_campaign_2025 } from "./pride_campaign_2025"; import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
import { article as plugins_resource_packs } from "./plugins_resource_packs";
import { article as new_site_beta } from "./new_site_beta";
import { article as new_environments } from "./new_environments";
import { article as modrinth_servers_beta } from "./modrinth_servers_beta";
import { article as modrinth_servers_asia } from "./modrinth_servers_asia";
import { article as modrinth_beta } from "./modrinth_beta";
import { article as modrinth_app_beta } from "./modrinth_app_beta";
import { article as modpacks_alpha } from "./modpacks_alpha";
import { article as modpack_changes } from "./modpack_changes";
import { article as licensing_guide } from "./licensing_guide";
import { article as knossos_v2_1_0 } from "./knossos_v2_1_0";
import { article as free_server_medal } from "./free_server_medal";
import { article as download_adjustment } from "./download_adjustment";
import { article as design_refresh } from "./design_refresh";
import { article as creator_updates_july_2025 } from "./creator_updates_july_2025";
import { article as creator_update } from "./creator_update";
import { article as creator_monetization } from "./creator_monetization";
import { article as carbon_ads } from "./carbon_ads";
import { article as capital_return } from "./capital_return";
import { article as becoming_sustainable } from "./becoming_sustainable";
import { article as accelerating_development } from "./accelerating_development";
import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_for_modrinth_servers";
export const articles = [ export const articles = [
windows_borderless_malware_disclosure, windows_borderless_malware_disclosure,

View File

@@ -3,12 +3,13 @@
<button <button
v-if="!!slots.title" v-if="!!slots.title"
:class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'" :class="buttonClass ?? 'flex flex-col gap-2 bg-transparent m-0 p-0 border-none'"
@click="() => (isOpen ? close() : open())" @click="() => (forceOpen ? undefined : toggledOpen ? close() : open())"
> >
<slot name="button" :open="isOpen"> <slot name="button" :open="isOpen">
<div class="flex items-center gap-1 w-full"> <div class="flex items-center gap-1 w-full">
<slot name="title" /> <slot name="title" />
<DropdownIcon <DropdownIcon
v-if="!forceOpen"
class="ml-auto size-5 transition-transform duration-300 shrink-0" class="ml-auto size-5 transition-transform duration-300 shrink-0"
:class="{ 'rotate-180': isOpen }" :class="{ 'rotate-180': isOpen }"
/> />
@@ -28,7 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets' import { DropdownIcon } from '@modrinth/assets'
import { ref, useSlots } from 'vue' import { computed, ref, useSlots } from 'vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -37,6 +38,7 @@ const props = withDefaults(
buttonClass?: string buttonClass?: string
contentClass?: string contentClass?: string
titleWrapperClass?: string titleWrapperClass?: string
forceOpen?: boolean
}>(), }>(),
{ {
type: 'standard', type: 'standard',
@@ -44,27 +46,29 @@ const props = withDefaults(
buttonClass: null, buttonClass: null,
contentClass: null, contentClass: null,
titleWrapperClass: null, titleWrapperClass: null,
forceOpen: false,
}, },
) )
const isOpen = ref(props.openByDefault) const toggledOpen = ref(props.openByDefault)
const isOpen = computed(() => toggledOpen.value || props.forceOpen)
const emit = defineEmits(['onOpen', 'onClose']) const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots() const slots = useSlots()
function open() { function open() {
isOpen.value = true toggledOpen.value = true
emit('onOpen') emit('onOpen')
} }
function close() { function close() {
isOpen.value = false toggledOpen.value = false
emit('onClose') emit('onClose')
} }
defineExpose({ defineExpose({
open, open,
close, close,
isOpen, isOpen: toggledOpen,
}) })
defineOptions({ defineOptions({

View File

@@ -5,9 +5,11 @@
:disabled="disabled" :disabled="disabled"
:dropdown-id="dropdownId" :dropdown-id="dropdownId"
:tooltip="tooltip" :tooltip="tooltip"
:placement="placement"
> >
<slot></slot> <slot></slot>
<template #menu> <template #menu>
<slot name="menu-header" />
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)"> <template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div <div
v-if="isDivider(option)" v-if="isDivider(option)"
@@ -96,12 +98,14 @@ withDefaults(
disabled?: boolean disabled?: boolean
dropdownId?: string dropdownId?: string
tooltip?: string tooltip?: string
placement?: string
}>(), }>(),
{ {
options: () => [], options: () => [],
disabled: false, disabled: false,
dropdownId: undefined, dropdownId: undefined,
tooltip: undefined, tooltip: undefined,
placement: 'bottom-end',
}, },
) )

View File

@@ -3,7 +3,7 @@
ref="dropdown" ref="dropdown"
no-auto-focus no-auto-focus
:aria-id="dropdownId || null" :aria-id="dropdownId || null"
placement="bottom-end" :placement="placement"
:class="dropdownClass" :class="dropdownClass"
@apply-hide="focusTrigger" @apply-hide="focusTrigger"
@apply-show="focusMenuChild" @apply-show="focusMenuChild"
@@ -45,6 +45,11 @@ defineProps({
default: null, default: null,
required: false, required: false,
}, },
placement: {
type: String,
default: 'bottom-end',
required: false,
},
}) })
function focusMenuChild() { function focusMenuChild() {

View File

@@ -17,6 +17,9 @@
"button.cancel": { "button.cancel": {
"defaultMessage": "Cancel" "defaultMessage": "Cancel"
}, },
"button.clear": {
"defaultMessage": "Clear"
},
"button.close": { "button.close": {
"defaultMessage": "Close" "defaultMessage": "Close"
}, },

View File

@@ -25,6 +25,10 @@ export const commonMessages = defineMessages({
id: 'button.cancel', id: 'button.cancel',
defaultMessage: 'Cancel', defaultMessage: 'Cancel',
}, },
clearButton: {
id: 'button.clear',
defaultMessage: 'Clear',
},
closeButton: { closeButton: {
id: 'button.close', id: 'button.close',
defaultMessage: 'Close', defaultMessage: 'Close',