You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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}`"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
185
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal file
185
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal 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>
|
||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
79
apps/app-frontend/src/helpers/friends.ts
Normal file
79
apps/app-frontend/src/helpers/friends.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
packages/assets/icons/user-cog.svg
Normal file
1
packages/assets/icons/user-cog.svg
Normal 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 |
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"button.cancel": {
|
"button.cancel": {
|
||||||
"defaultMessage": "Cancel"
|
"defaultMessage": "Cancel"
|
||||||
},
|
},
|
||||||
|
"button.clear": {
|
||||||
|
"defaultMessage": "Clear"
|
||||||
|
},
|
||||||
"button.close": {
|
"button.close": {
|
||||||
"defaultMessage": "Close"
|
"defaultMessage": "Close"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user