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,
CompassIcon,
DownloadIcon,
ExternalIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
@@ -18,6 +19,7 @@ import {
RestoreIcon,
RightArrowIcon,
SettingsIcon,
UserIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
@@ -69,7 +71,7 @@ import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/he
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.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 { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -817,29 +819,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<SettingsIcon />
</NavButton>
<ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
direction="left"
>
<Avatar
:src="credentials.user.avatar_url"
:alt="credentials.user.username"
size="32px"
circle
/>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
</ButtonStyled>
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
<LogInIcon />
<template #label>Sign in</template>
<OverflowMenu
v-if="credentials"
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"
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
},
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
placement="right-end"
>
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
Signed in as
<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>
</div>
<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 class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
<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>
<AccountsCard ref="accounts" mode="small" />
</suspense>
</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>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
<h3 class="px-4 text-lg 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 v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
<div class="space-y-4 flex flex-col items-center w-full">
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"

View File

@@ -24,7 +24,7 @@
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
@@ -40,22 +40,27 @@ defineExpose({
item.value = passedItem
options.value = passedOptions
const menuWidth = contextMenu.value.clientWidth
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'
}
// show to get dimensions
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]"
>
<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"
class="hidden light-image rounded-[inherit]"
/>
<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"
class="dark-image rounded-[inherit]"
/>

View File

@@ -1,41 +1,40 @@
<script setup lang="ts">
import {
MailIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
injectNotificationManager,
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { defineMessages, useVIntl } from '@vintl/vintl'
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 { get_user_many } from '@/helpers/cache'
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 formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: unknown | null
credentials: ModrinthCredentials | null
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const manageFriendsModal = ref()
const friendInvitesModal = ref()
const username = ref('')
@@ -47,61 +46,64 @@ async function addFriendFromModal() {
await loadFriends()
}
const friendOptions = ref()
async function handleFriendOptions(args) {
switch (args.option) {
case 'remove-friend':
await removeFriend(args.item)
break
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function addFriend(friend: Friend) {
await add_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: Friend) {
await remove_friend(
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
).catch(handleError)
await loadFriends()
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((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 filteredFriends = computed<FriendWithUserData[]>(() =>
sortedFriends.value.filter((x) =>
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
),
)
type Friend = {
id: string
friend_id: string | null
status: string | null
last_updated: Dayjs | null
created: Dayjs
username: string
accepted: boolean
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 activeFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
)
const onlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
)
const offlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !x.online && x.accepted),
)
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)
@@ -110,34 +112,7 @@ async function loadFriends(timeout = false) {
try {
const friendsList = await friends()
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,
}
})
}
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
@@ -166,49 +141,78 @@ const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
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>
<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">
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4">
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
<p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
<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 />
<div class="flex flex-col gap-2">
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
<div>
<p class="m-0">
<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 v-else>
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
@@ -246,77 +250,81 @@ onUnmounted(() => {
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="addFriendModal" header="Add a friend">
<div class="mb-4">
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
<input
v-model="username"
class="mt-2 w-full"
type="text"
placeholder="Enter username..."
@keyup.enter="addFriendFromModal"
/>
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="min-w-[30rem]">
<h2 class="m-0 text-base font-medium text-primary">
{{ formatMessage(messages.usernameTitle) }}
</h2>
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<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>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
</ModalWrapper>
<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 />
Add friend
</button>
</ButtonStyled>
</ModalWrapper>
<div class="flex justify-between items-center">
<h3 class="text-lg m-0">Friends</h3>
<ButtonStyled v-if="userCredentials" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'add-friend',
action: () => addFriendModal.show(),
},
{
id: 'manage-friends',
action: () => manageFriendsModal.show(),
shown: acceptedFriends.length > 0,
},
{
id: 'view-requests',
action: () => friendInvitesModal.show(),
shown: pendingFriends.length > 0,
},
]"
aria-label="More options"
<div class="iconified-input flex-1">
<input
v-model="search"
type="text"
class="friends-search-bar flex w-full"
:placeholder="formatMessage(messages.searchFriends)"
@keyup.esc="search = ''"
/>
<button
v-if="search"
v-tooltip="formatMessage(commonMessages.clearButton)"
class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
@click="search = ''"
>
<MoreVerticalIcon aria-hidden="true" />
<template #add-friend>
<UserPlusIcon aria-hidden="true" />
Add friend
</template>
<template #manage-friends>
<SettingsIcon aria-hidden="true" />
Manage friends
<div
v-if="acceptedFriends.length > 0"
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
>
{{ acceptedFriends.length }}
</div>
</template>
<template #view-requests>
<MailIcon aria-hidden="true" />
View friend requests
<div
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>
<XIcon />
</button>
</div>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
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"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2 mt-2">
<div class="flex flex-col gap-3">
<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 class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
@@ -325,10 +333,13 @@ onUnmounted(() => {
</div>
</div>
</template>
<template v-else-if="acceptedFriends.length === 0">
<div class="text-sm">
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm mx-4">
<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 v-else>
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
@@ -337,38 +348,54 @@ onUnmounted(() => {
</div>
</template>
<template v-else>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #remove-friend> <TrashIcon /> Remove friend </template>
</ContextMenu>
<div
v-for="friend in acceptedFriends.slice(0, 5)"
:key="friend.username"
class="flex gap-2 items-center"
:class="{ grayscale: !friend.online }"
@contextmenu.prevent.stop="
(event) =>
friendOptions.showMenu(event, friend, [
{
name: 'remove-friend',
color: 'danger',
},
])
"
>
<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 class="flex flex-col">
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
{{ friend.username }}
</span>
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
</div>
<FriendsSection
v-if="activeFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="activeFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="onlineFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="onlineFriends"
:heading="formatMessage(messages.online)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="offlineFriends.length > 0"
:is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3"
:friends="offlineFriends"
:heading="formatMessage(messages.offline)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
/>
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
</template>
</div>
</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'
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')
}
export async function logout() {
export async function logout(): Promise<void> {
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')
}
export async function cancelLogin() {
export async function cancelLogin(): Promise<void> {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -65,6 +65,63 @@
"app.update.reload-to-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": {
"message": "Add and play"
},

View File

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

View File

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

View File

@@ -188,6 +188,7 @@ import _UnplugIcon from './icons/unplug.svg?component'
import _UpdatedIcon from './icons/updated.svg?component'
import _UploadIcon from './icons/upload.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 _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
@@ -387,6 +388,7 @@ export const UnlinkIcon = _UnlinkIcon
export const UnplugIcon = _UnplugIcon
export const UpdatedIcon = _UpdatedIcon
export const UploadIcon = _UploadIcon
export const UserCogIcon = _UserCogIcon
export const UserPlusIcon = _UserPlusIcon
export const UserXIcon = _UserXIcon
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
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
import { article as whats_modrinth } from "./whats_modrinth";
import { article as two_years_of_modrinth } from "./two_years_of_modrinth";
import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history";
import { article as a_new_chapter_for_modrinth_servers } from "./a_new_chapter_for_modrinth_servers";
import { article as accelerating_development } from "./accelerating_development";
import { article as becoming_sustainable } from "./becoming_sustainable";
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_russian } from "./standing_by_our_values_russian";
import { article as skins_now_in_modrinth_app } from "./skins_now_in_modrinth_app";
import { article as russian_censorship } from "./russian_censorship";
import { article as redesign } from "./redesign";
import { article as pride_campaign_2025 } from "./pride_campaign_2025";
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";
import { article as two_years_of_modrinth } from "./two_years_of_modrinth";
import { article as two_years_of_modrinth_history } from "./two_years_of_modrinth_history";
import { article as whats_modrinth } from "./whats_modrinth";
import { article as windows_borderless_malware_disclosure } from "./windows_borderless_malware_disclosure";
export const articles = [
windows_borderless_malware_disclosure,

View File

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

View File

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

View File

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

View File

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

View File

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