From 0ec70d29b913666b06a6f4e3d702b412019dc2b8 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 30 Jun 2026 12:39:45 +0100 Subject: [PATCH 01/50] feat: implement instance share page + search_users backend call --- apps/app-frontend/src/helpers/users.js | 5 + .../app-frontend/src/pages/instance/Index.vue | 5 + .../app-frontend/src/pages/instance/Share.vue | 488 ++++++++++++++++++ apps/app-frontend/src/pages/instance/index.js | 3 +- apps/app-frontend/src/routes.js | 9 + apps/app/build.rs | 10 + apps/app/capabilities/plugins.json | 1 + apps/app/src/api/mod.rs | 1 + apps/app/src/api/users.rs | 13 + apps/app/src/main.rs | 1 + packages/app-lib/src/api/mod.rs | 3 +- packages/app-lib/src/api/users.rs | 28 + 12 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 apps/app-frontend/src/helpers/users.js create mode 100644 apps/app-frontend/src/pages/instance/Share.vue create mode 100644 apps/app/src/api/users.rs create mode 100644 packages/app-lib/src/api/users.rs diff --git a/apps/app-frontend/src/helpers/users.js b/apps/app-frontend/src/helpers/users.js new file mode 100644 index 0000000000..cb6c76673f --- /dev/null +++ b/apps/app-frontend/src/helpers/users.js @@ -0,0 +1,5 @@ +import { invoke } from '@tauri-apps/api/core' + +export async function search_user(query) { + return await invoke('plugin:users|search_user', { query }) +} diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 122edf3a84..7559c987ae 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -520,6 +520,11 @@ const tabs = computed(() => [ href: `${basePath.value}/logs`, icon: TerminalSquareIcon, }, + { + label: 'Share', + href: `${basePath.value}/share`, + icon: UserPlusIcon, + }, ]) if (instance.value) { diff --git a/apps/app-frontend/src/pages/instance/Share.vue b/apps/app-frontend/src/pages/instance/Share.vue new file mode 100644 index 0000000000..4ae984bcf9 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/Share.vue @@ -0,0 +1,488 @@ + + + diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js index b96705e883..ad9b0be8c0 100644 --- a/apps/app-frontend/src/pages/instance/index.js +++ b/apps/app-frontend/src/pages/instance/index.js @@ -3,6 +3,7 @@ import Index from './Index.vue' import Logs from './Logs.vue' import Mods from './Mods.vue' import Overview from './Overview.vue' +import Share from './Share.vue' import Worlds from './Worlds.vue' -export { Files, Index, Logs, Mods, Overview, Worlds } +export { Files, Index, Logs, Mods, Overview, Share, Worlds } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 5919805754..2baa1edd06 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -215,6 +215,15 @@ export default new createRouter({ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }], }, }, + { + path: 'share', + name: 'InstanceShare', + component: Instance.Share, + meta: { + useRootContext: true, + breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }], + }, + }, { path: '', name: 'Mods', diff --git a/apps/app/build.rs b/apps/app/build.rs index cda15ffcdf..efe910ae45 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -250,6 +250,16 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "users", + InlinedPlugin::new() + .commands(&[ + "search_user", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "utils", InlinedPlugin::new() diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json index 2cb2521766..4a30955f70 100644 --- a/apps/app/capabilities/plugins.json +++ b/apps/app/capabilities/plugins.json @@ -107,6 +107,7 @@ "settings:default", "shortcuts:default", "tags:default", + "users:default", "utils:default", "ads:default", "friends:default", diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 2d45357cbf..a3820d44bc 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -15,6 +15,7 @@ pub mod process; pub mod settings; pub mod shortcuts; pub mod tags; +pub mod users; pub mod utils; pub mod ads; diff --git a/apps/app/src/api/users.rs b/apps/app/src/api/users.rs new file mode 100644 index 0000000000..a6fbeeaa1f --- /dev/null +++ b/apps/app/src/api/users.rs @@ -0,0 +1,13 @@ +use crate::api::Result; +use theseus::users::SearchUser; + +#[tauri::command] +pub async fn search_user(query: &str) -> Result> { + Ok(theseus::users::search_user(query).await?) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("users") + .invoke_handler(tauri::generate_handler![search_user]) + .build() +} diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 96d6b9e33b..4cc30cb563 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -243,6 +243,7 @@ fn main() { .plugin(api::settings::init()) .plugin(api::shortcuts::init()) .plugin(api::tags::init()) + .plugin(api::users::init()) .plugin(api::utils::init()) .plugin(api::cache::init()) .plugin(api::files::init()) diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 927a666aaf..e5e4f46017 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -14,6 +14,7 @@ pub mod process; pub mod server_address; pub mod settings; pub mod tags; +pub mod users; pub mod worlds; pub mod data { @@ -27,7 +28,7 @@ pub mod data { ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize, - }; + }; pub use ariadne::users::UserStatus; pub use modrinth_content_management::{ ContentType, ResolutionPreferences, ResolveContentPlan, diff --git a/packages/app-lib/src/api/users.rs b/packages/app-lib/src/api/users.rs new file mode 100644 index 0000000000..e3fe2a9b39 --- /dev/null +++ b/packages/app-lib/src/api/users.rs @@ -0,0 +1,28 @@ +use crate::State; +use crate::util::fetch::fetch_json; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SearchUser { + pub id: String, + pub username: String, + pub avatar_url: Option, +} + +#[tracing::instrument] +pub async fn search_user(query: &str) -> crate::Result> { + let state = State::get().await?; + let query = urlencoding::encode(query); + + fetch_json( + Method::GET, + &format!("{}users/search?query={}", env!("MODRINTH_API_URL_V3"), query), + None, + None, + Some("/v3/users/search"), + &state.api_semaphore, + &state.pool, + ) + .await +} From 7c7f3584ac23cd7f03cf99db8170215170b24816 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Tue, 30 Jun 2026 12:54:42 +0100 Subject: [PATCH 02/50] feat: invite players modal --- packages/ui/src/components/index.ts | 1 + packages/ui/src/components/sharing/index.ts | 2 + .../sharing/invite-players-modal/index.vue | 504 ++++++++++++++++++ .../invite-players-modal-user-row.vue | 88 +++ .../sharing/invite-players-modal/types.ts | 27 + .../sharing/InvitePlayersModal.stories.ts | 170 ++++++ standards/frontend/COMPONENT_STRUCTURE.md | 128 +++++ 7 files changed, 920 insertions(+) create mode 100644 packages/ui/src/components/sharing/index.ts create mode 100644 packages/ui/src/components/sharing/invite-players-modal/index.vue create mode 100644 packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue create mode 100644 packages/ui/src/components/sharing/invite-players-modal/types.ts create mode 100644 packages/ui/src/stories/sharing/InvitePlayersModal.stories.ts create mode 100644 standards/frontend/COMPONENT_STRUCTURE.md diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 98a16429fc..6d99b45fcb 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -14,6 +14,7 @@ export * from './project' export * from './search' export * from './servers' export * from './settings' +export * from './sharing' export * from './skin' export * from './user' export * from './version' diff --git a/packages/ui/src/components/sharing/index.ts b/packages/ui/src/components/sharing/index.ts new file mode 100644 index 0000000000..9404102e49 --- /dev/null +++ b/packages/ui/src/components/sharing/index.ts @@ -0,0 +1,2 @@ +export { default as InvitePlayersModal } from './invite-players-modal/index.vue' +export * from './invite-players-modal/types' diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue new file mode 100644 index 0000000000..f45d938d61 --- /dev/null +++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue @@ -0,0 +1,504 @@ + + + diff --git a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue new file mode 100644 index 0000000000..10df2f0f3a --- /dev/null +++ b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-user-row.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/ui/src/components/sharing/invite-players-modal/types.ts b/packages/ui/src/components/sharing/invite-players-modal/types.ts new file mode 100644 index 0000000000..462fa15e6c --- /dev/null +++ b/packages/ui/src/components/sharing/invite-players-modal/types.ts @@ -0,0 +1,27 @@ +import type { RouteLocationRaw } from 'vue-router' + +export type InvitePlayersUserStatus = 'available' | 'pending' | 'added' +export type InvitePlayersUserProfileLink = + | RouteLocationRaw + | (() => void | Promise) + | undefined + +export interface InvitePlayersUser { + id: string + username: string + avatarUrl?: string | null + status?: InvitePlayersUserStatus + online?: boolean +} + +export interface InvitePlayersSearchUser { + id: string + username: string + avatarUrl?: string | null + email?: string +} + +export interface InvitePlayersInvitePayload { + user: InvitePlayersUser + source: 'friend' | 'search' +} diff --git a/packages/ui/src/stories/sharing/InvitePlayersModal.stories.ts b/packages/ui/src/stories/sharing/InvitePlayersModal.stories.ts new file mode 100644 index 0000000000..431039bbd6 --- /dev/null +++ b/packages/ui/src/stories/sharing/InvitePlayersModal.stories.ts @@ -0,0 +1,170 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import InvitePlayersModal from '../../components/sharing/invite-players-modal/index.vue' +import type { + InvitePlayersInvitePayload, + InvitePlayersUser, + InvitePlayersUserStatus, +} from '../../components/sharing/invite-players-modal/types' + +const apiUsers: Labrinth.Users.v3.SearchUser[] = [ + { + id: 'geometrically', + username: 'Geometrically', + avatar_url: 'https://cdn.modrinth.com/user/u6dRKJwZ/7ba3bdb11590a64843e9d2ab83ef85eaab42ec8e.png', + }, + { + id: 'prospector', + username: 'Prospector', + avatar_url: 'https://cdn.modrinth.com/user/PHyAPGui/30a3a3f53866531831db4aa006794e6bbcfc4121.png', + }, + { + id: 'fetch', + username: 'Fetch', + avatar_url: 'https://cdn.modrinth.com/user/yol4bNw3/ee2c7a7580ed475cfe3cfe8cc92df45ce33031e0.png', + }, + { + id: 'imb11', + username: 'IMB11', + avatar_url: null, + }, + { + id: 'josh', + username: 'Josh', + avatar_url: null, + }, + { + id: 'emma', + username: 'Emma', + avatar_url: null, + }, +] + +const friendStatuses: Record = { + geometrically: 'added', + fetch: 'pending', +} + +const meta = { + title: 'Sharing/InvitePlayersModal', + component: InvitePlayersModal, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'Invite players modal for app instance sharing and server player invites. Callers provide the user search proxy and own invite/cancel persistence.', + }, + }, + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +function toInviteUser( + user: Labrinth.Users.v3.SearchUser, + status: InvitePlayersUserStatus = 'available', +): InvitePlayersUser { + return { + id: user.id, + username: user.username, + avatarUrl: user.avatar_url, + status, + online: user.id === 'prospector', + } +} + +function createFriends() { + return apiUsers + .slice(0, 4) + .map((user) => toInviteUser(user, friendStatuses[user.id] ?? 'available')) +} + +function createSearchUsers() { + return apiUsers.map((user) => ({ + id: user.id, + username: user.username, + avatarUrl: user.avatar_url, + })) +} + +function createRender(args: Record) { + return { + components: { ButtonStyled, InvitePlayersModal }, + setup() { + const modalRef = ref | null>(null) + const friends = ref(createFriends()) + const searchUsers = createSearchUsers() + const lastAction = ref('') + + async function searchInviteUsers(query: string) { + await new Promise((resolve) => setTimeout(resolve, 250)) + const normalizedQuery = query.trim().toLowerCase() + return searchUsers.filter((user) => user.username.toLowerCase().startsWith(normalizedQuery)) + } + + function handleInvite(payload: InvitePlayersInvitePayload) { + const existingFriend = friends.value.find((friend) => friend.id === payload.user.id) + + if (existingFriend) { + existingFriend.status = 'pending' + } else { + friends.value = [{ ...payload.user, status: 'pending' }, ...friends.value] + } + + lastAction.value = `Invited ${payload.user.username} from ${payload.source}` + } + + function handleCancel(user: InvitePlayersUser) { + const existingFriend = friends.value.find((friend) => friend.id === user.id) + if (existingFriend) existingFriend.status = 'available' + lastAction.value = `Cancelled invite for ${user.username}` + } + + return { + args, + friends, + handleCancel, + handleInvite, + lastAction, + modalRef, + searchInviteUsers, + } + }, + template: /* html */ ` +
+ + + +

{{ lastAction }}

+ +
+ `, + } +} + +export const ShareInstance: Story = { + args: { + header: 'Share instance', + link: 'https://modrinth.com/instance/abc123', + }, + render: (args) => createRender(args), +} + +export const FriendsOnly: Story = { + args: { + header: 'Invite players', + }, + render: (args) => createRender(args), +} diff --git a/standards/frontend/COMPONENT_STRUCTURE.md b/standards/frontend/COMPONENT_STRUCTURE.md new file mode 100644 index 0000000000..99cfeb3837 --- /dev/null +++ b/standards/frontend/COMPONENT_STRUCTURE.md @@ -0,0 +1,128 @@ +# Component Structure + +## Component folders + +Prefer giving non-trivial components their own folder: + +``` +components/ +└── analytics-chart/ + ├── index.vue + ├── analytics-chart-header.vue + ├── analytics-chart-plot.vue + ├── analytics-chart-data.ts + └── use-analytics-chart.ts +``` + +The folder name should match the public component name in kebab case. The main component in that folder should be `index.vue`. + +This keeps imports short: + +```ts +import AnalyticsChart from '@/components/analytics-chart/index.vue' +``` + +If the local resolver supports directory indexes, importing the folder is also fine: + +```ts +import AnalyticsChart from '@/components/analytics-chart/' +``` + +Use the explicit `index.vue` import when the TypeScript setup cannot resolve the directory import reliably. + +## Local implementation files + +Keep files that only exist to support one component inside that component's folder: + +``` +analytics-chart/ +├── index.vue +├── analytics-chart-header.vue +├── analytics-chart-plot.vue +├── analytics-chart-tooltip.vue +├── chart-ranges.ts +└── use-chart-hover-state.ts +``` + +Good candidates for local files: + +- Small subcomponents used only by the main component +- Local composables used only by the main component or its local subcomponents +- Helpers that split up a large ` diff --git a/apps/app/build.rs b/apps/app/build.rs index efe910ae45..911b44e0e3 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -253,9 +253,7 @@ fn main() { .plugin( "users", InlinedPlugin::new() - .commands(&[ - "search_user", - ]) + .commands(&["search_user"]) .default_permission( DefaultPermissionRule::AllowAllCommands, ), diff --git a/apps/app/src/api/users.rs b/apps/app/src/api/users.rs index a6fbeeaa1f..b4c5dba75a 100644 --- a/apps/app/src/api/users.rs +++ b/apps/app/src/api/users.rs @@ -3,11 +3,11 @@ use theseus::users::SearchUser; #[tauri::command] pub async fn search_user(query: &str) -> Result> { - Ok(theseus::users::search_user(query).await?) + Ok(theseus::users::search_user(query).await?) } pub fn init() -> tauri::plugin::TauriPlugin { - tauri::plugin::Builder::new("users") - .invoke_handler(tauri::generate_handler![search_user]) - .build() + tauri::plugin::Builder::new("users") + .invoke_handler(tauri::generate_handler![search_user]) + .build() } diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index e5e4f46017..172f836176 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -28,7 +28,7 @@ pub mod data { ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize, - }; + }; pub use ariadne::users::UserStatus; pub use modrinth_content_management::{ ContentType, ResolutionPreferences, ResolveContentPlan, diff --git a/packages/app-lib/src/api/users.rs b/packages/app-lib/src/api/users.rs index e3fe2a9b39..6d5f4019f6 100644 --- a/packages/app-lib/src/api/users.rs +++ b/packages/app-lib/src/api/users.rs @@ -5,24 +5,28 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct SearchUser { - pub id: String, - pub username: String, - pub avatar_url: Option, + pub id: String, + pub username: String, + pub avatar_url: Option, } #[tracing::instrument] pub async fn search_user(query: &str) -> crate::Result> { - let state = State::get().await?; - let query = urlencoding::encode(query); + let state = State::get().await?; + let query = urlencoding::encode(query); - fetch_json( - Method::GET, - &format!("{}users/search?query={}", env!("MODRINTH_API_URL_V3"), query), - None, - None, - Some("/v3/users/search"), - &state.api_semaphore, - &state.pool, - ) - .await + fetch_json( + Method::GET, + &format!( + "{}users/search?query={}", + env!("MODRINTH_API_URL_V3"), + query + ), + None, + None, + Some("/v3/users/search"), + &state.api_semaphore, + &state.pool, + ) + .await } diff --git a/packages/ui/src/components/sharing/invite-players-modal/index.vue b/packages/ui/src/components/sharing/invite-players-modal/index.vue index f45d938d61..57732f8cf8 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/index.vue +++ b/packages/ui/src/components/sharing/invite-players-modal/index.vue @@ -56,7 +56,7 @@ @@ -126,80 +126,34 @@ - -
-
- {{ formatMessage(messages.expiryLabel) }} - -
-
- {{ formatMessage(messages.maxUsesLabel) }} - -
-
- - -
+ diff --git a/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue new file mode 100644 index 0000000000..60264bc2d1 --- /dev/null +++ b/packages/ui/src/components/sharing/invite-players-modal/invite-players-modal-invite-link-editor.vue @@ -0,0 +1,167 @@ + + + diff --git a/packages/ui/src/components/sharing/invite-players-modal/types.ts b/packages/ui/src/components/sharing/invite-players-modal/types.ts index 3e44d9f3c0..6936612594 100644 --- a/packages/ui/src/components/sharing/invite-players-modal/types.ts +++ b/packages/ui/src/components/sharing/invite-players-modal/types.ts @@ -30,3 +30,7 @@ export interface InviteLinkSettings { expiresAt: Date maxUses: number } + +export function normalizeInviteKey(value: string) { + return value.trim().toLowerCase() +} diff --git a/packages/ui/src/components/sharing/invite-players-modal/use-invite-players-search.ts b/packages/ui/src/components/sharing/invite-players-modal/use-invite-players-search.ts new file mode 100644 index 0000000000..6e93c82615 --- /dev/null +++ b/packages/ui/src/components/sharing/invite-players-modal/use-invite-players-search.ts @@ -0,0 +1,245 @@ +import { useDebounceFn } from '@vueuse/core' +import { computed, ref, type MaybeRefOrGetter, toValue, watch } from 'vue' + +import type { ComboboxOption } from '../../base/Combobox.vue' +import { + normalizeInviteKey, + type InvitePlayersInvitePayload, + type InvitePlayersSearchUser, + type InvitePlayersUser, + type InvitePlayersUserStatus, +} from './types' + +export function useInvitePlayersSearch(options: { + friends: MaybeRefOrGetter + suggestions: MaybeRefOrGetter + searchUsers: MaybeRefOrGetter< + ((query: string) => Promise) | undefined + > + canInvite: MaybeRefOrGetter + inviteDisabledMessage: MaybeRefOrGetter + alreadyInvitedMessage: MaybeRefOrGetter + searchingMessage: MaybeRefOrGetter + noResultsMessage: MaybeRefOrGetter + onInvite: (payload: InvitePlayersInvitePayload) => void +}) { + const searchTarget = ref('') + const searchInputKey = ref(0) + const selectedSearchUser = ref(null) + const remoteSearchUsers = ref([]) + const searchLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle') + const searchLookupRequestId = ref(0) + const friendOrder = ref(new Map()) + const searchMinimumLength = 1 + const passwordManagerIgnoreAttrs = { + 'data-1p-ignore': 'true', + 'data-bwignore': 'true', + 'data-form-type': 'other', + 'data-lpignore': 'true', + 'data-protonpass-ignore': 'true', + } + const normalizedSearchTarget = computed(() => searchTarget.value.trim()) + const usesRemoteSearch = computed(() => !!toValue(options.searchUsers)) + const searchLookupMessage = computed(() => + usesRemoteSearch.value && searchLookupStatus.value !== 'loaded' + ? toValue(options.searchingMessage) + : toValue(options.noResultsMessage), + ) + const searchableUsers = computed(() => { + const users = new Map() + for (const user of [...remoteSearchUsers.value, ...toValue(options.suggestions)]) { + users.set(normalizeInviteKey(user.id), user) + users.set(normalizeInviteKey(user.username), user) + if (user.email) users.set(normalizeInviteKey(user.email), user) + } + return [...new Set(users.values())] + }) + const searchOptions = computed[]>(() => + searchableUsers.value.map((user) => ({ + value: user.username, + label: user.username, + searchTerms: [user.username, user.id, user.email].filter(Boolean) as string[], + })), + ) + const sortedFriends = computed(() => + toValue(options.friends) + .map((friend, index) => ({ + friend, + order: friendOrder.value.get(friend.id) ?? friendOrder.value.size + index, + })) + .sort((a, b) => a.order - b.order) + .map(({ friend }) => friend), + ) + + function findSearchUser(value: string) { + const normalizedValue = normalizeInviteKey(value) + return searchableUsers.value.find( + (user) => + normalizeInviteKey(user.username) === normalizedValue || + normalizeInviteKey(user.id) === normalizedValue || + (!!user.email && normalizeInviteKey(user.email) === normalizedValue), + ) + } + + const matchedSearchUser = computed(() => { + if ( + selectedSearchUser.value && + normalizeInviteKey(selectedSearchUser.value.username) === + normalizeInviteKey(normalizedSearchTarget.value) + ) { + return selectedSearchUser.value + } + return findSearchUser(normalizedSearchTarget.value) + }) + const invitedUserKeys = computed(() => { + const keys = new Set() + for (const friend of toValue(options.friends)) { + if (friendStatus(friend) === 'available') continue + keys.add(normalizeInviteKey(friend.id)) + keys.add(normalizeInviteKey(friend.username)) + } + return keys + }) + const searchTargetAlreadyInvited = computed(() => { + const user = matchedSearchUser.value + if (!user) return false + return ( + invitedUserKeys.value.has(normalizeInviteKey(user.id)) || + invitedUserKeys.value.has(normalizeInviteKey(user.username)) + ) + }) + const canInviteSearchTarget = computed( + () => + toValue(options.canInvite) && + normalizedSearchTarget.value.length >= searchMinimumLength && + !!matchedSearchUser.value && + !searchTargetAlreadyInvited.value && + (!usesRemoteSearch.value || + searchLookupStatus.value === 'loaded' || + !!selectedSearchUser.value), + ) + const searchInviteTooltip = computed(() => { + if (!toValue(options.canInvite)) return toValue(options.inviteDisabledMessage) + if (searchTargetAlreadyInvited.value) return toValue(options.alreadyInvitedMessage) + return undefined + }) + + const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => { + const searchUsers = toValue(options.searchUsers) + if (!searchUsers) return + try { + const users = await searchUsers(query) + if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) + return + remoteSearchUsers.value = users + } catch { + if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) + return + remoteSearchUsers.value = [] + } finally { + if (requestId === searchLookupRequestId.value && query === normalizedSearchTarget.value) { + searchLookupStatus.value = 'loaded' + } + } + }, 250) + + function friendStatus(friend: InvitePlayersUser): InvitePlayersUserStatus { + return friend.status ?? 'available' + } + + function friendStatusSort(friend: InvitePlayersUser) { + switch (friendStatus(friend)) { + case 'available': + return 0 + case 'requested': + return 1 + case 'pending': + return 2 + case 'added': + return 3 + } + return 4 + } + + function syncFriendOrder(friends: InvitePlayersUser[]) { + const nextOrder = new Map(friendOrder.value) + let nextIndex = nextOrder.size + const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id)) + const orderedFriends = unorderedFriends + .map((friend, index) => ({ friend, index })) + .sort((a, b) => friendStatusSort(a.friend) - friendStatusSort(b.friend) || a.index - b.index) + if (orderedFriends.length === 0) return + for (const { friend } of orderedFriends) { + nextOrder.set(friend.id, nextIndex) + nextIndex += 1 + } + friendOrder.value = nextOrder + } + + function handleSearchInput(value: string) { + searchTarget.value = value + selectedSearchUser.value = null + remoteSearchUsers.value = [] + searchLookupRequestId.value += 1 + if (normalizedSearchTarget.value.length < searchMinimumLength) { + searchLookupStatus.value = 'idle' + return + } + if (!usesRemoteSearch.value) { + searchLookupStatus.value = 'loaded' + return + } + searchLookupStatus.value = 'loading' + void searchTargetUsers(normalizedSearchTarget.value, searchLookupRequestId.value) + } + + function handleSearchSelect(option: ComboboxOption) { + searchTarget.value = option.value + selectedSearchUser.value = findSearchUser(option.value) ?? null + searchLookupStatus.value = 'loaded' + } + + function resetSearch() { + searchTarget.value = '' + searchInputKey.value += 1 + selectedSearchUser.value = null + remoteSearchUsers.value = [] + searchLookupStatus.value = 'idle' + searchLookupRequestId.value += 1 + } + + function inviteSearchTarget() { + if (!canInviteSearchTarget.value) return + const user = matchedSearchUser.value + if (!user) return + options.onInvite({ + user: { + id: user.id, + username: user.username, + avatarUrl: user.avatarUrl, + }, + source: 'search', + }) + resetSearch() + } + + watch(() => toValue(options.friends), syncFriendOrder, { immediate: true }) + + return { + searchTarget, + searchInputKey, + searchOptions, + searchLookupMessage, + searchMinimumLength, + usesRemoteSearch, + passwordManagerIgnoreAttrs, + sortedFriends, + canInviteSearchTarget, + searchInviteTooltip, + findSearchUser, + handleSearchInput, + handleSearchSelect, + inviteSearchTarget, + resetSearch, + } +} From 877afa7278872c4d04ee1b61e4ce5b4642f0ad1f Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 10 Jul 2026 17:00:20 +0100 Subject: [PATCH 47/50] chore: reduced unused code --- .../src/components/ui/Instance.vue | 51 +++------------ packages/ui/src/components/base/NavTabs.vue | 63 +++++++------------ 2 files changed, 32 insertions(+), 82 deletions(-) diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 0c39843c7b..97dbca3039 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -39,12 +39,7 @@ const props = defineProps({ type: Boolean, default: false, }, - list: { - type: Boolean, - default: false, - }, }) -const emit = defineEmits(['select']) const playing = ref(false) const loading = ref(false) @@ -138,49 +133,23 @@ defineExpose({ const currentEvent = ref(null) -const unlisten = props.list - ? () => {} - : await process_listener((e) => { - if (e.instance_id === props.instance.id) { - currentEvent.value = e.event - if (e.event === 'finished') { - playing.value = false - } - } - }) +const unlisten = await process_listener((e) => { + if (e.instance_id === props.instance.id) { + currentEvent.value = e.event + if (e.event === 'finished') { + playing.value = false + } + } +}) onMounted(() => { - if (!props.list) { - checkProcess() - } + checkProcess() }) onUnmounted(() => unlisten())