diff --git a/.claude/skills/api-module/SKILL.md b/.claude/skills/api-module/SKILL.md new file mode 100644 index 000000000..37b4ac778 --- /dev/null +++ b/.claude/skills/api-module/SKILL.md @@ -0,0 +1,18 @@ +--- +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 new file mode 100644 index 000000000..6558eed3c --- /dev/null +++ b/.claude/skills/cross-platform-pages/SKILL.md @@ -0,0 +1,26 @@ +--- +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 new file mode 100644 index 000000000..defabf8d2 --- /dev/null +++ b/.claude/skills/figma-mcp/SKILL.md @@ -0,0 +1,22 @@ +--- +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 new file mode 100644 index 000000000..7edd69996 --- /dev/null +++ b/.claude/skills/i18n-pass/SKILL.md @@ -0,0 +1,24 @@ +--- +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:** + - ` - + - diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss index 493096396..a503a966e 100644 --- a/apps/app-frontend/src/assets/stylesheets/global.scss +++ b/apps/app-frontend/src/assets/stylesheets/global.scss @@ -45,6 +45,14 @@ color-scheme: dark; --view-width: calc(100% - 5rem); --expanded-view-width: calc(100% - 13rem); + --medal-promotion-bg: #000; + --medal-promotion-bg-orange: rgba(208, 246, 255, 0.25); + --medal-promotion-text-orange: #42abff; + --medal-promotion-bg-gradient: linear-gradient( + 90deg, + rgba(66, 170, 255, 0.15), + rgba(0, 0, 0, 0) 100% + ); } body { @@ -77,16 +85,10 @@ body { } a { - color: var(--color-link); + color: inherit; text-decoration: none; - - &:hover { - text-decoration: none; - } -} - -input { - border: none !important; + -webkit-font-smoothing: antialiased; + will-change: filter; } .badge { @@ -159,4 +161,75 @@ img { box-shadow: var(--shadow-card); } +// From the Bootstrap project +// The MIT License (MIT) +// Copyright (c) 2011-2023 The Bootstrap Authors +// https://github.com/twbs/bootstrap/blob/2f617215755b066904248525a8c56ea425dde871/scss/mixins/_visually-hidden.scss#L8 +.visually-hidden { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; + + &:not(caption) { + position: absolute !important; + } +} + @import '@modrinth/assets/omorphia.scss'; + +input { + border-radius: var(--size-rounded-sm); + box-sizing: border-box; + border: 2px solid transparent; + // safari iOS rounds inputs by default + // set the appearance to none to prevent this + appearance: none !important; +} + +pre { + font-weight: var(--font-weight-regular); +} + +input, +textarea { + background: var(--color-button-bg); + color: var(--color-text); + padding: 0.5rem 1rem; + font-weight: var(--font-weight-medium); + border: none; + outline: 2px solid transparent; + box-shadow: + var(--shadow-inset-sm), + 0 0 0 0 transparent; + transition: box-shadow 0.1s ease-in-out; + min-height: 36px; + + &:focus, + &:focus-visible { + box-shadow: + inset 0 0 0 transparent, + 0 0 0 0.25rem var(--color-brand-shadow); + color: var(--color-button-text-active); + } + + &:disabled, + &[disabled='true'] { + opacity: 0.6; + pointer-events: none; + cursor: not-allowed; + } + + &:focus::placeholder { + opacity: 0.8; + } + + &::placeholder { + color: var(--color-button-text); + opacity: 0.6; + } +} diff --git a/apps/app-frontend/src/components/GridDisplay.vue b/apps/app-frontend/src/components/GridDisplay.vue index c0e7fe7f1..3d6dc42a1 100644 --- a/apps/app-frontend/src/components/GridDisplay.vue +++ b/apps/app-frontend/src/components/GridDisplay.vue @@ -8,21 +8,28 @@ import { SearchIcon, StopCircleIcon, TrashIcon, - XIcon, } from '@modrinth/assets' -import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui' -import { formatCategoryHeader } from '@modrinth/utils' +import { + Accordion, + DropdownSelect, + formatLoader, + injectNotificationManager, + StyledInput, + useVIntl, +} from '@modrinth/ui' import { useStorage } from '@vueuse/core' import dayjs from 'dayjs' import { computed, ref } from 'vue' import ContextMenu from '@/components/ui/ContextMenu.vue' import Instance from '@/components/ui/Instance.vue' -import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' +import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import { duplicate, remove } from '@/helpers/profile.js' const { handleError } = injectNotificationManager() +const { formatMessage } = useVIntl() + const props = defineProps({ instances: { type: Array, @@ -127,12 +134,33 @@ const state = useStorage( { group: 'Group', sortBy: 'Name', + collapsedGroups: [], }, localStorage, { mergeDefaults: true }, ) const search = ref('') +const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? [])) + +const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}` + +const isSectionCollapsed = (sectionName) => { + return collapsedSectionKeys.value.has(getSectionKey(sectionName)) +} + +const setSectionCollapsed = (sectionName, collapsed) => { + const sectionKey = getSectionKey(sectionName) + const collapsedSections = new Set(state.value.collapsedGroups ?? []) + + if (collapsed) { + collapsedSections.add(sectionKey) + } else { + collapsedSections.delete(sectionKey) + } + + state.value.collapsedGroups = [...collapsedSections] +} const filteredResults = computed(() => { const { group = 'Group', sortBy = 'Name' } = state.value @@ -175,7 +203,7 @@ const filteredResults = computed(() => { if (group === 'Loader') { instances.forEach((instance) => { - const loader = formatCategoryHeader(instance.loader) + const loader = formatLoader(formatMessage, instance.loader) if (!instanceMap.has(loader)) { instanceMap.set(loader, []) } @@ -243,13 +271,14 @@ const filteredResults = computed(() => { diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index 363f07441..8e3a4cc27 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -18,16 +18,17 @@ import { useRouter } from 'vue-router' import ContextMenu from '@/components/ui/ContextMenu.vue' import Instance from '@/components/ui/Instance.vue' -import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' -import ProjectCard from '@/components/ui/ProjectCard.vue' +import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue' +import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import { trackEvent } from '@/helpers/analytics' import { get_by_profile_path } from '@/helpers/process.js' import { duplicate, kill, remove, run } from '@/helpers/profile.js' import { showProfileInFolder } from '@/helpers/utils.js' +import { injectContentInstall } from '@/providers/content-install' import { handleSevereError } from '@/store/error.js' -import { install as installVersion } from '@/store/install.js' const { handleError } = injectNotificationManager() +const { install: installVersion } = injectContentInstall() const router = useRouter() @@ -148,7 +149,7 @@ const handleOptionsClick = async (args) => { break case 'edit': await router.push({ - path: `/instance/${encodeURIComponent(args.item.path)}/`, + path: `/instance/${encodeURIComponent(args.item.path)}`, }) break case 'duplicate': @@ -238,14 +239,7 @@ onUnmounted(() => { - diff --git a/apps/app-frontend/src/components/ui/AppActionBar.vue b/apps/app-frontend/src/components/ui/AppActionBar.vue new file mode 100644 index 000000000..916edd96f --- /dev/null +++ b/apps/app-frontend/src/components/ui/AppActionBar.vue @@ -0,0 +1,607 @@ + + + + + diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue index ee12e17ea..032a569be 100644 --- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue +++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue @@ -1,64 +1,158 @@ - + + diff --git a/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue b/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue deleted file mode 100644 index c608c5477..000000000 --- a/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue +++ /dev/null @@ -1,380 +0,0 @@ - - - - - diff --git a/apps/app-frontend/src/components/ui/ErrorModal.vue b/apps/app-frontend/src/components/ui/ErrorModal.vue index 54c9cbd85..5f883fa11 100644 --- a/apps/app-frontend/src/components/ui/ErrorModal.vue +++ b/apps/app-frontend/src/components/ui/ErrorModal.vue @@ -6,6 +6,7 @@ import { HammerIcon, LogInIcon, UpdatedIcon, + WrenchIcon, XIcon, } from '@modrinth/assets' import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui' @@ -19,26 +20,21 @@ import { install } from '@/helpers/profile.js' import { cancel_directory_change } from '@/helpers/settings.ts' import { handleSevereError } from '@/store/error.js' -// This code is modified by AstralRinth -import { applyMigrationFix } from '@/helpers/utils.js' -import { restartApp } from '@/helpers/utils.js' - const { handleError } = injectNotificationManager() const errorModal = ref() const error = ref() const closable = ref(true) const errorCollapsed = ref(false) -const migrationFixSuccess = ref(null) // null | true | false -const migrationFixCallbackModel = ref() const title = ref('An error occurred') const errorType = ref('unknown') -const supportLink = ref('https://support.modrinth.com') +const supportLink = ref('https://astralium.su/product/astralrinth/support') const metadata = ref({}) defineExpose({ async show(errorVal, context, canClose = true, source = null) { + console.log(errorVal, context, canClose, source) closable.value = canClose if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) { @@ -65,7 +61,7 @@ defineExpose({ errorType.value = 'directory_move' supportLink.value = 'https://support.modrinth.com' - if (errorVal.message.includes('directory is not writeable')) { + if (errorVal.message.includes('directory is not writable')) { metadata.value.readOnly = true } @@ -78,7 +74,7 @@ defineExpose({ supportLink.value = 'https://support.modrinth.com' metadata.value.profilePath = context.profilePath } else if (source === 'state_init') { - title.value = 'Error initializing AstralRinth App' + title.value = 'Error initializing Modrinth App' errorType.value = 'state_init' supportLink.value = 'https://support.modrinth.com' } else { @@ -156,37 +152,17 @@ async function copyToClipboard(text) { copied.value = false }, 3000) } - -async function onApplyMigrationFix(eol) { - console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`) - try { - const result = await applyMigrationFix(eol) - migrationFixSuccess.value = result === true - console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result) - } catch (err) { - console.error(`[AR] • Failed to apply migration fix:`, err) - migrationFixSuccess.value = false - } finally { - migrationFixCallbackModel.value?.show?.() - if (migrationFixSuccess.value === true) { - setTimeout(async () => { - await restartApp() - }, 3000) - } - } -} -