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/13] 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/13] 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 @@