diff --git a/.agents/skills/api-module/SKILL.md b/.agents/skills/api-module/SKILL.md new file mode 100644 index 0000000000..32cc16756b --- /dev/null +++ b/.agents/skills/api-module/SKILL.md @@ -0,0 +1,23 @@ +--- +name: api-module +description: Add an API endpoint module to packages/api-client from an OpenAPI schema. Use for new backend endpoints, API client modules, or tasks that provide an OpenAPI schema. +--- + +# Add an API Module + +Read the applicable `AGENTS.md` files before you edit code. + +Read [the API module standard](../../../standards/frontend/ADDING_API_MODULES.md) in full. + +1. Identify the OpenAPI schema from the request. If more than one schema is possible, ask the user to select one. +2. Read the schema. Identify each endpoint, HTTP method, request type, response type, and path parameter. +3. Get the service and version from the URL prefix. For example, map `/v3/projects` to `labrinth/v3/`. +4. Define the API types in `types.ts`. Make each type match the schema exactly. +5. Do not change, rename, or remove API fields. +6. Make a module class that extends `AbstractModule`. Implement each endpoint with `this.client.request()` or `this.client.upload()`. +7. Use the request-option pattern from the standard. Do not call `$fetch`, `fetch`, or another HTTP client directly. +8. Add the module to `MODULE_REGISTRY` so the client can instantiate it. +9. Export new service types from the applicable barrel `index.ts`. +10. Check the module paths, registry key, public type exports, and endpoint types. + +Run only the checks that the user or the applicable `AGENTS.md` permits. diff --git a/.agents/skills/api-module/agents/openai.yaml b/.agents/skills/api-module/agents/openai.yaml new file mode 100644 index 0000000000..c8dd768afe --- /dev/null +++ b/.agents/skills/api-module/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Add API Module" + short_description: "Add typed API client modules from OpenAPI" + default_prompt: "Use $api-module to add an API client module from this OpenAPI schema." diff --git a/.agents/skills/cross-platform-pages/SKILL.md b/.agents/skills/cross-platform-pages/SKILL.md new file mode 100644 index 0000000000..2399604821 --- /dev/null +++ b/.agents/skills/cross-platform-pages/SKILL.md @@ -0,0 +1,38 @@ +--- +name: cross-platform-pages +description: Convert a page to the shared Modrinth page system for the website and desktop app. Use for shared layouts, wrapped layouts, or platform dependency-injection contracts. +--- + +# Convert a Cross-Platform Page + +Read the applicable `AGENTS.md` files before you edit code. + +Read these standards in full: + +- [Cross-platform pages](../../../standards/frontend/CROSS_PLATFORM_PAGES.md) +- [Dependency injection](../../../standards/frontend/DEPENDENCY_INJECTION.md) + +1. Identify the target page from the request. +2. Read the page and its route shell. Identify data sources, mutations, navigation, and platform APIs. +3. Use a wrapped layout when both platforms use the same API source and page logic. +4. Use a shared layout when platform data or operations have different implementations. + +For a shared layout: + +1. Define a provider contract for all platform operations. +2. Put common UI and state logic in the shared layout. +3. Put reusable search, filter, and selection logic in local composables. +4. Implement the contract in `apps/frontend/` and `apps/app-frontend/`. +5. Use optional contract fields only for capabilities that are not available on both platforms. + +For a wrapped layout: + +1. Move the page to `packages/ui/src/layouts/wrapped/` and preserve its route structure. +2. Replace platform-only imports with common utilities or provider calls. +3. Make each frontend route shell render the wrapped component. +4. Match primary query options in both route shells when the layout uses `ReadyTransition` and `useReadyState`. +5. Prefetch these queries with `ensureQueryData`, as the standard specifies. + +Check that both route shells resolve their imports. Check that all required provider fields have implementations. + +Run only the checks that the user or the applicable `AGENTS.md` permits. diff --git a/.agents/skills/cross-platform-pages/agents/openai.yaml b/.agents/skills/cross-platform-pages/agents/openai.yaml new file mode 100644 index 0000000000..ad15d4df68 --- /dev/null +++ b/.agents/skills/cross-platform-pages/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Convert Cross-Platform Page" + short_description: "Share pages across the web and desktop app" + default_prompt: "Use $cross-platform-pages to convert this page for the website and desktop app." diff --git a/.agents/skills/figma-mcp/SKILL.md b/.agents/skills/figma-mcp/SKILL.md new file mode 100644 index 0000000000..b6d99eecca --- /dev/null +++ b/.agents/skills/figma-mcp/SKILL.md @@ -0,0 +1,21 @@ +--- +name: figma-mcp +description: Convert a Figma design into a Modrinth Vue page or component. Use when a request provides a Figma URL or asks to implement a Figma layout. +--- + +# Implement a Figma Design + +Read the applicable `AGENTS.md` files before you edit code. + +Read `packages/ui/AGENTS.md` in full. + +1. Load the available Figma design-to-code instructions and follow the MCP tool guidance. +2. Call `get_design_context` first with `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. +3. Treat the result as reference code and adapt it to the Modrinth codebase. +4. Map Figma color variables to the applicable `surface-*` and `text-*` tokens. Do not use aliased Figma names directly. +5. Reuse applicable components from `packages/ui/src/components/` before creating new ones. Also refer to `standards/frontend/COMPONENT_STRUCTURE.md` +6. Read `packages/assets/styles/variables.scss` when Figma does not supply a required token. +7. Use exact spacing values from the design. +8. Implement the result as a Vue SFC with Tailwind classes and the existing component library. + +Run only the checks that the user or the applicable `AGENTS.md` permits. diff --git a/.agents/skills/figma-mcp/agents/openai.yaml b/.agents/skills/figma-mcp/agents/openai.yaml new file mode 100644 index 0000000000..1a7c61d304 --- /dev/null +++ b/.agents/skills/figma-mcp/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Implement Figma Design" + short_description: "Build Modrinth Vue UI from Figma designs" + default_prompt: "Use $figma-mcp to implement this Figma design as a Modrinth Vue component." diff --git a/.agents/skills/i18n-pass/SKILL.md b/.agents/skills/i18n-pass/SKILL.md new file mode 100644 index 0000000000..05f69de695 --- /dev/null +++ b/.agents/skills/i18n-pass/SKILL.md @@ -0,0 +1,32 @@ +--- +name: i18n-pass +description: Convert hard-coded English text in changed Vue components to the @modrinth/ui localization system. Use for an i18n pass, untranslated-string review, pull request, or component migration. +--- + +# Do an Internationalization Pass + +Read the applicable `AGENTS.md` files before you edit code. + +Read [the internationalization standard](../../../standards/frontend/INTERNATIONALIZATION.md) in full. + +1. Identify the scope from the request. +2. For a pull request, use `gh pr diff ` to identify changed files. +3. For a file path, inspect that file. +4. When the request gives no scope, inspect the current uncommitted diff. +5. Limit the pass to changed `.vue` files. +6. Find user-visible text in templates and scripts. + +Check inner text, `alt`, `placeholder`, `aria-label`, buttons, tooltips, notifications, dropdown labels, and error messages. + +Do not change dynamic expressions, HTML tag names, CSS classes, internal identifiers, or log messages. + +1. Define stable message IDs with `defineMessage` or `defineMessages`. +2. Replace simple text with `formatMessage()` calls. +3. Use `` for text that contains links or markup. +4. Use ICU selections and plurals when grammar depends on a value. +5. Add a space before `}}` when an ICU placeholder ends at the Vue delimiter. +6. Do not change component logic, layout, or reactivity. +7. Do not edit localization JSON files. The user maintains those files. +8. Check the changed templates again for hard-coded English text. + +Run only the checks that the user or the applicable `AGENTS.md` permits. diff --git a/.agents/skills/i18n-pass/agents/openai.yaml b/.agents/skills/i18n-pass/agents/openai.yaml new file mode 100644 index 0000000000..347ef98b2d --- /dev/null +++ b/.agents/skills/i18n-pass/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Run Internationalization Pass" + short_description: "Localize user-visible text in Vue files" + default_prompt: "Use $i18n-pass to localize the user-visible text in these changed Vue files." diff --git a/.agents/skills/review-changelog/SKILL.md b/.agents/skills/review-changelog/SKILL.md new file mode 100644 index 0000000000..5ab041c1c9 --- /dev/null +++ b/.agents/skills/review-changelog/SKILL.md @@ -0,0 +1,40 @@ +--- +name: review-changelog +description: Review the latest packages/blog/changelog.ts entry against the Modrinth changelog standard. Use before a pull request or when asked to review or lint a changelog entry. +--- + +# Review a Changelog Entry + +Read [the changelog standard](../../../standards/maintaining/CHANGELOG.md) in full before the review. + +1. Open `packages/blog/changelog.ts`. +2. Find the first entry in the `VERSIONS` array. +3. If the request names `web`, `hosting`, or `app`, review the latest entry for that product. +4. Otherwise, review the latest entry and all adjacent entries with the same date. + +Check the entry structure: + +- `date` contains a valid ISO 8601 timestamp. +- `product` is `web`, `hosting`, or `app`. +- An `app` entry has a `version` value. +- A `web` or `hosting` entry does not have a `version` value. +- Standard headings are `## Added`, `## Changed`, `## Fixed`, and `## Security`. +- A featured release can use a linked heading. +- Flag the legacy `## Improvements` heading. + +Check each bullet: + +- The voice and tense agree with the section. +- The first verb agrees with the section. +- The bullet describes user-visible behavior, not implementation. +- The bullet identifies the applicable page, tab, modal, or feature. +- The bullet contains one sentence, uses sentence case, and ends with a period. +- Product and UI names use the public labels. +- The bullet does not contain filler, vague intensifiers, apologies, or internal references. +- The bullet is not a duplicate detail of a larger listed change. + +Group findings by entry. For each finding, show the original bullet and a proposed replacement. + +If the entry has no findings, state this result. Do not edit the changelog unless the user asks you to apply fixes. + +When the user asks for fixes, preserve tab indentation and template-literal formatting. diff --git a/.agents/skills/review-changelog/agents/openai.yaml b/.agents/skills/review-changelog/agents/openai.yaml new file mode 100644 index 0000000000..421afc3587 --- /dev/null +++ b/.agents/skills/review-changelog/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review Changelog" + short_description: "Review changelog entries for style problems" + default_prompt: "Use $review-changelog to review the latest changelog entry." diff --git a/.agents/skills/tanstack-query/SKILL.md b/.agents/skills/tanstack-query/SKILL.md new file mode 100644 index 0000000000..71c3ecc71b --- /dev/null +++ b/.agents/skills/tanstack-query/SKILL.md @@ -0,0 +1,39 @@ +--- +name: tanstack-query +description: Convert Vue server-state code to TanStack Query. Use for useQuery, useMutation, cache invalidation, optimistic updates, or replacement of useAsyncData and manual ref patterns. +--- + +# Convert Data Code to TanStack Query + +Read the applicable `AGENTS.md` files before you edit code. + +Read [the TanStack Query standard](../../../standards/frontend/FETCHING_DATA.md) in full. + +1. Identify the target file from the request. +2. Find `useAsyncData`, `useFetch`, manual API refs, and fetch calls in `onMounted`. +3. Identify mutations that use manual loading, error, or result refs. + +For queries: + +1. Replace manual fetch logic with `useQuery`. +2. Get `api-client` with `injectModrinthClient()`. +3. Use a hierarchical query key with the resource, qualifier, and parameters. +4. Use a computed query key for reactive parameters. +5. Use a computed `enabled` option when the query depends on other data. +6. Use a shared query-option factory when multiple components use the query. + +For mutations: + +1. Replace manual mutation state with `useMutation`. +2. Invalidate or update related query data after success. +3. Use an optimistic update only when the UI needs an immediate response. +4. Cancel the applicable query and save its prior data before an optimistic update. +5. Restore the prior data after an error. Invalidate the query after settlement. + +Remove manual loading and error refs that TanStack Query replaces. Remove obsolete `onMounted` fetch calls. + +Keep Nuxt SSR behavior. Match route-shell prefetch options when `ReadyTransition` and `useReadyState` depend on the query. + +Check query keys, invalidation prefixes, reactive values, and rollback data. + +Run only the checks that the user or the applicable `AGENTS.md` permits. diff --git a/.agents/skills/tanstack-query/agents/openai.yaml b/.agents/skills/tanstack-query/agents/openai.yaml new file mode 100644 index 0000000000..2db5ece878 --- /dev/null +++ b/.agents/skills/tanstack-query/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate to TanStack Query" + short_description: "Migrate Vue server state to TanStack Query" + default_prompt: "Use $tanstack-query to migrate this Vue component to TanStack Query." diff --git a/.claude/skills/api-module/SKILL.md b/.claude/skills/api-module/SKILL.md deleted file mode 100644 index 37b4ac7788..0000000000 --- a/.claude/skills/api-module/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: api-module -description: Add a new API endpoint module to packages/api-client from an OpenAPI schema. Use when adding new backend endpoints, creating API client modules, or when an openapi.yml is provided. -argument-hint: ---- - -Refer to the standard: @standards/frontend/ADDING_API_MODULES.md - -## Steps - -1. **Read the OpenAPI schema** at `$ARGUMENTS` — identify the endpoints, request/response shapes, and path parameters. -2. **Read the standard above** for naming conventions, type rules, and the module registration pattern. -3. **Determine the service and version** — the URL path prefix tells you which service directory and version namespace to use (e.g. `/v3/projects` → `labrinth/v3/`). -4. **Define types in `types.ts`** — types must match the API response 1:1. Use the OpenAPI schema as the source of truth. Do not reshape or rename fields. -5. **Create the module class** — extend `BaseModule`, implement each endpoint as a method. Use the correct HTTP verb and request options pattern from the standard. -6. **Register in `MODULE_REGISTRY`** — add the module entry so it's auto-instantiated on the client. -7. **Export types** from the service's barrel `index.ts`. -8. **Verify** — check that the module compiles and the types are accessible from `@modrinth/api-client`. diff --git a/.claude/skills/cross-platform-pages/SKILL.md b/.claude/skills/cross-platform-pages/SKILL.md deleted file mode 100644 index 6558eed3c3..0000000000 --- a/.claude/skills/cross-platform-pages/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: cross-platform-pages -description: Convert a page to the cross-platform page system so it works in both the website and the desktop app. Use when moving a page into packages/ui/src/layouts/, creating shared or wrapped layouts, or setting up DI contracts for platform abstraction. -argument-hint: ---- - -Refer to the standards: @standards/frontend/CROSS_PLATFORM_PAGES.md and @standards/frontend/DEPENDENCY_INJECTION.md - -## Steps - -1. **Read the target page** at `$ARGUMENTS` and understand its data sources, mutations, and navigation. -2. **Read the standards above** to understand the shared vs wrapped distinction and the DI pattern. -3. **Decide the category:** - - **Wrapped** (`layouts/wrapped/`) — if the page uses the same API source on both platforms (e.g. web requests, not Tauri plugins). Just move the page component into `packages/ui` and import it from both frontends. - - **Shared** (`layouts/shared/`) — if the page has different data-fetching logic per platform (e.g. website uses `api-client`, app uses Tauri `invoke`). Requires a DI contract. -4. **For shared layouts:** - - Define a DI contract interface in `providers/` capturing all platform-specific operations. - - Create the layout component that injects the context and handles all UI logic. - - Extract reusable stateful logic (search, filtering, selection) into `composables/`. - - Implement the contract separately in each frontend (`apps/frontend/`, `apps/app-frontend/`). -5. **For wrapped pages:** - - Move the page component into `packages/ui/src/layouts/wrapped/` matching the route structure. - - Replace any platform-specific imports with shared utilities. - - Import and render the wrapped page from both frontends as a simple component. - - If the layout uses TanStack Query for initial route paint with `ReadyTransition` / `useReadyState`, each platform route shell must call `ensureQueryData` for those queries with matching keys and fetchers — see **Platform route shells: prefetch with `ensureQueryData`** in `standards/frontend/CROSS_PLATFORM_PAGES.md`. -6. **Verify** the page renders correctly by checking for missing imports and that all DI contracts are satisfied. diff --git a/.claude/skills/figma-mcp/SKILL.md b/.claude/skills/figma-mcp/SKILL.md deleted file mode 100644 index defabf8d2e..0000000000 --- a/.claude/skills/figma-mcp/SKILL.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: figma-mcp -description: Use the Figma MCP server to translate a Figma design into a Vue page or component layout. Use when the user provides a Figma URL, asks to implement a design, or wants to draft a page layout from Figma. -argument-hint: ---- - -Refer to the standard: @standards/frontend/FIGMA_MCP_USAGE.md -Also read @packages/ui/CLAUDE.md for color token mapping and component conventions. - -## Steps - -1. **Parse the Figma URL** from `$ARGUMENTS` — extract the `fileKey` and `nodeId`. Convert `-` to `:` in the node ID. -2. **Read the standards above** for the available tools, adaptation rules, and color usage. -3. **Call `get_design_context`** with the extracted `nodeId` and `fileKey`, using `clientLanguages: "typescript,html,css"` and `clientFrameworks: "vue"`. This is always the first tool to call. -5. **Adapt the output to the Modrinth codebase:** - - Map Figma color variables to `surface-*` / `text-*` tokens — never use Figma's aliased names directly. - - Check `packages/ui/src/components/` for existing components that match elements in the design (buttons, cards, modals, inputs, etc.). - - Check `packages/assets/styles/variables.scss` for tokens not exposed in Figma. - - Match spacing values exactly from the design. -6. **Use `get_screenshot`** if you need a closer visual reference of specific nodes. -7. **Use `get_variable_defs`** to verify which design tokens are applied to ambiguous elements. -8. **Build the component** as a Vue SFC using Tailwind classes and the project's existing component library. diff --git a/.claude/skills/i18n-pass/SKILL.md b/.claude/skills/i18n-pass/SKILL.md deleted file mode 100644 index 7edd69996c..0000000000 --- a/.claude/skills/i18n-pass/SKILL.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: i18n-pass -description: Perform an i18n localization pass on changed files or a pull request, converting hard-coded English strings to the @modrinth/ui i18n system. Use when internationalizing a set of changes, reviewing a PR for untranslated strings, or converting a specific component. -argument-hint: [file-path-or-pr-number] ---- - -Refer to the standard: @standards/frontend/INTERNATIONALIZATION.md - -## Steps - -1. **Identify the scope of changes:** - - If `$ARGUMENTS` is a PR number, run `gh pr diff $ARGUMENTS` to get the changed files. - - If `$ARGUMENTS` is a file path, use that directly. - - If no argument, check `git diff` for uncommitted changes. -2. **Read the standard above** for the message definition pattern, ICU format rules, and `IntlFormatted` usage. -3. **Filter to Vue SFCs** — only `.vue` files need i18n passes. Skip non-component files. -4. **For each file, scan for hard-coded strings:** - - ` @@ -145,10 +146,10 @@ import { UserPlusIcon, XIcon, } from '@modrinth/assets' +import { Button, IconButton } from '@modrinth/ui' import { AutoLink, Avatar, - ButtonStyled, defineMessages, type SortDirection, StyledInput, @@ -161,6 +162,7 @@ import { } from '@modrinth/ui' import { computed, ref, watch } from 'vue' +import { injectSharedInstanceManagement } from './shared-instance-management-context' import { type MethodFilter, methodLabels, @@ -169,19 +171,15 @@ import { type ShareTableColumn, } from './shared-instance-share-types' -const props = defineProps<{ - rows: ShareRow[] - actionsLocked?: boolean - inviteDisabled?: boolean - invitePending?: boolean - pushUpdateDisabled?: boolean - pushUpdatePending?: boolean -}>() -const emit = defineEmits<{ - invite: [event: MouseEvent] - remove: [row: ShareRow] - 'push-update': [event: MouseEvent] -}>() +const management = injectSharedInstanceManagement() +const { + rows, + actionsLocked, + inviteDisabled, + invitePending, + pushUpdateDisabled, + pushUpdatePending, +} = management const search = ref('') const methodFilter = ref('all') const sortColumn = ref('joined') @@ -194,7 +192,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [ { id: 'direct', label: methodLabels.direct }, { id: 'link', label: methodLabels.link }, ] -const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1) +const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1) const columns = computed[]>(() => { const result: TableColumn[] = [ { @@ -230,7 +228,7 @@ const columns = computed[]>(() => { cellClass: 'whitespace-nowrap !px-2', }, ] - if (!props.actionsLocked) + if (!actionsLocked.value) result.push({ key: 'actions', label: 'Actions', @@ -243,7 +241,7 @@ const columns = computed[]>(() => { }) const filteredRows = computed(() => { const query = search.value.trim().toLowerCase() - return props.rows.filter((row) => { + return rows.value.filter((row) => { if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false if (!query) return true return [ @@ -285,7 +283,7 @@ function filterClass(active: boolean) { return [ 'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]', active - ? 'border-green bg-brand-highlight text-brand' + ? 'border-brand bg-brand-highlight text-brand' : 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5', ] } diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue index 1f7983dcdd..96d67be712 100644 --- a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue +++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue @@ -32,16 +32,12 @@
- - + +
@@ -52,7 +48,7 @@ import { UserXIcon, XIcon } from '@modrinth/assets' import { Admonition, Avatar, - ButtonStyled, + Button, commonMessages, defineMessages, NewModal, diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts index 801f9a3552..cd0c43304c 100644 --- a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts +++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts @@ -12,13 +12,14 @@ import { } from '@/helpers/instance' import type { GameInstance } from '@/helpers/types' +import { instanceKeys } from '../query-options' import { normalizeInviteKey, SHARED_INSTANCE_USER_LIMIT, type ShareRow, } from './shared-instance-share-types' -type MembersQueryKey = readonly ['sharedInstanceUsers', string] +type MembersQueryKey = ReturnType type OptimisticChange = { queryKey: MembersQueryKey @@ -48,7 +49,7 @@ export function useSharedInstanceMembers(options: { onError: (error: unknown) => void }) { const queryClient = useQueryClient() - const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const) + const queryKey = computed(() => instanceKeys.sharedMembers(options.instance.value.id)) const invitingUserIds = new Set() const removingUserIds = new Set() const exclusiveMutationPending = ref(false) diff --git a/apps/app-frontend/src/pages/instance/shared-instance-context.ts b/apps/app-frontend/src/pages/instance/shared-instance-context.ts new file mode 100644 index 0000000000..683a7a0686 --- /dev/null +++ b/apps/app-frontend/src/pages/instance/shared-instance-context.ts @@ -0,0 +1,181 @@ +import { createContext, injectAuth } from '@modrinth/ui' +import { useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, type Ref, ref, watch } from 'vue' + +import { useUserQuery } from '@/composables/users/use-user-query' +import { + getSharedInstanceUnavailableReason, + install_get_shared_instance_update_preview, + isSharedInstanceUnavailableError, + type SharedInstanceUnavailableReason, +} from '@/helpers/install' +import { can_current_user_use_shared_instances } from '@/helpers/instance' +import type { GameInstance } from '@/helpers/types' + +import { instanceKeys } from './query-options' + +export type SharedInstanceManager = + | { + type: 'user' + name: string + avatarUrl?: string + tintBy: string + } + | { + type: 'server' + name: string + avatarUrl?: string + tintBy: string + } + +export function createSharedInstanceContext( + instance: Ref, + offline: Ref, + notifyError: (error: unknown) => void, +) { + const auth = injectAuth() + const queryClient = useQueryClient() + const forcedUnavailableReason = ref(null) + + const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) + const wrongAccount = computed(() => { + if (auth.isReady && !auth.isReady.value) return false + if (!expectedUserId.value) return false + return auth.user.value?.id !== expectedUserId.value + }) + const actionsLocked = computed(() => wrongAccount.value) + const signedOut = computed(() => !auth.session_token.value) + const managerUserId = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + if (attachment.role === 'owner') { + return actionsLocked.value ? (attachment.linked_user_id ?? null) : null + } + return attachment.manager_id ?? null + }) + const managerUserQuery = useUserQuery(managerUserId) + const manager = computed(() => { + const attachment = instance.value?.shared_instance + if (!attachment) return null + + if (attachment.server_manager_name) { + return { + type: 'server', + name: attachment.server_manager_name, + avatarUrl: attachment.server_manager_icon_url ?? undefined, + tintBy: attachment.server_manager_name, + } + } + + const user = managerUserQuery.data.value + if (!user) return null + return { + type: 'user', + name: user.username, + avatarUrl: user.avatar_url ?? undefined, + tintBy: user.id, + } + }) + const unavailableManager = computed(() => manager.value?.name ?? null) + + const eligibilityQuery = useQuery({ + queryKey: computed(() => instanceKeys.sharedEligibility(auth.user.value?.id)), + queryFn: can_current_user_use_shared_instances, + enabled: () => !!auth.session_token.value && !!auth.user.value?.id, + retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) + const currentUserCanUseSharedInstances = computed( + () => !auth.session_token.value || eligibilityQuery.data.value !== false, + ) + + const updatePreviewQuery = useQuery({ + queryKey: computed(() => + instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id), + ), + queryFn: () => install_get_shared_instance_update_preview(instance.value!.id), + enabled: computed( + () => + !!instance.value?.id && + !!instance.value.shared_instance && + !actionsLocked.value && + !offline.value && + (auth.isReady?.value ?? true) && + !!auth.session_token.value && + !!auth.user.value?.id, + ), + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false, + }) + + watch(updatePreviewQuery.data, (preview) => { + if (preview !== undefined) forcedUnavailableReason.value = null + }) + watch(updatePreviewQuery.error, (error) => { + if (!error) return + if (isSharedInstanceUnavailableError(error)) { + forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error) + } else { + notifyError(error) + } + }) + + const unavailableReason = computed(() => forcedUnavailableReason.value) + const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) + const updatePreview = computed(() => + unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null), + ) + + watch( + () => instance.value?.id, + () => { + forcedUnavailableReason.value = null + }, + ) + + async function refreshAvailability() { + forcedUnavailableReason.value = null + if (!instance.value?.id) return + await queryClient.invalidateQueries({ + queryKey: instanceKeys.sharedUpdatePreview(instance.value.id, auth.user.value?.id), + }) + } + + async function refreshUpdatePreview() { + forcedUnavailableReason.value = null + if (!instance.value?.id || !auth.user.value?.id) return null + const result = await updatePreviewQuery.refetch({ throwOnError: true }) + return result.data ?? null + } + + function setUnavailable(reason: SharedInstanceUnavailableReason | null) { + forcedUnavailableReason.value = reason + } + + return { + actionsLocked, + shareActionsLocked, + unavailableReason, + unavailableManager, + manager, + updatePreview, + expectedUserId, + wrongAccount, + signedOut, + eligibilityQuery, + currentUserCanUseSharedInstances, + refreshAvailability, + refreshUpdatePreview, + setUnavailable, + } +} + +export type SharedInstanceContext = ReturnType + +export const [injectSharedInstance, provideSharedInstance] = createContext( + 'InstancePage', + 'sharedInstance', +) diff --git a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts deleted file mode 100644 index 408bd3c2cc..0000000000 --- a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { injectAuth } from '@modrinth/ui' -import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue' - -import { useUserQuery } from '@/composables/users/use-user-query' -import { - getSharedInstanceUnavailableReason, - install_get_shared_instance_update_preview, - isSharedInstanceUnavailableError, - type SharedInstanceUnavailableReason, -} from '@/helpers/install' -import type { GameInstance } from '@/helpers/types' - -export type SharedInstanceManager = - | { - type: 'user' - name: string - avatarUrl?: string - tintBy: string - } - | { - type: 'server' - name: string - avatarUrl?: string - tintBy: string - } - -export function useSharedInstanceState( - instance: Ref, - offline: Ref, - notifyError: (error: unknown) => void, -) { - const auth = injectAuth() - const updatePreview = - ref>>(null) - const updatePreviewLoaded = ref(false) - const unavailableReason = ref(null) - const availabilityCheckKey = ref(null) - const availabilityRefresh = ref(0) - let availabilityRequestId = 0 - let availabilityRequest: { - key: string - promise: Promise<{ - preview: Awaited> - error: unknown | null - }> - } | null = null - - const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null) - const wrongAccount = computed(() => { - if (auth.isReady && !auth.isReady.value) return false - if (!expectedUserId.value) return false - return auth.user.value?.id !== expectedUserId.value - }) - const actionsLocked = computed(() => wrongAccount.value) - const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null) - const signedOut = computed(() => !auth.session_token.value) - const managerUserId = computed(() => { - const attachment = instance.value?.shared_instance - if (!attachment) return null - if (attachment.role === 'owner') { - return actionsLocked.value ? (attachment.linked_user_id ?? null) : null - } - return attachment.manager_id ?? null - }) - const managerUserQuery = useUserQuery(managerUserId) - const manager = computed(() => { - const attachment = instance.value?.shared_instance - if (!attachment) return null - - if (attachment.server_manager_name) { - return { - type: 'server', - name: attachment.server_manager_name, - avatarUrl: attachment.server_manager_icon_url ?? undefined, - tintBy: attachment.server_manager_name, - } - } - - const user = managerUserQuery.data.value - if (!user) return null - return { - type: 'user', - name: user.username, - avatarUrl: user.avatar_url ?? undefined, - tintBy: user.id, - } - }) - const unavailableManager = computed(() => manager.value?.name ?? null) - - function reset() { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - unavailableReason.value = null - } - - function refreshAvailability() { - availabilityCheckKey.value = null - updatePreviewLoaded.value = false - availabilityRefresh.value++ - } - - function setUnavailable(reason: SharedInstanceUnavailableReason | null) { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - unavailableReason.value = reason - } - - async function checkAvailability(instanceId: string, key: string, throwError = false) { - const requestId = ++availabilityRequestId - let request = availabilityRequest - if (!request || request.key !== key) { - const promise = install_get_shared_instance_update_preview(instanceId).then( - (preview) => ({ preview, error: null }), - (error: unknown) => ({ preview: null, error }), - ) - request = { key, promise } - availabilityRequest = request - void promise.finally(() => { - if (availabilityRequest?.promise === promise) availabilityRequest = null - }) - } - - const result = await request.promise - if (!isCurrentRequest(requestId, instanceId, key)) return null - - if (result.error !== null) { - updatePreviewLoaded.value = false - if (isSharedInstanceUnavailableError(result.error)) { - updatePreview.value = null - unavailableReason.value = getSharedInstanceUnavailableReason(result.error) - } else if (!throwError) { - notifyError(result.error) - } - - if (throwError) throw result.error - return null - } - - updatePreview.value = result.preview - updatePreviewLoaded.value = true - unavailableReason.value = null - return result.preview - } - - async function refreshUpdatePreview() { - const instanceId = instance.value?.id - const userId = auth.user.value?.id - if (!instanceId || !userId) return null - - const key = `${instanceId}:${userId}` - availabilityCheckKey.value = key - return await checkAvailability(instanceId, key, true) - } - - function isCurrentRequest(requestId: number, instanceId: string, key: string) { - return ( - requestId === availabilityRequestId && - instance.value?.id === instanceId && - availabilityCheckKey.value === key - ) - } - - watch( - () => ({ - refresh: availabilityRefresh.value, - instanceId: instance.value?.id, - role: instance.value?.shared_instance?.role, - locked: actionsLocked.value, - offline: offline.value, - signedIn: !!auth.session_token.value, - userId: auth.user.value?.id ?? null, - authReady: auth.isReady?.value ?? true, - }), - async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => { - if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) { - availabilityRequestId++ - availabilityRequest = null - availabilityCheckKey.value = null - updatePreview.value = null - updatePreviewLoaded.value = false - if (instanceId && role) unavailableReason.value = null - return - } - - const key = `${instanceId}:${userId}` - if (availabilityCheckKey.value === key) return - availabilityCheckKey.value = key - await checkAvailability(instanceId, key) - }, - { immediate: true }, - ) - - return { - actionsLocked, - shareActionsLocked, - unavailableReason, - unavailableManager, - manager, - updatePreview, - expectedUserId, - wrongAccount, - signedOut, - reset, - refreshAvailability, - refreshUpdatePreview, - setUnavailable, - } -} - -export type SharedInstanceState = ReturnType - -const sharedInstanceStateKey: InjectionKey = Symbol('shared-instance-state') - -export function provideSharedInstanceState(state: SharedInstanceState) { - provide(sharedInstanceStateKey, state) -} - -export function injectSharedInstanceState() { - const state = inject(sharedInstanceStateKey) - if (!state) throw new Error('Shared instance state has not been provided.') - return state -} diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/worlds/index.vue similarity index 87% rename from apps/app-frontend/src/pages/instance/Worlds.vue rename to apps/app-frontend/src/pages/instance/worlds/index.vue index 1ddcb5e943..10f9118401 100644 --- a/apps/app-frontend/src/pages/instance/Worlds.vue +++ b/apps/app-frontend/src/pages/instance/worlds/index.vue @@ -35,23 +35,14 @@ " />
- - - - - - + +
@@ -72,12 +63,17 @@ {{ option.label }}
- - - +
- - diff --git a/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue b/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue index e4f62b43f0..606be33b05 100644 --- a/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue +++ b/apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue @@ -57,18 +57,14 @@
@@ -76,7 +72,7 @@ diff --git a/apps/frontend/src/components/ui/NotificationItem.vue b/apps/frontend/src/components/ui/NotificationItem.vue index 1bbac8bf38..4f3cfa001b 100644 --- a/apps/frontend/src/components/ui/NotificationItem.vue +++ b/apps/frontend/src/components/ui/NotificationItem.vue @@ -46,18 +46,18 @@ class="flex flex-wrap items-center gap-3" :class="{ 'gap-2': compact }" > - - - - - - + +
+ + + +
+
+
+ - - - -
-
-
- - - - +
- - - - Open link - - - - - - - - + + + Open link + + +
@@ -390,10 +399,12 @@ import { } from '@modrinth/assets' import { Avatar, - ButtonStyled, + Button, + ButtonLink, Categories, CopyCode, DoubleIcon, + IconButton, injectModrinthClient, injectNotificationManager, ProjectStatusBadge, diff --git a/apps/frontend/src/components/ui/OptionGroup.vue b/apps/frontend/src/components/ui/OptionGroup.vue deleted file mode 100644 index 36313921ef..0000000000 --- a/apps/frontend/src/components/ui/OptionGroup.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/ui/OrganizationPageHeader.vue b/apps/frontend/src/components/ui/OrganizationPageHeader.vue index c5f74542df..6063423a50 100644 --- a/apps/frontend/src/components/ui/OrganizationPageHeader.vue +++ b/apps/frontend/src/components/ui/OrganizationPageHeader.vue @@ -38,21 +38,19 @@ @@ -68,18 +66,17 @@ import { SettingsIcon, UsersIcon, } from '@modrinth/assets' +import { ButtonLink, TeleportOverflowMenu } from '@modrinth/ui' import { Avatar, - ButtonStyled, commonMessages, defineMessages, + type OverflowMenuOption, PageHeader, PageHeaderActions, PageHeaderBadgeItem, PageHeaderMetadata, PageHeaderMetadataNumberItem, - TeleportOverflowMenu, - type TeleportOverflowMenuItem, useFormatNumber, useVIntl, } from '@modrinth/ui' @@ -135,7 +132,7 @@ const emit = defineEmits<{ const { formatMessage } = useVIntl() const formatNumber = useFormatNumber() -const moreActions = computed(() => [ +const moreActions = computed(() => [ { id: 'manage-projects', label: formatMessage(messages.manageProjects), @@ -143,10 +140,7 @@ const moreActions = computed(() => [ action: () => emit('manageProjects'), shown: props.canManage, }, - { - divider: true, - shown: props.canManage, - }, + { type: 'divider', shown: props.canManage }, { id: 'copy-id', label: formatMessage(commonMessages.copyIdButton), diff --git a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue index 6f66486fd0..46524dbff5 100644 --- a/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue +++ b/apps/frontend/src/components/ui/OrganizationProjectTransferModal.vue @@ -65,38 +65,37 @@
- - - +
+ + diff --git a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue index 3f3b0d9ad5..247b350a73 100644 --- a/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue +++ b/apps/frontend/src/components/ui/moderation/SharedInstanceReportContext.vue @@ -11,9 +11,9 @@ import { UsersIcon, VersionIcon, } from '@modrinth/assets' +import { Button, IconButton } from '@modrinth/ui' import { Avatar, - ButtonStyled, ConfirmLeaveModal, type ContentItem, injectModrinthClient, @@ -322,12 +322,14 @@ function formattedLoader(version: SharedInstanceReportVersion) { - - - + @@ -363,15 +365,14 @@ function formattedLoader(version: SharedInstanceReportVersion) { {{ instance.member_count === 1 ? 'member' : 'members' }} - - - + + + @@ -392,12 +393,16 @@ function formattedLoader(version: SharedInstanceReportVersion) { launching them. - - - + diff --git a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue index 3727b6197c..e5790e3e84 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue +++ b/apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue @@ -24,9 +24,13 @@

{{ checklistTitleText }} - - diff --git a/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts b/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts index e5271651d6..b3d40f8d62 100644 --- a/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts +++ b/apps/frontend/src/components/ui/moderation/checklist/checklist-context.ts @@ -1,11 +1,5 @@ -import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation' -import type { ComputedRef, InjectionKey, Ref } from 'vue' - -export interface ActiveAction { - node: IdentifiedNodeBuilder - state: Record - statePath: string[] -} +import type { ActiveAction, NodeState } from '@modrinth/moderation/src/types/node' +import type { InjectionKey, Ref } from 'vue' export interface LiveNode { isActive: boolean @@ -17,7 +11,5 @@ export interface LiveNode { activeActions: ActiveAction[] } -export const NODE_META_KEY: InjectionKey>> = - Symbol('nodeMeta') export const STATE_KEY: InjectionKey>>> = Symbol('checklistState') diff --git a/apps/frontend/src/components/ui/moderation/settings/ModerationKeybind.vue b/apps/frontend/src/components/ui/moderation/settings/ModerationKeybind.vue index 3f13325e88..377c658252 100644 --- a/apps/frontend/src/components/ui/moderation/settings/ModerationKeybind.vue +++ b/apps/frontend/src/components/ui/moderation/settings/ModerationKeybind.vue @@ -2,15 +2,24 @@
+ {{ props.title }} - - - + + +
+ diff --git a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue index a0f385a639..1343a79171 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue @@ -35,16 +35,16 @@ > @@ -67,180 +67,189 @@ :open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined" > @@ -304,11 +313,13 @@
No versions created
Create your first project version.

- - - +
@@ -335,7 +346,8 @@ import { } from '@modrinth/assets' import { Admonition, - ButtonStyled, + Button, + ButtonLink, commonMessages, commonProjectSettingsMessages, ConfirmModal, @@ -343,8 +355,8 @@ import { injectModrinthClient, injectNotificationManager, injectProjectPageContext, - OverflowMenu, ProjectPageVersions, + TeleportOverflowMenu, useVIntl, } from '@modrinth/ui' import { useTemplateRef, watch } from 'vue' diff --git a/apps/frontend/src/pages/[type]/[project]/version/[version].vue b/apps/frontend/src/pages/[type]/[project]/version/[version].vue index 5facef220d..193aeb89f7 100644 --- a/apps/frontend/src/pages/[type]/[project]/version/[version].vue +++ b/apps/frontend/src/pages/[type]/[project]/version/[version].vue @@ -65,18 +65,19 @@
- - - - - - + +
@@ -104,16 +105,16 @@ > @@ -127,23 +128,39 @@ class="mb-4" > @@ -61,10 +60,10 @@ import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets' import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui' import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue' +import { Button } from '#ui/components/base/buttons' import { useDebugLogger } from '#ui/composables/debug-logger' import { injectFilePicker } from '../../../../providers' -import ButtonStyled from '../../../base/ButtonStyled.vue' import Combobox from '../../../base/Combobox.vue' import { injectCreationFlowContext } from '../creation-flow-context' diff --git a/packages/ui/src/components/modal/ConfirmLeaveModal.vue b/packages/ui/src/components/modal/ConfirmLeaveModal.vue index cba7b8468c..b49b7c0665 100644 --- a/packages/ui/src/components/modal/ConfirmLeaveModal.vue +++ b/packages/ui/src/components/modal/ConfirmLeaveModal.vue @@ -8,18 +8,14 @@ @@ -30,7 +26,7 @@ import { RightArrowIcon, XIcon } from '@modrinth/assets' import { ref } from 'vue' import Admonition from '#ui/components/base/Admonition.vue' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button } from '#ui/components/base/buttons' import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n' import NewModal from './NewModal.vue' diff --git a/packages/ui/src/components/modal/ConfirmModal.vue b/packages/ui/src/components/modal/ConfirmModal.vue index 87a091324e..157b909d20 100644 --- a/packages/ui/src/components/modal/ConfirmModal.vue +++ b/packages/ui/src/components/modal/ConfirmModal.vue @@ -31,18 +31,19 @@ wrapper-class="max-w-[20rem]" />
- - - - - - + +
@@ -53,7 +54,8 @@ import { TrashIcon, XIcon } from '@modrinth/assets' import { renderString } from '@modrinth/utils' import { computed, ref } from 'vue' -import ButtonStyled from '../base/ButtonStyled.vue' +import { Button } from '#ui/components/base/buttons' + import StyledInput from '../base/StyledInput.vue' import NewModal from './NewModal.vue' diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue index 803b831078..c5cb846bcd 100644 --- a/packages/ui/src/components/modal/NewModal.vue +++ b/packages/ui/src/components/modal/NewModal.vue @@ -43,33 +43,28 @@
- - - + +
- - - +